feat: add configurable operator appearance and layout
This commit is contained in:
1363
scripts/Invoke-LegacyPackageAppearanceSmoke.ps1
Normal file
1363
scripts/Invoke-LegacyPackageAppearanceSmoke.ps1
Normal file
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,8 @@ public static class LegacyPackageInputNative
|
||||
public int ScreenTop;
|
||||
public int Width;
|
||||
public int Height;
|
||||
public double CssScaleX;
|
||||
public double CssScaleY;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
@@ -391,13 +393,11 @@ public static class LegacyPackageInputNative
|
||||
Point source = CssPointToScreen(
|
||||
baseline,
|
||||
sourceCssX,
|
||||
sourceCssY,
|
||||
devicePixelRatio);
|
||||
sourceCssY);
|
||||
Point target = CssPointToScreen(
|
||||
baseline,
|
||||
targetCssX,
|
||||
targetCssY,
|
||||
devicePixelRatio);
|
||||
targetCssY);
|
||||
if (source.X == target.X && source.Y == target.Y)
|
||||
{
|
||||
throw new InvalidOperationException("The Windows drag source and target are identical.");
|
||||
@@ -429,7 +429,7 @@ public static class LegacyPackageInputNative
|
||||
source, true, false);
|
||||
|
||||
Point threshold = new Point {
|
||||
X = source.X + Math.Max(8, (int)Math.Round(12.0 * devicePixelRatio)),
|
||||
X = source.X + Math.Max(8, (int)Math.Round(12.0 * baseline.CssScaleX)),
|
||||
Y = source.Y
|
||||
};
|
||||
AssertPointOwnedByWindow(window, threshold);
|
||||
@@ -566,11 +566,11 @@ public static class LegacyPackageInputNative
|
||||
viewportCssHeight, devicePixelRatio, baseline);
|
||||
|
||||
Point rangeStart = CssPointToScreen(
|
||||
baseline, rangeStartCssX, rangeStartCssY, devicePixelRatio);
|
||||
baseline, rangeStartCssX, rangeStartCssY);
|
||||
Point rangeEnd = CssPointToScreen(
|
||||
baseline, rangeEndCssX, rangeEndCssY, devicePixelRatio);
|
||||
baseline, rangeEndCssX, rangeEndCssY);
|
||||
Point restore = CssPointToScreen(
|
||||
baseline, restoreCssX, restoreCssY, devicePixelRatio);
|
||||
baseline, restoreCssX, restoreCssY);
|
||||
if ((rangeStart.X == rangeEnd.X && rangeStart.Y == rangeEnd.Y) ||
|
||||
(rangeEnd.X == restore.X && rangeEnd.Y == restore.Y))
|
||||
{
|
||||
@@ -710,7 +710,7 @@ public static class LegacyPackageInputNative
|
||||
150, window, processId, forbiddenPort, viewportCssWidth,
|
||||
viewportCssHeight, devicePixelRatio, baseline);
|
||||
|
||||
Point point = CssPointToScreen(baseline, cssX, cssY, devicePixelRatio);
|
||||
Point point = CssPointToScreen(baseline, cssX, cssY);
|
||||
AssertPointOwnedByWindow(window, point);
|
||||
bool leftButtonDown = false;
|
||||
try
|
||||
@@ -784,7 +784,7 @@ public static class LegacyPackageInputNative
|
||||
150, window, processId, forbiddenPort, viewportCssWidth,
|
||||
viewportCssHeight, devicePixelRatio, baseline);
|
||||
|
||||
Point point = CssPointToScreen(baseline, cssX, cssY, devicePixelRatio);
|
||||
Point point = CssPointToScreen(baseline, cssX, cssY);
|
||||
AssertPointOwnedByWindow(window, point);
|
||||
bool leftButtonDown = false;
|
||||
try
|
||||
@@ -1056,16 +1056,31 @@ public static class LegacyPackageInputNative
|
||||
throw new InvalidOperationException("The package client rectangle is invalid.");
|
||||
}
|
||||
|
||||
int expectedWidth = (int)Math.Round(viewportCssWidth * devicePixelRatio);
|
||||
int expectedHeight = (int)Math.Round(viewportCssHeight * devicePixelRatio);
|
||||
if (expectedWidth <= 0 || expectedHeight <= 0 ||
|
||||
Math.Abs(client.Right - expectedWidth) > ClientPixelTolerance ||
|
||||
Math.Abs(client.Bottom - expectedHeight) > ClientPixelTolerance)
|
||||
int deviceWidth = (int)Math.Round(viewportCssWidth * devicePixelRatio);
|
||||
int deviceHeight = (int)Math.Round(viewportCssHeight * devicePixelRatio);
|
||||
int logicalWidth = (int)Math.Round(viewportCssWidth);
|
||||
int logicalHeight = (int)Math.Round(viewportCssHeight);
|
||||
bool deviceMatches = deviceWidth > 0 && deviceHeight > 0 &&
|
||||
Math.Abs(client.Right - deviceWidth) <= ClientPixelTolerance &&
|
||||
Math.Abs(client.Bottom - deviceHeight) <= ClientPixelTolerance;
|
||||
bool logicalMatches = logicalWidth > 0 && logicalHeight > 0 &&
|
||||
Math.Abs(client.Right - logicalWidth) <= ClientPixelTolerance &&
|
||||
Math.Abs(client.Bottom - logicalHeight) <= ClientPixelTolerance;
|
||||
if (!deviceMatches && !logicalMatches)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The browser viewport does not match the package client rectangle.");
|
||||
}
|
||||
|
||||
double cssScaleX = client.Right / viewportCssWidth;
|
||||
double cssScaleY = client.Bottom / viewportCssHeight;
|
||||
if (!IsFinitePositive(cssScaleX) || !IsFinitePositive(cssScaleY) ||
|
||||
Math.Abs(cssScaleX - cssScaleY) > 0.02)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The package client CSS coordinate scale is invalid.");
|
||||
}
|
||||
|
||||
Point origin = new Point { X = 0, Y = 0 };
|
||||
if (!ClientToScreen(window, ref origin))
|
||||
{
|
||||
@@ -1075,7 +1090,9 @@ public static class LegacyPackageInputNative
|
||||
ScreenLeft = origin.X,
|
||||
ScreenTop = origin.Y,
|
||||
Width = client.Right,
|
||||
Height = client.Bottom
|
||||
Height = client.Bottom,
|
||||
CssScaleX = cssScaleX,
|
||||
CssScaleY = cssScaleY
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1321,8 +1338,7 @@ public static class LegacyPackageInputNative
|
||||
private static Point CssPointToScreen(
|
||||
ClientGeometry geometry,
|
||||
double cssX,
|
||||
double cssY,
|
||||
double devicePixelRatio)
|
||||
double cssY)
|
||||
{
|
||||
if (Double.IsNaN(cssX) || Double.IsInfinity(cssX) ||
|
||||
Double.IsNaN(cssY) || Double.IsInfinity(cssY) || cssX < 0 || cssY < 0)
|
||||
@@ -1330,8 +1346,8 @@ public static class LegacyPackageInputNative
|
||||
throw new InvalidOperationException("A CSS input point is invalid.");
|
||||
}
|
||||
|
||||
int x = (int)Math.Round(cssX * devicePixelRatio);
|
||||
int y = (int)Math.Round(cssY * devicePixelRatio);
|
||||
int x = (int)Math.Round(cssX * geometry.CssScaleX);
|
||||
int y = (int)Math.Round(cssY * geometry.CssScaleY);
|
||||
if (x < 0 || x >= geometry.Width || y < 0 || y >= geometry.Height)
|
||||
{
|
||||
throw new InvalidOperationException("A CSS input point is outside the package client area.");
|
||||
@@ -1787,7 +1803,11 @@ function Assert-DebugAppXMatchesLatestPackage([string]$InstallLocation) {
|
||||
'MBN_STOCK_WEBVIEW.LegacyApplication.dll',
|
||||
'MBN_STOCK_WEBVIEW.LegacyBridge.dll',
|
||||
'MBN_STOCK_WEBVIEW.LegacyParityApp.dll',
|
||||
'Web/app.js'
|
||||
'Web/app.js',
|
||||
'Web/index.html',
|
||||
'Web/playout-ui.js',
|
||||
'Web/styles.css',
|
||||
'Web/workspace-navigation.js'
|
||||
)
|
||||
foreach ($relativePath in $expectedFiles) {
|
||||
$entry = $archive.GetEntry($relativePath)
|
||||
|
||||
@@ -257,8 +257,10 @@ public static class LegacyLocalStateNative
|
||||
|
||||
$ExpectedSourceSha256 =
|
||||
'1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2'
|
||||
$ExpectedInitialDestinationSha256 =
|
||||
$ExpectedFreshDestinationSha256 =
|
||||
'13C07BA64587549EDA9C1A694F3DFA19875CB1F888F02C9E071F17C47739E087'
|
||||
$ExpectedImportedDestinationSha256 =
|
||||
'2575C2420800670B0022EBEE819BA3D9640DB14E9552426DF46075D079F1F697'
|
||||
$ExpectedInitialPairLine = $Utf8.GetString([Convert]::FromBase64String(
|
||||
'MV5LUlgxMDDsp4DsiJgs7L2U7Iqk7ZS8IOyngOyImF5eXl5eXl4='))
|
||||
$ComparisonFileName = $Utf8.GetString([Convert]::FromBase64String(
|
||||
@@ -304,6 +306,7 @@ $ExpectedExchangePlan = @(
|
||||
$script:LocalInputRequestCalls = 0
|
||||
$script:LocalInputAcknowledgementCalls = 0
|
||||
$script:BaselineSettingsState = $null
|
||||
$script:DestinationBaselineMode = $null
|
||||
$script:InitialDestinationState = $null
|
||||
$script:FirstImportedDestinationState = $null
|
||||
$script:LastObservedFileState = $null
|
||||
@@ -399,19 +402,30 @@ function Assert-LocalFilePreflight {
|
||||
$state = Get-LocalFileState
|
||||
if ([string]$state.source.sha256 -cne $ExpectedSourceSha256 -or
|
||||
[int]$state.source.rowCount -ne 8 -or
|
||||
[string]$state.destination.sha256 -cne $ExpectedInitialDestinationSha256 -or
|
||||
[int]$state.destination.rowCount -ne 1 -or
|
||||
[string]$state.destination.firstPair -cne $ExpectedInitialPairIdentity) {
|
||||
Throw-SafeFailure `
|
||||
('The original 8-row source or exact current one-row baseline changed; ' +
|
||||
('The original 8-row source or known comparison baseline changed; ' +
|
||||
'no import was started.')
|
||||
}
|
||||
$initialBytes = [IO.File]::ReadAllBytes($DestinationComparisonPath)
|
||||
if ($Cp949.GetString($initialBytes).TrimEnd("`r", "`n") -cne
|
||||
$ExpectedInitialPairLine) {
|
||||
Throw-SafeFailure 'The current one-row comparison baseline is not the expected known pair.'
|
||||
$isFresh =
|
||||
[string]$state.destination.sha256 -ceq $ExpectedFreshDestinationSha256 -and
|
||||
[int]$state.destination.rowCount -eq 1
|
||||
$isAlreadyImported =
|
||||
[string]$state.destination.sha256 -ceq $ExpectedImportedDestinationSha256 -and
|
||||
[int]$state.destination.rowCount -eq 9
|
||||
if (-not $isFresh -and -not $isAlreadyImported) {
|
||||
Throw-SafeFailure `
|
||||
'The comparison destination is neither the known fresh nor imported baseline.'
|
||||
}
|
||||
if ($isFresh) {
|
||||
$initialBytes = [IO.File]::ReadAllBytes($DestinationComparisonPath)
|
||||
if ($Cp949.GetString($initialBytes).TrimEnd("`r", "`n") -cne
|
||||
$ExpectedInitialPairLine) {
|
||||
Throw-SafeFailure 'The current one-row comparison baseline is not the expected known pair.'
|
||||
}
|
||||
}
|
||||
$script:BaselineSettingsState = $state.settings
|
||||
$script:DestinationBaselineMode = if ($isFresh) { 'fresh' } else { 'alreadyImported' }
|
||||
$script:InitialDestinationState = $state.destination
|
||||
$script:LastObservedFileState = $state
|
||||
return $state
|
||||
@@ -755,14 +769,14 @@ function Invoke-LocalStateExchange(
|
||||
Throw-SafeFailure 'The original read-only comparison source changed during the smoke.'
|
||||
}
|
||||
if ($Sequence -lt 9 -and
|
||||
[string]$files.destination.sha256 -cne $ExpectedInitialDestinationSha256) {
|
||||
(-not (Test-JsonEquivalent $files.destination $script:InitialDestinationState))) {
|
||||
Throw-SafeFailure 'The comparison destination changed before the authorized Yes input.'
|
||||
}
|
||||
if ($Sequence -eq 10) {
|
||||
if ([int]$files.destination.rowCount -ne 9 -or
|
||||
[string]$files.destination.firstPair -cne $ExpectedInitialPairIdentity -or
|
||||
[string]$files.destination.sha256 -ceq $ExpectedInitialDestinationSha256) {
|
||||
Throw-SafeFailure 'The first authorized import is not an appended nine-row file.'
|
||||
[string]$files.destination.sha256 -cne $ExpectedImportedDestinationSha256) {
|
||||
Throw-SafeFailure 'The first authorized import did not retain the known nine-row result.'
|
||||
}
|
||||
$script:FirstImportedDestinationState = $files.destination
|
||||
}
|
||||
@@ -916,7 +930,13 @@ function Assert-LocalHarnessEvidence(
|
||||
[string]$ExchangePath) {
|
||||
$sealed = Read-LocalHarnessEvidenceFile $EvidencePath
|
||||
$value = $sealed.Value
|
||||
if ([int]$value.schemaVersion -ne 1 -or
|
||||
$baselineMode = [string]$value.files.initialDestinationMode
|
||||
$expectedFirstAdded = if ($baselineMode -ceq 'fresh') { 8 } else { 0 }
|
||||
$expectedFirstSkipped = if ($baselineMode -ceq 'fresh') { 0 } else { 8 }
|
||||
$expectedInitialPairCount = if ($baselineMode -ceq 'fresh') { 1 } else { 9 }
|
||||
if (($baselineMode -cne 'fresh' -and $baselineMode -cne 'alreadyImported') -or
|
||||
$baselineMode -cne $script:DestinationBaselineMode -or
|
||||
[int]$value.schemaVersion -ne 1 -or
|
||||
[string]$value.profile -cne 'local-settings-comparison-import' -or
|
||||
[string]$value.result -cne 'PASS' -or $null -ne $value.error -or
|
||||
[string]$value.target.expectedUrl -cne
|
||||
@@ -936,11 +956,18 @@ function Assert-LocalHarnessEvidence(
|
||||
-not (Test-ExactStringSequence @($value.settings.pickerKinds) @(
|
||||
'design', 'resource', 'background')) -or
|
||||
$value.comparison.initialPairPreserved -ne $true -or
|
||||
[int]$value.comparison.initialPairCount -ne $expectedInitialPairCount -or
|
||||
$value.comparison.secondImportIdempotent -ne $true -or
|
||||
[int]$value.comparison.afterFirstImport.receipt.addedPairCount -ne 8 -or
|
||||
[int]$value.comparison.afterFirstImport.receipt.addedPairCount -ne
|
||||
$expectedFirstAdded -or
|
||||
[int]$value.comparison.afterFirstImport.receipt.skippedDuplicateCount -ne
|
||||
$expectedFirstSkipped -or
|
||||
[int]$value.comparison.afterFirstImport.pairCount -ne 9 -or
|
||||
[int]$value.comparison.afterSecondImport.receipt.addedPairCount -ne 0 -or
|
||||
[int]$value.comparison.afterSecondImport.receipt.skippedDuplicateCount -ne 8 -or
|
||||
[int]$value.comparison.afterSecondImport.pairCount -ne 9 -or
|
||||
[string]$value.files.destinationAfterFirstImport.sha256 -cne
|
||||
$ExpectedImportedDestinationSha256 -or
|
||||
[string]$value.files.destinationAfterFirstImport.sha256 -cne
|
||||
[string]$value.files.destinationAfterSecondImport.sha256 -or
|
||||
@($value.exchanges).Count -ne 13) {
|
||||
@@ -1007,8 +1034,10 @@ if ($StaticAudit) {
|
||||
inputRetryCount = 0
|
||||
expectedSourceSha256 = $ExpectedSourceSha256
|
||||
expectedSourceRows = 8
|
||||
expectedInitialDestinationSha256 = $ExpectedInitialDestinationSha256
|
||||
expectedInitialDestinationRows = 1
|
||||
expectedFreshDestinationSha256 = $ExpectedFreshDestinationSha256
|
||||
expectedFreshDestinationRows = 1
|
||||
expectedImportedDestinationSha256 = $ExpectedImportedDestinationSha256
|
||||
expectedImportedDestinationRows = 9
|
||||
sendInputClickAvailable = $null -ne
|
||||
[LegacyPackageInputNative].GetMethod('SendApplicationClick')
|
||||
sendInputEscapeAvailable = $null -ne
|
||||
@@ -1085,8 +1114,8 @@ try {
|
||||
Throw-SafeFailure 'Final local files do not match the sealed cancel/idempotence result.'
|
||||
}
|
||||
|
||||
# This authorized import changed user-local comparison data. Avoid a second
|
||||
# activation; normal close is the only remaining app action.
|
||||
# The authorized import may have appended the fresh baseline or confirmed
|
||||
# the existing imported baseline. Normal close is the only remaining action.
|
||||
$script:Phase = 'normal close'
|
||||
Invoke-ExactNormalClose $script:ApplicationProcess $registration.Executable
|
||||
Wait-PortReleased $Port ([datetime]::UtcNow.AddSeconds(15))
|
||||
@@ -1106,7 +1135,8 @@ try {
|
||||
localInputRequestCalls = $script:LocalInputRequestCalls
|
||||
localInputAcknowledgementCalls = $script:LocalInputAcknowledgementCalls
|
||||
settingsPickerCancelCount = 3
|
||||
comparisonImportAddedCount = 8
|
||||
comparisonBaselineMode = $script:DestinationBaselineMode
|
||||
comparisonImportAddedCount = if ($script:DestinationBaselineMode -ceq 'fresh') { 8 } else { 0 }
|
||||
comparisonSecondImportAddedCount = 0
|
||||
output = $Output
|
||||
outputSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $Output).Hash
|
||||
|
||||
998
scripts/Test-LegacyPackageAppearanceSmoke.mjs
Normal file
998
scripts/Test-LegacyPackageAppearanceSmoke.mjs
Normal file
@@ -0,0 +1,998 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
const exactTargetUrl = "https://legacy-parity.mbn.local/index.html";
|
||||
const allowedOutboundTypes = new Set([
|
||||
"ready",
|
||||
"select-tab",
|
||||
"set-operator-appearance"
|
||||
]);
|
||||
const forbiddenCdpMethods = new Set([
|
||||
"Browser.close",
|
||||
"Page.close",
|
||||
"Runtime.terminateExecution",
|
||||
"Target.closeTarget",
|
||||
"Input.dispatchMouseEvent",
|
||||
"Input.dispatchKeyEvent",
|
||||
"Input.insertText"
|
||||
]);
|
||||
const appearanceValues = Object.freeze({
|
||||
colorTheme: Object.freeze(["system", "light", "dark"]),
|
||||
viewMode: Object.freeze(["automatic", "compact", "cards"]),
|
||||
startWorkspace: Object.freeze(["stockCut", "lastWorkspace"])
|
||||
});
|
||||
const controls = Object.freeze({
|
||||
settings: Object.freeze({
|
||||
id: "workspace-settings-tab",
|
||||
expression: 'document.getElementById("workspace-settings-tab")'
|
||||
}),
|
||||
colorTheme: Object.freeze({
|
||||
id: "operator-color-theme",
|
||||
expression: 'document.getElementById("operator-color-theme")'
|
||||
}),
|
||||
viewMode: Object.freeze({
|
||||
id: "operator-view-mode",
|
||||
expression: 'document.getElementById("operator-view-mode")'
|
||||
}),
|
||||
startWorkspace: Object.freeze({
|
||||
id: "operator-start-workspace",
|
||||
expression: 'document.getElementById("operator-start-workspace")'
|
||||
})
|
||||
});
|
||||
|
||||
class AppearanceFailure extends Error {
|
||||
constructor(category, message) {
|
||||
super(`${category}: ${message}`);
|
||||
this.name = "AppearanceFailure";
|
||||
this.category = category;
|
||||
}
|
||||
}
|
||||
|
||||
function failKnown(message) {
|
||||
throw new AppearanceFailure("KNOWN_FAILURE", message);
|
||||
}
|
||||
|
||||
function failUnknown(message) {
|
||||
throw new AppearanceFailure("OUTCOME_UNKNOWN", message);
|
||||
}
|
||||
|
||||
function parseClosedValue(values, name, allowed) {
|
||||
const value = values.get(name);
|
||||
if (!allowed.includes(value)) failKnown(`--${name} is outside the closed value set.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const accepted = new Set([
|
||||
"phase",
|
||||
"port",
|
||||
"output",
|
||||
"screenshot",
|
||||
"exchange-directory",
|
||||
"expected-color-theme",
|
||||
"expected-view-mode",
|
||||
"expected-start-workspace",
|
||||
"final-color-theme",
|
||||
"final-view-mode",
|
||||
"final-start-workspace",
|
||||
"timeout-ms"
|
||||
]);
|
||||
if (argv.length === 0 || argv.length % 2 !== 0) {
|
||||
failKnown(
|
||||
"Usage: node scripts/Test-LegacyPackageAppearanceSmoke.mjs " +
|
||||
"--phase mutate|verify-restore --port <port> --output <absolute.json> " +
|
||||
"--screenshot <absolute.png> --exchange-directory <absolute-directory> " +
|
||||
"--expected-color-theme <value> --expected-view-mode <value> " +
|
||||
"--expected-start-workspace <value> --final-color-theme <value> " +
|
||||
"--final-view-mode <value> --final-start-workspace <value> " +
|
||||
"[--timeout-ms <milliseconds>]");
|
||||
}
|
||||
const values = new Map();
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const rawName = argv[index];
|
||||
const value = argv[index + 1];
|
||||
if (!rawName?.startsWith("--") || !value) failKnown("Arguments must be --name value pairs.");
|
||||
const name = rawName.slice(2);
|
||||
if (!accepted.has(name) || values.has(name)) failKnown(`Invalid argument --${name}.`);
|
||||
values.set(name, value);
|
||||
}
|
||||
for (const required of [
|
||||
"phase", "port", "output", "screenshot", "exchange-directory",
|
||||
"expected-color-theme", "expected-view-mode", "expected-start-workspace",
|
||||
"final-color-theme", "final-view-mode", "final-start-workspace"
|
||||
]) {
|
||||
if (!values.has(required)) failKnown(`--${required} is required.`);
|
||||
}
|
||||
const phase = values.get("phase");
|
||||
if (!new Set(["mutate", "verify-restore"]).has(phase)) {
|
||||
failKnown("--phase must be mutate or verify-restore.");
|
||||
}
|
||||
const port = Number(values.get("port"));
|
||||
const timeoutMilliseconds = Number(values.get("timeout-ms") || "120000");
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65_535) {
|
||||
failKnown("--port must be from 1024 through 65535.");
|
||||
}
|
||||
if (!Number.isInteger(timeoutMilliseconds) ||
|
||||
timeoutMilliseconds < 30_000 || timeoutMilliseconds > 900_000) {
|
||||
failKnown("--timeout-ms must be from 30000 through 900000.");
|
||||
}
|
||||
const outputPath = path.resolve(values.get("output"));
|
||||
const screenshotPath = path.resolve(values.get("screenshot"));
|
||||
const exchangeDirectory = path.resolve(values.get("exchange-directory"));
|
||||
if (!path.isAbsolute(values.get("output")) ||
|
||||
path.extname(outputPath).toLowerCase() !== ".json" ||
|
||||
!path.isAbsolute(values.get("screenshot")) ||
|
||||
path.extname(screenshotPath).toLowerCase() !== ".png" ||
|
||||
!path.isAbsolute(values.get("exchange-directory"))) {
|
||||
failKnown("Output, screenshot and exchange paths must be absolute and typed correctly.");
|
||||
}
|
||||
if (new Set([
|
||||
outputPath.toLowerCase(), screenshotPath.toLowerCase(), exchangeDirectory.toLowerCase()
|
||||
]).size !== 3) {
|
||||
failKnown("Evidence paths must be distinct.");
|
||||
}
|
||||
if (fs.existsSync(outputPath) || fs.existsSync(screenshotPath) ||
|
||||
!fs.existsSync(exchangeDirectory) ||
|
||||
!fs.statSync(exchangeDirectory).isDirectory() ||
|
||||
fs.readdirSync(exchangeDirectory).length !== 0) {
|
||||
failKnown("Evidence is never overwritten and the exchange directory must be empty.");
|
||||
}
|
||||
const expected = Object.freeze({
|
||||
colorTheme: parseClosedValue(
|
||||
values, "expected-color-theme", appearanceValues.colorTheme),
|
||||
viewMode: parseClosedValue(values, "expected-view-mode", appearanceValues.viewMode),
|
||||
startWorkspace: parseClosedValue(
|
||||
values, "expected-start-workspace", appearanceValues.startWorkspace)
|
||||
});
|
||||
const final = Object.freeze({
|
||||
colorTheme: parseClosedValue(values, "final-color-theme", appearanceValues.colorTheme),
|
||||
viewMode: parseClosedValue(values, "final-view-mode", appearanceValues.viewMode),
|
||||
startWorkspace: parseClosedValue(
|
||||
values, "final-start-workspace", appearanceValues.startWorkspace)
|
||||
});
|
||||
if (Object.keys(expected).some(key => expected[key] === final[key])) {
|
||||
failKnown("Every phase must physically change all three appearance values.");
|
||||
}
|
||||
return {
|
||||
phase,
|
||||
port,
|
||||
timeoutMilliseconds,
|
||||
outputPath,
|
||||
screenshotPath,
|
||||
exchangeDirectory,
|
||||
expected,
|
||||
final
|
||||
};
|
||||
}
|
||||
|
||||
const configuration = parseArguments(process.argv.slice(2));
|
||||
const hardDeadline = Date.now() + configuration.timeoutMilliseconds;
|
||||
let socket = null;
|
||||
let nextRpcId = 1;
|
||||
let nextExchangeSequence = 1;
|
||||
let probeInstalled = false;
|
||||
let cancellationRequested = false;
|
||||
const pendingRpc = new Map();
|
||||
const evidence = {
|
||||
schemaVersion: 1,
|
||||
profile: "operator-appearance-persistence",
|
||||
phase: configuration.phase,
|
||||
result: "RUNNING",
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: null,
|
||||
target: {
|
||||
expectedUrl: exactTargetUrl,
|
||||
port: configuration.port,
|
||||
id: null,
|
||||
url: null
|
||||
},
|
||||
expectedInitial: configuration.expected,
|
||||
expectedFinal: configuration.final,
|
||||
safety: {
|
||||
requiredMode: "dryRun",
|
||||
requiredPhase: "idle",
|
||||
inputRetryCount: 0,
|
||||
appCloseRequested: false,
|
||||
playoutIntentIssued: false,
|
||||
allowedOutboundTypes: [...allowedOutboundTypes],
|
||||
observedOutboundTypes: [],
|
||||
blockedOutboundMessages: [],
|
||||
stateViolations: []
|
||||
},
|
||||
persistenceVerifiedBeforeInput: false,
|
||||
startWorkspaceBehaviorVerifiedBeforeInput: false,
|
||||
selections: [],
|
||||
exchanges: [],
|
||||
checkpoints: [],
|
||||
finalSnapshot: null,
|
||||
screenshot: null,
|
||||
error: null
|
||||
};
|
||||
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", chunk => {
|
||||
if (String(chunk).split(/\r?\n/u).includes("CANCEL")) cancellationRequested = true;
|
||||
});
|
||||
if (typeof process.stdin.unref === "function") process.stdin.unref();
|
||||
|
||||
function assertDeadline(label) {
|
||||
if (cancellationRequested) failUnknown(`The wrapper cancelled ${label}; no input was retried.`);
|
||||
if (Date.now() >= hardDeadline) {
|
||||
failUnknown(`The global deadline expired ${label}; no input was retried.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function sleep(milliseconds) {
|
||||
assertDeadline("before waiting");
|
||||
await new Promise(resolve => setTimeout(
|
||||
resolve,
|
||||
Math.max(0, Math.min(milliseconds, hardDeadline - Date.now()))));
|
||||
assertDeadline("after waiting");
|
||||
}
|
||||
|
||||
function sha256(bytes) {
|
||||
return createHash("sha256").update(bytes).digest("hex").toUpperCase();
|
||||
}
|
||||
|
||||
function exactAppearance(value) {
|
||||
return value ? {
|
||||
colorTheme: value.colorTheme,
|
||||
viewMode: value.viewMode,
|
||||
startWorkspace: value.startWorkspace
|
||||
} : null;
|
||||
}
|
||||
|
||||
function appearanceEquals(left, right) {
|
||||
return Boolean(left && right &&
|
||||
left.colorTheme === right.colorTheme &&
|
||||
left.viewMode === right.viewMode &&
|
||||
left.startWorkspace === right.startWorkspace);
|
||||
}
|
||||
|
||||
async function discoverTarget() {
|
||||
const discoveryUrl = `http://127.0.0.1:${configuration.port}/json/list`;
|
||||
const deadline = Math.min(hardDeadline, Date.now() + 15_000);
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(discoveryUrl, { cache: "no-store" });
|
||||
if (response.ok) {
|
||||
const targets = await response.json();
|
||||
const exact = targets.filter(target =>
|
||||
target?.type === "page" && target.url === exactTargetUrl);
|
||||
if (exact.length > 1) failKnown("More than one exact package WebView target exists.");
|
||||
if (exact.length === 1) return exact[0];
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AppearanceFailure) throw error;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
failKnown("The exact package target was not found.");
|
||||
}
|
||||
|
||||
function validateEndpoint(target) {
|
||||
if (!target?.id || !target.webSocketDebuggerUrl) failKnown("The target has no debugger URL.");
|
||||
const endpoint = new URL(target.webSocketDebuggerUrl);
|
||||
if (endpoint.protocol !== "ws:" || endpoint.hostname !== "127.0.0.1" ||
|
||||
Number(endpoint.port) !== configuration.port ||
|
||||
endpoint.pathname !== `/devtools/page/${target.id}`) {
|
||||
failKnown("The debugger endpoint is not the exact loopback page endpoint.");
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
async function connect(endpoint) {
|
||||
if (typeof WebSocket !== "function") failKnown("This Node runtime has no WebSocket API.");
|
||||
socket = new WebSocket(endpoint.href);
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new AppearanceFailure(
|
||||
"OUTCOME_UNKNOWN", "CDP socket open timed out.")), 5_000);
|
||||
socket.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true });
|
||||
socket.addEventListener("error", () => { clearTimeout(timer); reject(new AppearanceFailure(
|
||||
"OUTCOME_UNKNOWN", "CDP socket failed to open.")); }, { once: true });
|
||||
});
|
||||
socket.addEventListener("message", event => {
|
||||
const message = JSON.parse(String(event.data));
|
||||
if (!Object.hasOwn(message, "id")) return;
|
||||
const pending = pendingRpc.get(message.id);
|
||||
if (!pending) return;
|
||||
pendingRpc.delete(message.id);
|
||||
clearTimeout(pending.timer);
|
||||
if (message.error) pending.reject(new AppearanceFailure(
|
||||
"KNOWN_FAILURE", `${pending.method} failed: ${message.error.message || "CDP error"}`));
|
||||
else pending.resolve(message.result);
|
||||
});
|
||||
}
|
||||
|
||||
async function rpc(method, params = {}, timeoutMilliseconds = 10_000) {
|
||||
assertDeadline(`before ${method}`);
|
||||
if (forbiddenCdpMethods.has(method)) failKnown(`CDP method ${method} is forbidden.`);
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) failUnknown("CDP socket is not open.");
|
||||
const id = nextRpcId++;
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingRpc.delete(id);
|
||||
reject(new AppearanceFailure(
|
||||
"OUTCOME_UNKNOWN", `${method} timed out; no input was retried.`));
|
||||
}, Math.min(timeoutMilliseconds, Math.max(1, hardDeadline - Date.now())));
|
||||
pendingRpc.set(id, { resolve, reject, timer, method });
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
}
|
||||
|
||||
async function evaluate(expression) {
|
||||
const result = await rpc("Runtime.evaluate", {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true
|
||||
});
|
||||
if (result?.exceptionDetails) failKnown("A read-only page inspection failed.");
|
||||
return result?.result?.value;
|
||||
}
|
||||
|
||||
async function installProbe() {
|
||||
const token = `appearance-${Date.now()}-${randomBytes(8).toString("hex")}`;
|
||||
const installed = await evaluate(`(() => {
|
||||
if (!window.chrome?.webview) throw new Error("WebView2 bridge is unavailable.");
|
||||
if (globalThis.__legacyAppearanceProbe) throw new Error("Appearance probe already exists.");
|
||||
const probe = {
|
||||
token: ${JSON.stringify(token)},
|
||||
states: [],
|
||||
outboundMessages: [],
|
||||
blockedOutboundMessages: [],
|
||||
stateViolations: [],
|
||||
inputEvents: [],
|
||||
originalPostMessage: window.chrome.webview.postMessage,
|
||||
postMessageWrapper: null,
|
||||
stateListener: null,
|
||||
inputListener: null
|
||||
};
|
||||
const allowed = new Set(${JSON.stringify([...allowedOutboundTypes])});
|
||||
probe.stateListener = event => {
|
||||
if (event.data?.type !== "state" || !event.data.payload) return;
|
||||
const state = event.data.payload;
|
||||
probe.states.push(structuredClone(state));
|
||||
if (probe.states.length > 100) probe.states.shift();
|
||||
const playout = state.playout;
|
||||
const reasons = [];
|
||||
if (!playout) reasons.push("missing-playout");
|
||||
else {
|
||||
if (playout.mode !== "dryRun") reasons.push("mode");
|
||||
if (playout.phase !== "idle") reasons.push("phase");
|
||||
if (playout.isConnected === true) reasons.push("connected");
|
||||
if (playout.preparedCode != null) reasons.push("prepared-code");
|
||||
if (playout.onAirCode != null) reasons.push("on-air-code");
|
||||
if (playout.outcomeUnknown === true) reasons.push("outcome-unknown");
|
||||
if (playout.isPlayCompletionPending === true) reasons.push("play-pending");
|
||||
if (playout.isTakeOutCompletionPending === true) reasons.push("takeout-pending");
|
||||
if (playout.isBusy === true) reasons.push("playout-busy");
|
||||
if (playout.refreshActive === true) reasons.push("refresh-active");
|
||||
}
|
||||
if (reasons.length > 0) probe.stateViolations.push({
|
||||
at: new Date().toISOString(),
|
||||
revision: state.revision ?? null,
|
||||
reasons
|
||||
});
|
||||
};
|
||||
probe.inputListener = event => {
|
||||
const element = event.target instanceof Element ? event.target : null;
|
||||
const controlled = element?.closest?.(
|
||||
"#workspace-settings-tab, #operator-color-theme, " +
|
||||
"#operator-view-mode, #operator-start-workspace");
|
||||
if (!controlled) return;
|
||||
probe.inputEvents.push({
|
||||
sequence: probe.inputEvents.length + 1,
|
||||
type: event.type,
|
||||
targetId: controlled.id || null,
|
||||
isTrusted: event.isTrusted === true,
|
||||
pointerType: event.pointerType || null,
|
||||
button: Number.isInteger(event.button) ? event.button : null,
|
||||
buttons: Number.isInteger(event.buttons) ? event.buttons : null,
|
||||
key: typeof event.key === "string" ? event.key : null,
|
||||
value: controlled instanceof HTMLSelectElement ? controlled.value : null,
|
||||
at: new Date().toISOString()
|
||||
});
|
||||
if (probe.inputEvents.length > 150) probe.inputEvents.shift();
|
||||
};
|
||||
probe.postMessageWrapper = message => {
|
||||
const type = typeof message?.type === "string" ? message.type : null;
|
||||
const record = {
|
||||
sequence: probe.outboundMessages.length + 1,
|
||||
type,
|
||||
appearance: type === "set-operator-appearance" ? {
|
||||
colorTheme: message?.payload?.colorTheme ?? null,
|
||||
viewMode: message?.payload?.viewMode ?? null,
|
||||
startWorkspace: message?.payload?.startWorkspace ?? null
|
||||
} : null,
|
||||
at: new Date().toISOString()
|
||||
};
|
||||
probe.outboundMessages.push(record);
|
||||
if (!type || !allowed.has(type)) {
|
||||
probe.blockedOutboundMessages.push(record);
|
||||
return;
|
||||
}
|
||||
return probe.originalPostMessage.call(window.chrome.webview, message);
|
||||
};
|
||||
try {
|
||||
window.chrome.webview.postMessage = probe.postMessageWrapper;
|
||||
if (window.chrome.webview.postMessage !== probe.postMessageWrapper) {
|
||||
throw new Error("The outbound safety gate could not be installed.");
|
||||
}
|
||||
globalThis.__legacyAppearanceProbe = probe;
|
||||
window.chrome.webview.addEventListener("message", probe.stateListener);
|
||||
["pointerdown", "pointerup", "keydown", "keyup", "input", "change"].forEach(type =>
|
||||
document.addEventListener(type, probe.inputListener, true));
|
||||
window.chrome.webview.postMessage({ type: "ready", payload: {} });
|
||||
} catch (error) {
|
||||
window.chrome.webview.postMessage = probe.originalPostMessage;
|
||||
delete globalThis.__legacyAppearanceProbe;
|
||||
throw error;
|
||||
}
|
||||
return probe.token;
|
||||
})()`);
|
||||
if (installed !== token) failKnown("The safety probe was not installed exactly once.");
|
||||
probeInstalled = true;
|
||||
}
|
||||
|
||||
async function removeProbe() {
|
||||
if (!probeInstalled || !socket || socket.readyState !== WebSocket.OPEN) return;
|
||||
const removed = await evaluate(`(() => {
|
||||
const probe = globalThis.__legacyAppearanceProbe;
|
||||
if (!probe) return false;
|
||||
window.chrome.webview.removeEventListener("message", probe.stateListener);
|
||||
["pointerdown", "pointerup", "keydown", "keyup", "input", "change"].forEach(type =>
|
||||
document.removeEventListener(type, probe.inputListener, true));
|
||||
if (window.chrome.webview.postMessage !== probe.postMessageWrapper) {
|
||||
throw new Error("The outbound safety gate identity changed.");
|
||||
}
|
||||
window.chrome.webview.postMessage = probe.originalPostMessage;
|
||||
delete globalThis.__legacyAppearanceProbe;
|
||||
return true;
|
||||
})()`);
|
||||
if (removed !== true) failUnknown("The safety probe was not restored exactly once.");
|
||||
probeInstalled = false;
|
||||
}
|
||||
|
||||
async function readSnapshot() {
|
||||
return await evaluate(`(() => {
|
||||
const probe = globalThis.__legacyAppearanceProbe;
|
||||
const state = probe?.states?.at(-1) || null;
|
||||
const html = document.documentElement;
|
||||
const body = document.body;
|
||||
const active = document.activeElement;
|
||||
const appearance = state?.operatorSettings ? {
|
||||
colorTheme: state.operatorSettings.colorTheme,
|
||||
viewMode: state.operatorSettings.viewMode,
|
||||
startWorkspace: state.operatorSettings.startWorkspace
|
||||
} : null;
|
||||
return {
|
||||
state: state ? {
|
||||
revision: state.revision,
|
||||
isBusy: state.isBusy === true,
|
||||
playout: structuredClone(state.playout),
|
||||
operatorSettings: state.operatorSettings ? {
|
||||
revision: state.operatorSettings.revision,
|
||||
colorTheme: state.operatorSettings.colorTheme,
|
||||
viewMode: state.operatorSettings.viewMode,
|
||||
startWorkspace: state.operatorSettings.startWorkspace,
|
||||
restartRequired: state.operatorSettings.restartRequired === true,
|
||||
message: state.operatorSettings.message || "",
|
||||
messageKind: state.operatorSettings.messageKind || null
|
||||
} : null
|
||||
} : null,
|
||||
appearance,
|
||||
safety: {
|
||||
wrapped: probe?.postMessageWrapper != null &&
|
||||
window.chrome?.webview?.postMessage === probe.postMessageWrapper,
|
||||
outboundMessages: (probe?.outboundMessages || []).map(value => ({ ...value })),
|
||||
blockedOutboundMessages: (probe?.blockedOutboundMessages || []).map(value => ({ ...value })),
|
||||
stateViolations: (probe?.stateViolations || []).map(value => ({
|
||||
...value,
|
||||
reasons: [...value.reasons]
|
||||
})),
|
||||
inputEvents: (probe?.inputEvents || []).map(value => ({ ...value }))
|
||||
},
|
||||
dom: {
|
||||
url: location.href,
|
||||
readyState: document.readyState,
|
||||
bodyBusy: body.getAttribute("aria-busy"),
|
||||
connectionText: (document.getElementById("playout-connection-state")?.textContent || "").trim(),
|
||||
settingsVisible: document.getElementById("settings-workspace")?.hidden === false &&
|
||||
document.getElementById("settings-workspace")?.inert !== true,
|
||||
settingsCurrent: document.getElementById("workspace-settings-tab")
|
||||
?.getAttribute("aria-current") || null,
|
||||
activeWorkspace: document.getElementById("workspace-region")
|
||||
?.dataset?.activeWorkspace || null,
|
||||
activeElementId: active?.id || null,
|
||||
selects: {
|
||||
colorTheme: document.getElementById("operator-color-theme")?.value ?? null,
|
||||
viewMode: document.getElementById("operator-view-mode")?.value ?? null,
|
||||
startWorkspace: document.getElementById("operator-start-workspace")?.value ?? null
|
||||
},
|
||||
htmlAppearance: {
|
||||
colorTheme: html.dataset.colorTheme || null,
|
||||
viewMode: html.dataset.viewMode || null,
|
||||
startWorkspace: html.dataset.startWorkspace || null
|
||||
},
|
||||
bodyAppearance: {
|
||||
colorTheme: body.dataset.colorTheme || null,
|
||||
viewMode: body.dataset.viewMode || null,
|
||||
startWorkspace: body.dataset.startWorkspace || null
|
||||
},
|
||||
computed: {
|
||||
colorScheme: getComputedStyle(html).colorScheme || null,
|
||||
bodyBackground: getComputedStyle(body).backgroundColor || null
|
||||
},
|
||||
legacyDialogHidden: document.getElementById("legacy-dialog")?.hidden === true,
|
||||
namedModalHidden: document.getElementById("named-playlist-modal")?.hidden === true,
|
||||
namedConfirmationHidden:
|
||||
document.getElementById("named-playlist-confirmation")?.hidden === true
|
||||
}
|
||||
};
|
||||
})()`);
|
||||
}
|
||||
|
||||
function updateSafety(snapshot) {
|
||||
evidence.safety.observedOutboundTypes =
|
||||
snapshot?.safety?.outboundMessages?.map(row => row.type) || [];
|
||||
evidence.safety.blockedOutboundMessages =
|
||||
snapshot?.safety?.blockedOutboundMessages || [];
|
||||
evidence.safety.stateViolations = snapshot?.safety?.stateViolations || [];
|
||||
evidence.safety.playoutIntentIssued = evidence.safety.observedOutboundTypes.some(type =>
|
||||
["prepare-playout", "take-in", "next-playout", "take-out",
|
||||
"choose-background", "toggle-background"].includes(type));
|
||||
}
|
||||
|
||||
function assertSafety(snapshot, label, allowBusy = false) {
|
||||
updateSafety(snapshot);
|
||||
if (!snapshot?.state?.playout || snapshot.safety.wrapped !== true) {
|
||||
failUnknown(`The native DryRun state or safety gate is missing at ${label}.`);
|
||||
}
|
||||
if (snapshot.safety.blockedOutboundMessages.length > 0) {
|
||||
failKnown(`An unapproved outbound message was blocked at ${label}.`);
|
||||
}
|
||||
if (snapshot.safety.stateViolations.length > 0) {
|
||||
failUnknown(`DryRun left its safe state at ${label}.`);
|
||||
}
|
||||
const playout = snapshot.state.playout;
|
||||
if (playout.mode !== "dryRun" || playout.phase !== "idle" ||
|
||||
playout.isConnected === true || playout.preparedCode != null ||
|
||||
playout.onAirCode != null || playout.outcomeUnknown === true ||
|
||||
playout.isPlayCompletionPending === true ||
|
||||
playout.isTakeOutCompletionPending === true || playout.isBusy === true ||
|
||||
playout.refreshActive === true) {
|
||||
failUnknown(`Playout is not exact DryRun IDLE at ${label}.`);
|
||||
}
|
||||
if (!allowBusy && snapshot.state.isBusy === true) {
|
||||
failKnown(`The operator UI is busy at ${label}.`);
|
||||
}
|
||||
if (snapshot.dom.url !== exactTargetUrl || snapshot.dom.readyState !== "complete" ||
|
||||
snapshot.dom.connectionText !== "DRY RUN" ||
|
||||
snapshot.dom.legacyDialogHidden !== true ||
|
||||
snapshot.dom.namedModalHidden !== true ||
|
||||
snapshot.dom.namedConfirmationHidden !== true) {
|
||||
failKnown(`The exact package document is not ready at ${label}.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(label, predicate, { allowBusy = false, allowMissingState = false } = {}) {
|
||||
if (allowMissingState && label !== "DryRun IDLE preflight") {
|
||||
failKnown("Only the input-free initial preflight may await the first native state.");
|
||||
}
|
||||
const deadline = Math.min(hardDeadline, Date.now() + 45_000);
|
||||
let last = null;
|
||||
while (Date.now() < deadline) {
|
||||
last = await readSnapshot();
|
||||
if (!last?.state?.playout && allowMissingState) {
|
||||
if (last?.safety?.wrapped !== true || last?.dom?.url !== exactTargetUrl ||
|
||||
last?.dom?.readyState !== "complete" || last?.dom?.connectionText !== "DRY RUN") {
|
||||
failUnknown("The exact DryRun document changed before the first state.");
|
||||
}
|
||||
updateSafety(last);
|
||||
if (evidence.safety.blockedOutboundMessages.length > 0 ||
|
||||
evidence.safety.stateViolations.length > 0) {
|
||||
failUnknown("The safety gate observed a violation before initial state.");
|
||||
}
|
||||
await sleep(100);
|
||||
continue;
|
||||
}
|
||||
assertSafety(last, label, allowBusy);
|
||||
if (predicate(last)) return last;
|
||||
await sleep(100);
|
||||
}
|
||||
failUnknown(`Timed out waiting for ${label}; no input was retried.`);
|
||||
}
|
||||
|
||||
function assertAppearanceSnapshot(snapshot, expected, label, requireNeutralLoad = false) {
|
||||
if (!appearanceEquals(snapshot.appearance, expected) ||
|
||||
!appearanceEquals(snapshot.dom.selects, expected) ||
|
||||
!appearanceEquals(snapshot.dom.htmlAppearance, expected) ||
|
||||
!appearanceEquals(snapshot.dom.bodyAppearance, expected) ||
|
||||
snapshot.state.operatorSettings?.restartRequired !== false) {
|
||||
failKnown(`The native and rendered appearance do not agree at ${label}.`);
|
||||
}
|
||||
if (requireNeutralLoad && new Set(["warning", "error"])
|
||||
.has(snapshot.state.operatorSettings?.messageKind)) {
|
||||
failKnown("The existing operator settings could not be loaded cleanly before input.");
|
||||
}
|
||||
}
|
||||
|
||||
async function geometryFor(control) {
|
||||
const value = await evaluate(`(() => {
|
||||
const element = ${control.expression};
|
||||
if (!element) return null;
|
||||
element.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
viewport: {
|
||||
width: document.documentElement.clientWidth,
|
||||
height: document.documentElement.clientHeight
|
||||
},
|
||||
devicePixelRatio: window.devicePixelRatio,
|
||||
disabled: element.disabled === true,
|
||||
hidden: element.hidden === true || element.closest("[hidden]") != null,
|
||||
optionValues: element instanceof HTMLSelectElement
|
||||
? [...element.options].map(option => option.value) : null
|
||||
};
|
||||
})()`);
|
||||
if (!value || value.disabled || value.hidden || value.width <= 0 || value.height <= 0 ||
|
||||
!Number.isFinite(value.x) || !Number.isFinite(value.y) ||
|
||||
value.x < 0 || value.y < 0 ||
|
||||
value.x >= value.viewport.width || value.y >= value.viewport.height ||
|
||||
value.devicePixelRatio < 0.25 || value.devicePixelRatio > 5) {
|
||||
failKnown(`#${control.id} is not a unique visible physical-input target.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exchangePath(sequence, suffix) {
|
||||
return path.join(configuration.exchangeDirectory,
|
||||
`${String(sequence).padStart(2, "0")}.${suffix}.json`);
|
||||
}
|
||||
|
||||
function readSmallJson(filePath, label) {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (!stat.isFile() || stat.size <= 0 || stat.size > 64 * 1024) {
|
||||
failKnown(`${label} is empty or too large.`);
|
||||
}
|
||||
const bytes = fs.readFileSync(filePath);
|
||||
let value;
|
||||
try { value = JSON.parse(bytes.toString("utf8")); }
|
||||
catch { failKnown(`${label} is not strict JSON.`); }
|
||||
return { value, bytes, sha256: sha256(bytes) };
|
||||
}
|
||||
|
||||
async function waitForTrustedInput(control, startIndex, targetValue) {
|
||||
const deadline = Math.min(hardDeadline, Date.now() + 10_000);
|
||||
while (Date.now() < deadline) {
|
||||
const events = await evaluate(`(() =>
|
||||
(globalThis.__legacyAppearanceProbe?.inputEvents || [])
|
||||
.slice(${Number(startIndex)}).map(value => ({ ...value })))()`);
|
||||
const relevant = events.filter(event => event.targetId === control.id);
|
||||
const down = relevant.find(event => event.type === "pointerdown");
|
||||
const up = relevant.find(event => event.type === "pointerup");
|
||||
if (down && up) {
|
||||
if (down.sequence >= up.sequence || down.isTrusted !== true || up.isTrusted !== true ||
|
||||
down.pointerType !== "mouse" || up.pointerType !== "mouse" ||
|
||||
down.button !== 0 || up.button !== 0 || down.buttons !== 1 || up.buttons !== 0) {
|
||||
failKnown(`#${control.id} did not receive one trusted mouse down/up pair.`);
|
||||
}
|
||||
if (targetValue === null) return { pointerDown: down, pointerUp: up, change: null };
|
||||
const change = relevant.find(event =>
|
||||
event.type === "change" && event.isTrusted === true && event.value === targetValue);
|
||||
const trustedKeyDowns = relevant.filter(event =>
|
||||
event.type === "keydown" && event.isTrusted === true);
|
||||
// A native HTML <select> popup consumes Home/ArrowDown/Enter before the
|
||||
// page can observe DOM keydown events. The wrapper's sealed one-shot ack
|
||||
// proves those physical key counts; the page proves the trusted change.
|
||||
if (change) {
|
||||
return {
|
||||
pointerDown: down,
|
||||
pointerUp: up,
|
||||
change,
|
||||
observedTrustedKeyDownCount: trustedKeyDowns.length,
|
||||
observedKeys: trustedKeyDowns.map(event => event.key)
|
||||
};
|
||||
}
|
||||
}
|
||||
await sleep(25);
|
||||
}
|
||||
failUnknown(`#${control.id} trusted input evidence did not appear; no input was retried.`);
|
||||
}
|
||||
|
||||
async function requestPhysicalInput({
|
||||
operation,
|
||||
control,
|
||||
appearanceKey = null,
|
||||
targetValue = null,
|
||||
expectedBefore,
|
||||
expectedAfter
|
||||
}) {
|
||||
const sequence = nextExchangeSequence++;
|
||||
const token = randomBytes(16).toString("hex").toUpperCase();
|
||||
const geometry = await geometryFor(control);
|
||||
let optionIndex = null;
|
||||
if (appearanceKey) {
|
||||
if (!Array.isArray(geometry.optionValues) ||
|
||||
JSON.stringify(geometry.optionValues) !==
|
||||
JSON.stringify(appearanceValues[appearanceKey])) {
|
||||
failKnown(`#${control.id} option order is outside the closed contract.`);
|
||||
}
|
||||
optionIndex = geometry.optionValues.indexOf(targetValue);
|
||||
if (optionIndex < 0) failKnown(`#${control.id} lacks the requested option.`);
|
||||
}
|
||||
const eventStart = await evaluate(
|
||||
"globalThis.__legacyAppearanceProbe?.inputEvents?.length ?? null");
|
||||
const outboundStart = await evaluate(
|
||||
"globalThis.__legacyAppearanceProbe?.outboundMessages?.length ?? null");
|
||||
if (!Number.isInteger(eventStart) || !Number.isInteger(outboundStart)) {
|
||||
failUnknown("The trusted input/outbound evidence baseline is unavailable.");
|
||||
}
|
||||
const request = {
|
||||
schemaVersion: 1,
|
||||
phase: configuration.phase,
|
||||
sequence,
|
||||
token,
|
||||
targetUrl: exactTargetUrl,
|
||||
operation,
|
||||
targetId: control.id,
|
||||
appearanceKey,
|
||||
targetValue,
|
||||
optionIndex,
|
||||
expectedBefore,
|
||||
expectedAfter,
|
||||
point: { x: geometry.x, y: geometry.y },
|
||||
viewport: geometry.viewport,
|
||||
devicePixelRatio: geometry.devicePixelRatio,
|
||||
mouseDownCalls: 1,
|
||||
mouseUpCalls: 1,
|
||||
homeKeyCalls: appearanceKey ? 1 : 0,
|
||||
arrowDownKeyCalls: appearanceKey ? optionIndex : 0,
|
||||
enterKeyCalls: appearanceKey ? 1 : 0,
|
||||
inputRetryCount: 0
|
||||
};
|
||||
const requestPath = exchangePath(sequence, "request");
|
||||
const acknowledgementPath = exchangePath(sequence, "ack");
|
||||
const requestBytes = Buffer.from(`${JSON.stringify(request)}\n`, "utf8");
|
||||
fs.writeFileSync(requestPath, requestBytes, { flag: "wx" });
|
||||
const deadline = Math.min(hardDeadline, Date.now() + 45_000);
|
||||
while (!fs.existsSync(acknowledgementPath)) {
|
||||
if (Date.now() >= deadline) {
|
||||
failUnknown(`Physical input exchange ${sequence} timed out; no input was retried.`);
|
||||
}
|
||||
await sleep(50);
|
||||
}
|
||||
const acknowledgement = readSmallJson(
|
||||
acknowledgementPath,
|
||||
`Physical input acknowledgement ${sequence}`);
|
||||
const ack = acknowledgement.value;
|
||||
if (!ack || ack.schemaVersion !== 1 || ack.phase !== configuration.phase ||
|
||||
ack.sequence !== sequence || ack.token !== token || ack.result !== "PASS" ||
|
||||
ack.operation !== operation || ack.targetId !== control.id ||
|
||||
ack.appearanceKey !== appearanceKey || ack.targetValue !== targetValue ||
|
||||
ack.optionIndex !== optionIndex || ack.packageIdentityValidated !== true ||
|
||||
ack.dryRunValidated !== true || ack.tornadoConnectionCount !== 0 ||
|
||||
ack.inputRetryCount !== 0 || ack.mouseDownCalls !== 1 || ack.mouseUpCalls !== 1 ||
|
||||
ack.homeKeyCalls !== request.homeKeyCalls ||
|
||||
ack.arrowDownKeyCalls !== request.arrowDownKeyCalls ||
|
||||
ack.enterKeyCalls !== request.enterKeyCalls ||
|
||||
!appearanceEquals(ack.settings?.appearance, expectedAfter)) {
|
||||
failKnown(`Physical input acknowledgement ${sequence} is outside its one-shot contract.`);
|
||||
}
|
||||
const physicalInput = await waitForTrustedInput(
|
||||
control,
|
||||
eventStart,
|
||||
targetValue);
|
||||
if (appearanceKey) {
|
||||
const current = await waitFor(`#${control.id} native save`, snapshot =>
|
||||
appearanceEquals(snapshot.appearance, expectedAfter) &&
|
||||
appearanceEquals(snapshot.dom.selects, expectedAfter) &&
|
||||
appearanceEquals(snapshot.dom.htmlAppearance, expectedAfter) &&
|
||||
appearanceEquals(snapshot.dom.bodyAppearance, expectedAfter) &&
|
||||
snapshot.state.isBusy !== true,
|
||||
{ allowBusy: true });
|
||||
const outbound = current.safety.outboundMessages.slice(outboundStart);
|
||||
if (outbound.length !== 1 || outbound[0].type !== "set-operator-appearance" ||
|
||||
!appearanceEquals(outbound[0].appearance, expectedAfter)) {
|
||||
failKnown(`#${control.id} did not issue exactly one closed appearance intent.`);
|
||||
}
|
||||
evidence.selections.push({
|
||||
appearanceKey,
|
||||
targetValue,
|
||||
expectedAfter,
|
||||
trustedChange: true,
|
||||
physicalKeyCounts: {
|
||||
home: ack.homeKeyCalls,
|
||||
arrowDown: ack.arrowDownKeyCalls,
|
||||
enter: ack.enterKeyCalls,
|
||||
total: ack.homeKeyCalls + ack.arrowDownKeyCalls + ack.enterKeyCalls
|
||||
},
|
||||
observedDomKeyDownCount: physicalInput.observedTrustedKeyDownCount,
|
||||
outboundIntentCount: 1,
|
||||
nativeRevision: current.state.operatorSettings.revision,
|
||||
restartRequired: current.state.operatorSettings.restartRequired
|
||||
});
|
||||
}
|
||||
evidence.exchanges.push({
|
||||
sequence,
|
||||
operation,
|
||||
targetId: control.id,
|
||||
appearanceKey,
|
||||
targetValue,
|
||||
optionIndex,
|
||||
requestPath,
|
||||
requestSha256: sha256(requestBytes),
|
||||
acknowledgementPath,
|
||||
acknowledgementSha256: acknowledgement.sha256,
|
||||
physicalKeyCounts: {
|
||||
home: ack.homeKeyCalls,
|
||||
arrowDown: ack.arrowDownKeyCalls,
|
||||
enter: ack.enterKeyCalls
|
||||
},
|
||||
physicalInput,
|
||||
settings: ack.settings
|
||||
});
|
||||
}
|
||||
|
||||
async function checkpoint(name, details = null) {
|
||||
const snapshot = await readSnapshot();
|
||||
assertSafety(snapshot, name, false);
|
||||
evidence.checkpoints.push({ name, details, snapshot });
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function captureScreenshot() {
|
||||
const response = await rpc("Page.captureScreenshot", {
|
||||
format: "png",
|
||||
fromSurface: true,
|
||||
captureBeyondViewport: false
|
||||
}, 20_000);
|
||||
if (!response?.data) failKnown("CDP did not return screenshot data.");
|
||||
const bytes = Buffer.from(response.data, "base64");
|
||||
fs.writeFileSync(configuration.screenshotPath, bytes, { flag: "wx" });
|
||||
evidence.screenshot = {
|
||||
path: configuration.screenshotPath,
|
||||
bytes: bytes.length,
|
||||
sha256: sha256(bytes)
|
||||
};
|
||||
}
|
||||
|
||||
async function runSequence() {
|
||||
await rpc("Runtime.enable");
|
||||
await rpc("Page.enable");
|
||||
await installProbe();
|
||||
let current = await waitFor("DryRun IDLE preflight", snapshot =>
|
||||
snapshot.state?.playout?.mode === "dryRun" &&
|
||||
snapshot.state.isBusy !== true &&
|
||||
snapshot.state.operatorSettings != null,
|
||||
{ allowMissingState: true });
|
||||
assertAppearanceSnapshot(
|
||||
current,
|
||||
configuration.expected,
|
||||
"input-free initial appearance",
|
||||
true);
|
||||
evidence.persistenceVerifiedBeforeInput = configuration.phase === "verify-restore";
|
||||
if (configuration.phase === "verify-restore") {
|
||||
const expectedStartupWorkspace = configuration.expected.startWorkspace === "lastWorkspace"
|
||||
? "settings"
|
||||
: "stock";
|
||||
current = await waitFor("persisted start workspace behavior", snapshot =>
|
||||
appearanceEquals(snapshot.appearance, configuration.expected) &&
|
||||
snapshot.dom.activeWorkspace === expectedStartupWorkspace,
|
||||
{ allowBusy: false });
|
||||
evidence.startWorkspaceBehaviorVerifiedBeforeInput = true;
|
||||
}
|
||||
evidence.checkpoints.push({
|
||||
name: configuration.phase === "verify-restore"
|
||||
? "persisted-appearance-after-relaunch"
|
||||
: "baseline-appearance-before-mutation",
|
||||
expectedStartupWorkspace: configuration.phase === "verify-restore"
|
||||
? (configuration.expected.startWorkspace === "lastWorkspace" ? "settings" : "stock")
|
||||
: null,
|
||||
snapshot: current
|
||||
});
|
||||
|
||||
await requestPhysicalInput({
|
||||
operation: "application-click",
|
||||
control: controls.settings,
|
||||
expectedBefore: configuration.expected,
|
||||
expectedAfter: configuration.expected
|
||||
});
|
||||
current = await waitFor("settings workspace", snapshot =>
|
||||
snapshot.dom.settingsVisible === true &&
|
||||
snapshot.dom.settingsCurrent === "page" &&
|
||||
snapshot.dom.activeWorkspace === "settings",
|
||||
{ allowBusy: false });
|
||||
assertAppearanceSnapshot(current, configuration.expected, "settings workspace preflight");
|
||||
|
||||
let before = configuration.expected;
|
||||
for (const appearanceKey of ["colorTheme", "viewMode", "startWorkspace"]) {
|
||||
const after = Object.freeze({
|
||||
...before,
|
||||
[appearanceKey]: configuration.final[appearanceKey]
|
||||
});
|
||||
await requestPhysicalInput({
|
||||
operation: "select-appearance-option",
|
||||
control: controls[appearanceKey],
|
||||
appearanceKey,
|
||||
targetValue: configuration.final[appearanceKey],
|
||||
expectedBefore: before,
|
||||
expectedAfter: after
|
||||
});
|
||||
before = after;
|
||||
}
|
||||
|
||||
current = await checkpoint(
|
||||
configuration.phase === "verify-restore"
|
||||
? "baseline-restored-by-physical-input"
|
||||
: "alternate-appearance-persisted",
|
||||
{ appearance: configuration.final });
|
||||
assertAppearanceSnapshot(current, configuration.final, "final appearance");
|
||||
if (evidence.exchanges.length !== 4 || evidence.selections.length !== 3 ||
|
||||
nextExchangeSequence !== 5 ||
|
||||
evidence.selections.some(selection => selection.trustedChange !== true ||
|
||||
selection.physicalKeyCounts.home !== 1 ||
|
||||
selection.physicalKeyCounts.enter !== 1 ||
|
||||
selection.physicalKeyCounts.total !==
|
||||
2 + selection.physicalKeyCounts.arrowDown ||
|
||||
selection.outboundIntentCount !== 1 || selection.restartRequired !== false)) {
|
||||
failKnown("The closed four-step appearance input plan did not complete exactly once.");
|
||||
}
|
||||
await captureScreenshot();
|
||||
evidence.finalSnapshot = current;
|
||||
evidence.result = "PASS";
|
||||
evidence.completedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
async function writeEvidence() {
|
||||
const bytes = Buffer.from(`${JSON.stringify(evidence, null, 2)}\n`, "utf8");
|
||||
fs.writeFileSync(configuration.outputPath, bytes, { flag: "wx" });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const target = await discoverTarget();
|
||||
evidence.target.id = target.id;
|
||||
evidence.target.url = target.url;
|
||||
await connect(validateEndpoint(target));
|
||||
await runSequence();
|
||||
} catch (error) {
|
||||
exitCode = 1;
|
||||
evidence.result = "FAIL";
|
||||
evidence.completedAt = new Date().toISOString();
|
||||
evidence.error = {
|
||||
category: error instanceof AppearanceFailure ? error.category : "OUTCOME_UNKNOWN",
|
||||
message: error instanceof AppearanceFailure
|
||||
? error.message
|
||||
: "OUTCOME_UNKNOWN: Unexpected appearance harness failure."
|
||||
};
|
||||
} finally {
|
||||
try {
|
||||
await removeProbe();
|
||||
} catch (cleanupError) {
|
||||
exitCode = 1;
|
||||
evidence.result = "FAIL";
|
||||
evidence.completedAt = new Date().toISOString();
|
||||
evidence.error = {
|
||||
category: "OUTCOME_UNKNOWN",
|
||||
message: cleanupError instanceof Error
|
||||
? cleanupError.message
|
||||
: "OUTCOME_UNKNOWN: Appearance probe cleanup failed."
|
||||
};
|
||||
}
|
||||
if (socket && socket.readyState === WebSocket.OPEN) socket.close();
|
||||
if (!fs.existsSync(configuration.outputPath)) {
|
||||
try { await writeEvidence(); }
|
||||
catch { exitCode = 1; }
|
||||
}
|
||||
}
|
||||
process.exitCode = exitCode;
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -1278,7 +1278,16 @@ async function readWorkspaceLayout() {
|
||||
const stock = document.getElementById("stock-workspace");
|
||||
const catalog = document.getElementById("catalog-workspace");
|
||||
const settings = document.getElementById("settings-workspace");
|
||||
const splitter = document.getElementById("workspace-splitter");
|
||||
const schedule = document.querySelector(".playlist-panel");
|
||||
const publishedScheduleWidth = window.LegacyWorkspaceNavigation?.scheduleWidth?.();
|
||||
let storedScheduleWidth = null;
|
||||
try {
|
||||
storedScheduleWidth = window.localStorage?.getItem(
|
||||
"mbn-stock-webview.schedule-width.v1") ?? null;
|
||||
} catch (_) {
|
||||
storedScheduleWidth = null;
|
||||
}
|
||||
const scheduleBounds = schedule?.getBoundingClientRect() || null;
|
||||
const scheduleHit = scheduleBounds && scheduleBounds.width > 0 && scheduleBounds.height > 0
|
||||
? document.elementFromPoint(
|
||||
@@ -1294,6 +1303,24 @@ async function readWorkspaceLayout() {
|
||||
region: rectangle(region),
|
||||
navigation: rectangle(navigation),
|
||||
surface: rectangle(surface),
|
||||
splitter: rectangle(splitter),
|
||||
splitterState: splitter ? {
|
||||
role: splitter.getAttribute("role"),
|
||||
orientation: splitter.getAttribute("aria-orientation"),
|
||||
controls: splitter.getAttribute("aria-controls"),
|
||||
valueMinimum: Number(splitter.getAttribute("aria-valuemin")),
|
||||
valueMaximum: Number(splitter.getAttribute("aria-valuemax")),
|
||||
valueNow: Number(splitter.getAttribute("aria-valuenow")),
|
||||
valueText: splitter.getAttribute("aria-valuetext"),
|
||||
tabIndex: splitter.tabIndex,
|
||||
focused: document.activeElement === splitter,
|
||||
dragging: splitter.classList.contains("dragging")
|
||||
} : null,
|
||||
scheduleWidthPercent: Number.isFinite(publishedScheduleWidth)
|
||||
? publishedScheduleWidth
|
||||
: null,
|
||||
scheduleCssWidth: shell?.style?.getPropertyValue("--schedule-pane-width") || null,
|
||||
storedScheduleWidth,
|
||||
schedule: rectangle(schedule),
|
||||
scheduleHitOwned: Boolean(schedule && scheduleHit && schedule.contains(scheduleHit)),
|
||||
activeWorkspace: region?.dataset?.activeWorkspace || null,
|
||||
@@ -1380,21 +1407,36 @@ async function waitForWorkspaceLayout(label, predicate) {
|
||||
|
||||
function assertWorkspaceAndScheduleGeometry(layout, label) {
|
||||
if (!layout?.shell || !layout.region || !layout.navigation || !layout.surface ||
|
||||
!layout.splitter || layout.splitter.width <= 0 || layout.splitter.height <= 0 ||
|
||||
!layout.schedule || layout.schedule.width <= 0 || layout.schedule.height <= 0 ||
|
||||
layout.scheduleHitOwned !== true) {
|
||||
failKnown(`${label} does not expose the complete workspace and fixed schedule geometry: ` +
|
||||
failKnown(`${label} does not expose the complete workspace, splitter, and schedule geometry: ` +
|
||||
JSON.stringify(layout));
|
||||
}
|
||||
const widthRatio = layout.region.width / layout.schedule.width;
|
||||
const workspaceShare = layout.region.width / layout.shell.width;
|
||||
if (!Number.isFinite(widthRatio) || widthRatio < 1.60 || widthRatio > 1.90 ||
|
||||
!Number.isFinite(workspaceShare) || workspaceShare < 0.62 || workspaceShare > 0.70 ||
|
||||
const scheduleShare = layout.schedule.width / layout.shell.width;
|
||||
const splitterState = layout.splitterState;
|
||||
if (!Number.isFinite(widthRatio) || widthRatio < 1.05 || widthRatio > 1.95 ||
|
||||
!Number.isFinite(workspaceShare) || workspaceShare < 0.50 || workspaceShare > 0.67 ||
|
||||
!Number.isFinite(scheduleShare) || scheduleShare < 0.33 || scheduleShare > 0.49 ||
|
||||
splitterState?.role !== "separator" || splitterState.orientation !== "vertical" ||
|
||||
splitterState.controls !== "workspace-region" || splitterState.tabIndex !== 0 ||
|
||||
!Number.isFinite(splitterState.valueMinimum) || splitterState.valueMinimum < 34 ||
|
||||
!Number.isFinite(splitterState.valueMaximum) || splitterState.valueMaximum > 48 ||
|
||||
!Number.isFinite(splitterState.valueNow) ||
|
||||
splitterState.valueNow < splitterState.valueMinimum ||
|
||||
splitterState.valueNow > splitterState.valueMaximum ||
|
||||
Math.abs(layout.region.right - layout.splitter.left) > 1.5 ||
|
||||
Math.abs(layout.splitter.right - layout.schedule.left) > 1.5 ||
|
||||
Math.abs(layout.schedule.right - layout.shell.right) > 1.5 ||
|
||||
Math.abs(layout.splitter.top - layout.shell.top) > 1.5 ||
|
||||
Math.abs(layout.splitter.bottom - layout.shell.bottom) > 1.5 ||
|
||||
Math.abs(layout.schedule.top - layout.shell.top) > 1.5 ||
|
||||
Math.abs(layout.schedule.bottom - layout.shell.bottom) > 1.5) {
|
||||
failKnown(`${label} does not preserve the approximately 1.75:1 workspace/schedule split.`);
|
||||
failKnown(`${label} does not preserve the adjustable workspace/schedule split.`);
|
||||
}
|
||||
return { widthRatio, workspaceShare };
|
||||
return { widthRatio, workspaceShare, scheduleShare };
|
||||
}
|
||||
|
||||
function assertFixedSchedule(baseline, current, label) {
|
||||
@@ -1406,6 +1448,149 @@ function assertFixedSchedule(baseline, current, label) {
|
||||
assertWorkspaceAndScheduleGeometry(current, label);
|
||||
}
|
||||
|
||||
function assertSplitterResizeResult(layout, expectedPercent, label) {
|
||||
assertWorkspaceAndScheduleGeometry(layout, label);
|
||||
const storedPercent = Number.parseFloat(layout.storedScheduleWidth);
|
||||
const cssPercent = Number.parseFloat(layout.scheduleCssWidth);
|
||||
const geometryPercent = layout.schedule.width / layout.shell.width * 100;
|
||||
if (!Number.isFinite(layout.scheduleWidthPercent) ||
|
||||
Math.abs(layout.scheduleWidthPercent - expectedPercent) > 0.15 ||
|
||||
!Number.isFinite(storedPercent) ||
|
||||
Math.abs(storedPercent - layout.scheduleWidthPercent) > 0.01 ||
|
||||
!Number.isFinite(cssPercent) ||
|
||||
Math.abs(cssPercent - layout.scheduleWidthPercent) > 0.01 ||
|
||||
Math.abs(geometryPercent - layout.scheduleWidthPercent) > 0.15 ||
|
||||
!Number.isFinite(layout.splitterState?.valueNow) ||
|
||||
Math.abs(layout.splitterState.valueNow -
|
||||
Number(layout.scheduleWidthPercent.toFixed(1))) > 0.001 ||
|
||||
layout.splitterState.valueText !==
|
||||
`송출 스케줄 ${Math.round(layout.scheduleWidthPercent)}%` ||
|
||||
layout.splitterState.focused !== true || layout.splitterState.dragging !== false) {
|
||||
failKnown(`${label} did not synchronize splitter geometry, ARIA, CSS, and local storage: ` +
|
||||
JSON.stringify(layout));
|
||||
}
|
||||
return layout.scheduleWidthPercent;
|
||||
}
|
||||
|
||||
async function dragWorkspaceSplitterToPercent(beforeLayout, targetPercent, label) {
|
||||
await waitForPointerReady(label);
|
||||
const start = {
|
||||
x: beforeLayout.splitter.left + beforeLayout.splitter.width / 2,
|
||||
y: beforeLayout.splitter.top + beforeLayout.splitter.height / 2
|
||||
};
|
||||
const target = {
|
||||
x: beforeLayout.shell.right - beforeLayout.shell.width * targetPercent / 100,
|
||||
y: start.y
|
||||
};
|
||||
const before = await readSnapshot();
|
||||
assertSafety(before, `${label} preflight`, false);
|
||||
evidence.inputs.push({
|
||||
type: "workspace-splitter-drag",
|
||||
label,
|
||||
start,
|
||||
target,
|
||||
targetPercent
|
||||
});
|
||||
try {
|
||||
await pressMouse(start, 0);
|
||||
await sleep(25);
|
||||
await moveMouse(target, 0, 1);
|
||||
await sleep(25);
|
||||
await releaseMouse(target, 0);
|
||||
} catch (error) {
|
||||
await cleanupDragInputSession(false);
|
||||
throw error;
|
||||
}
|
||||
const resized = await waitForWorkspaceLayout(label, layout =>
|
||||
Number.isFinite(layout.scheduleWidthPercent) &&
|
||||
Math.abs(layout.scheduleWidthPercent - targetPercent) <= 0.15 &&
|
||||
Number.isFinite(Number.parseFloat(layout.storedScheduleWidth)) &&
|
||||
Math.abs(Number.parseFloat(layout.storedScheduleWidth) -
|
||||
layout.scheduleWidthPercent) <= 0.01);
|
||||
const after = await readSnapshot();
|
||||
assertSafety(after, label, false);
|
||||
if (after.safety.outboundMessages.length !== before.safety.outboundMessages.length) {
|
||||
failKnown(`${label} emitted a native WebView intent.`);
|
||||
}
|
||||
return resized;
|
||||
}
|
||||
|
||||
async function exerciseWorkspaceSplitter(baseline) {
|
||||
const baselinePercent = baseline.scheduleWidthPercent;
|
||||
const minimum = Math.max(34, 600 / baseline.shell.width * 100);
|
||||
const maximum = Math.min(48, (baseline.shell.width - 780 - 8) /
|
||||
baseline.shell.width * 100);
|
||||
const range = maximum - minimum;
|
||||
if (!Number.isFinite(baselinePercent) || range < 3 ||
|
||||
baselinePercent < minimum - 0.15 || baselinePercent > maximum + 0.15) {
|
||||
failKnown("The rendered window has no safe splitter resize range.");
|
||||
}
|
||||
|
||||
const lowerTarget = minimum + range / 3;
|
||||
const upperTarget = maximum - range / 3;
|
||||
const pointerTarget = Math.abs(baselinePercent - lowerTarget) >=
|
||||
Math.abs(baselinePercent - upperTarget)
|
||||
? lowerTarget
|
||||
: upperTarget;
|
||||
const pointerLayout = await dragWorkspaceSplitterToPercent(
|
||||
baseline,
|
||||
pointerTarget,
|
||||
"drag workspace splitter");
|
||||
const pointerPercent = assertSplitterResizeResult(
|
||||
pointerLayout,
|
||||
pointerTarget,
|
||||
"workspace splitter pointer resize");
|
||||
|
||||
const leftExpected = Math.min(maximum, pointerPercent + 2);
|
||||
const rightExpected = Math.max(minimum, pointerPercent - 2);
|
||||
const keyboardKey = Math.abs(leftExpected - baselinePercent) >=
|
||||
Math.abs(rightExpected - baselinePercent)
|
||||
? "ArrowLeft"
|
||||
: "ArrowRight";
|
||||
const keyboardExpected = keyboardKey === "ArrowLeft" ? leftExpected : rightExpected;
|
||||
if (Math.abs(keyboardExpected - pointerPercent) < 0.25) {
|
||||
failKnown("The splitter has no safe keyboard resize step after pointer input.");
|
||||
}
|
||||
const beforeKeyboard = await readSnapshot();
|
||||
assertSafety(beforeKeyboard, "workspace splitter keyboard resize preflight", false);
|
||||
await pressKey(keyboardKey, "resize workspace splitter from keyboard");
|
||||
const keyboardLayout = await waitForWorkspaceLayout(
|
||||
"workspace splitter keyboard resize",
|
||||
layout => Number.isFinite(layout.scheduleWidthPercent) &&
|
||||
Math.abs(layout.scheduleWidthPercent - keyboardExpected) <= 0.15 &&
|
||||
Number.isFinite(Number.parseFloat(layout.storedScheduleWidth)) &&
|
||||
Math.abs(Number.parseFloat(layout.storedScheduleWidth) -
|
||||
layout.scheduleWidthPercent) <= 0.01);
|
||||
const keyboardPercent = assertSplitterResizeResult(
|
||||
keyboardLayout,
|
||||
keyboardExpected,
|
||||
"workspace splitter keyboard resize");
|
||||
const afterKeyboard = await readSnapshot();
|
||||
assertSafety(afterKeyboard, "workspace splitter keyboard resize", false);
|
||||
if (afterKeyboard.safety.outboundMessages.length !==
|
||||
beforeKeyboard.safety.outboundMessages.length) {
|
||||
failKnown("The workspace splitter keyboard resize emitted a native WebView intent.");
|
||||
}
|
||||
|
||||
const restoredLayout = await dragWorkspaceSplitterToPercent(
|
||||
keyboardLayout,
|
||||
baselinePercent,
|
||||
"restore workspace splitter width");
|
||||
const restoredPercent = assertSplitterResizeResult(
|
||||
restoredLayout,
|
||||
baselinePercent,
|
||||
"restored workspace splitter width");
|
||||
assertFixedSchedule(baseline, restoredLayout, "restored workspace splitter geometry");
|
||||
return {
|
||||
baselinePercent,
|
||||
pointerPercent,
|
||||
keyboardKey,
|
||||
keyboardPercent,
|
||||
restoredPercent,
|
||||
storedPercent: Number.parseFloat(restoredLayout.storedScheduleWidth)
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureWorkspace(workspace, label) {
|
||||
let layout = await readWorkspaceLayout();
|
||||
if (!workspaceLayoutMatches(layout, workspace)) {
|
||||
@@ -1430,9 +1615,14 @@ async function ensureWorkspace(workspace, label) {
|
||||
}
|
||||
|
||||
async function verifyWorkspaceNavigationAndLayout(preflight) {
|
||||
const baseline = await ensureWorkspace("stock", "workspace stock preflight");
|
||||
let baseline = await ensureWorkspace("stock", "workspace stock preflight");
|
||||
if (baseline.navigationExpanded !== true || baseline.navigationCollapsedClass === true) {
|
||||
failKnown("The workspace menu was not initially expanded.");
|
||||
await pointerClick(
|
||||
'document.getElementById("workspace-nav-toggle")',
|
||||
"expand workspace menu before layout verification");
|
||||
baseline = await waitForWorkspaceLayout("expanded workspace stock preflight", layout =>
|
||||
workspaceLayoutMatches(layout, "stock", true) &&
|
||||
layout.navigationCollapsedClass === false);
|
||||
}
|
||||
const firstMarket = baseline.marketTabs[0];
|
||||
if (!firstMarket?.tabId || !firstMarket.label) {
|
||||
@@ -1441,6 +1631,7 @@ async function verifyWorkspaceNavigationAndLayout(preflight) {
|
||||
const baselineRatio = assertWorkspaceAndScheduleGeometry(
|
||||
baseline,
|
||||
"expanded stock workspace preflight");
|
||||
const splitterResize = await exerciseWorkspaceSplitter(baseline);
|
||||
|
||||
await pointerClick(
|
||||
'document.getElementById("workspace-nav-toggle")',
|
||||
@@ -1602,9 +1793,11 @@ async function verifyWorkspaceNavigationAndLayout(preflight) {
|
||||
await checkpoint("workspace-navigation-layout", {
|
||||
widthRatio: Number(baselineRatio.widthRatio.toFixed(4)),
|
||||
workspaceShare: Number(baselineRatio.workspaceShare.toFixed(4)),
|
||||
scheduleShare: Number(baselineRatio.scheduleShare.toFixed(4)),
|
||||
expandedNavigationWidth: baseline.navigation.width,
|
||||
collapsedNavigationWidth: collapsed.navigation.width,
|
||||
schedule: baseline.schedule,
|
||||
splitterResize,
|
||||
graphicMenuCount: baseline.marketTabs.length,
|
||||
pointerTransitions: ["collapse", firstMarket.tabId, "expand", "settings", "stock"],
|
||||
verticalMenuDrag: [
|
||||
@@ -2023,6 +2216,8 @@ async function dragOperatorCatalogRow(sourceRowId, targetRowId, position, label)
|
||||
}
|
||||
|
||||
const keyDefinitions = Object.freeze({
|
||||
ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", virtualKey: 37 },
|
||||
ArrowRight: { key: "ArrowRight", code: "ArrowRight", virtualKey: 39 },
|
||||
ArrowDown: { key: "ArrowDown", code: "ArrowDown", virtualKey: 40 },
|
||||
ArrowUp: { key: "ArrowUp", code: "ArrowUp", virtualKey: 38 },
|
||||
Backspace: { key: "Backspace", code: "Backspace", virtualKey: 8 },
|
||||
|
||||
@@ -5,8 +5,10 @@ import { createHash, randomBytes } from "node:crypto";
|
||||
const exactTargetUrl = "https://legacy-parity.mbn.local/index.html";
|
||||
const expectedSourceSha256 =
|
||||
"1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2";
|
||||
const expectedInitialDestinationSha256 =
|
||||
const expectedFreshDestinationSha256 =
|
||||
"13C07BA64587549EDA9C1A694F3DFA19875CB1F888F02C9E071F17C47739E087";
|
||||
const expectedImportedDestinationSha256 =
|
||||
"2575C2420800670B0022EBEE819BA3D9640DB14E9552426DF46075D079F1F697";
|
||||
const expectedImportConfirmation =
|
||||
"현재 저장된 비교쌍의 순서는 그대로 유지하고, 원본 프로그램의 비교쌍 중 없는 항목만 뒤에 추가할까요?";
|
||||
const allowedOutboundTypes = new Set([
|
||||
@@ -142,8 +144,11 @@ const evidence = {
|
||||
files: {
|
||||
expectedSourceSha256,
|
||||
expectedSourceRowCount: 8,
|
||||
expectedInitialDestinationSha256,
|
||||
expectedInitialDestinationRowCount: 1,
|
||||
expectedFreshDestinationSha256,
|
||||
expectedFreshDestinationRowCount: 1,
|
||||
expectedImportedDestinationSha256,
|
||||
expectedImportedDestinationRowCount: 9,
|
||||
initialDestinationMode: null,
|
||||
settingsBaseline: null,
|
||||
destinationAfterFirstImport: null,
|
||||
destinationAfterSecondImport: null
|
||||
@@ -155,6 +160,7 @@ const evidence = {
|
||||
},
|
||||
comparison: {
|
||||
initialPair: null,
|
||||
initialPairCount: null,
|
||||
afterFirstImport: null,
|
||||
afterSecondImport: null,
|
||||
initialPairPreserved: false,
|
||||
@@ -651,14 +657,20 @@ async function waitForTrustedPointerClick(targetId, startIndex) {
|
||||
}
|
||||
|
||||
function assertExactPreflightFiles(files) {
|
||||
const isFresh = files.destination?.sha256 === expectedFreshDestinationSha256 &&
|
||||
files.destination?.rowCount === 1;
|
||||
const isAlreadyImported =
|
||||
files.destination?.sha256 === expectedImportedDestinationSha256 &&
|
||||
files.destination?.rowCount === 9;
|
||||
if (files.source?.sha256 !== expectedSourceSha256 || files.source?.rowCount !== 8 ||
|
||||
files.source?.isReadOnlySource !== true ||
|
||||
files.destination?.sha256 !== expectedInitialDestinationSha256 ||
|
||||
files.destination?.rowCount !== 1 ||
|
||||
(!isFresh && !isAlreadyImported) ||
|
||||
files.destination?.firstPair !== "KRX100지수|코스피 지수") {
|
||||
failKnown("The original source or one-row current comparison baseline changed.");
|
||||
failKnown("The original source or known current comparison baseline changed.");
|
||||
}
|
||||
evidence.files.settingsBaseline = files.settings;
|
||||
evidence.files.initialDestinationMode = isFresh ? "fresh" : "alreadyImported";
|
||||
return evidence.files.initialDestinationMode;
|
||||
}
|
||||
|
||||
function assertSettingsUnchanged(files, label) {
|
||||
@@ -676,7 +688,10 @@ async function runSequence() {
|
||||
{ allowBusy: false, allowMissingState: true });
|
||||
|
||||
const preflight = await requestExchange("observe-local-files");
|
||||
assertExactPreflightFiles(preflight.files);
|
||||
const baselineMode = assertExactPreflightFiles(preflight.files);
|
||||
const expectedInitialPairCount = baselineMode === "fresh" ? 1 : 9;
|
||||
const expectedFirstAdded = baselineMode === "fresh" ? 8 : 0;
|
||||
const expectedFirstSkipped = baselineMode === "fresh" ? 0 : 8;
|
||||
evidence.checkpoints.push({ name: "exact-file-preflight", files: preflight.files });
|
||||
|
||||
await requestExchange("application-click", "settings-navigation");
|
||||
@@ -708,19 +723,25 @@ async function runSequence() {
|
||||
evidence.settings.settingsFileUnchanged = true;
|
||||
|
||||
await requestExchange("application-click", "comparison-navigation");
|
||||
let comparison = await waitFor("comparison one-row baseline", snapshot =>
|
||||
let comparison = await waitFor(`comparison ${baselineMode} baseline`, snapshot =>
|
||||
snapshot.state.isBusy !== true && snapshot.dom.activeTabId === "comparison" &&
|
||||
snapshot.state.comparison?.isBusy !== true &&
|
||||
snapshot.state.comparison?.savedPairs?.length === 1 &&
|
||||
snapshot.state.comparison?.savedPairs?.length === expectedInitialPairCount &&
|
||||
snapshot.state.comparison?.canImportLegacyPairs === true,
|
||||
{ allowBusy: false });
|
||||
const initialPair = stablePair(comparison.state.comparison.savedPairs[0]);
|
||||
{ allowBusy: true });
|
||||
const initialPairs = comparison.state.comparison.savedPairs.map(stablePair);
|
||||
const initialPair = initialPairs[0];
|
||||
if (typeof initialPair?.rowId !== "string" || initialPair.rowId.length === 0 ||
|
||||
initialPair.rowNumber !== 1 ||
|
||||
initialPair.displayLabel !== "KRX100지수 ↔ 코스피 지수") {
|
||||
failKnown("The visible one-row comparison baseline is not the expected known pair.");
|
||||
failKnown("The visible comparison baseline does not start with the expected known pair.");
|
||||
}
|
||||
evidence.comparison.initialPair = initialPair;
|
||||
if (initialPairs.some((pair, index) => typeof pair?.rowId !== "string" ||
|
||||
pair.rowId.length === 0 || pair.rowNumber !== index + 1)) {
|
||||
failKnown("The visible comparison baseline does not retain sequential rows.");
|
||||
}
|
||||
evidence.comparison.initialPairCount = initialPairs.length;
|
||||
|
||||
await requestExchange("application-click", "comparison-import");
|
||||
await waitFor("first import confirmation", snapshot =>
|
||||
@@ -737,12 +758,14 @@ async function runSequence() {
|
||||
return snapshot.state.isBusy !== true && model?.isBusy !== true &&
|
||||
snapshot.dom.legacyDialogHidden === true && model?.savedPairs?.length === 9 &&
|
||||
receipt?.sourceSha256 === expectedSourceSha256 && receipt?.sourceRowCount === 8 &&
|
||||
receipt?.addedPairCount === 8 && receipt?.skippedDuplicateCount === 0 &&
|
||||
receipt?.addedPairCount === expectedFirstAdded &&
|
||||
receipt?.skippedDuplicateCount === expectedFirstSkipped &&
|
||||
receipt?.totalPairCount === 9;
|
||||
}, { allowBusy: true, allowDialog: true, timeoutMilliseconds: 90_000 });
|
||||
const afterFirstPairs = comparison.state.comparison.savedPairs.map(stablePair);
|
||||
if (JSON.stringify(afterFirstPairs[0]) !== JSON.stringify(initialPair)) {
|
||||
failKnown("The original current comparison pair was not preserved at the front.");
|
||||
if (JSON.stringify(afterFirstPairs.slice(0, initialPairs.length)) !==
|
||||
JSON.stringify(initialPairs)) {
|
||||
failKnown("The existing ordered comparison pairs were not preserved.");
|
||||
}
|
||||
evidence.comparison.initialPairPreserved = true;
|
||||
evidence.comparison.afterFirstImport = {
|
||||
@@ -754,8 +777,8 @@ async function runSequence() {
|
||||
assertSettingsUnchanged(afterFirstFiles.files, "after the first comparison import");
|
||||
if (afterFirstFiles.files.destination?.rowCount !== 9 ||
|
||||
afterFirstFiles.files.destination?.firstPair !== "KRX100지수|코스피 지수" ||
|
||||
afterFirstFiles.files.destination?.sha256 === expectedInitialDestinationSha256) {
|
||||
failKnown("The first comparison import file is not an appended nine-row result.");
|
||||
afterFirstFiles.files.destination?.sha256 !== expectedImportedDestinationSha256) {
|
||||
failKnown("The first comparison import did not retain the known nine-row result.");
|
||||
}
|
||||
evidence.files.destinationAfterFirstImport = afterFirstFiles.files.destination;
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ const evidence = {
|
||||
inferredFromAuthoritativeState: true,
|
||||
nativeRoute: "LegacyReadyIntent => _controller.Current"
|
||||
},
|
||||
preGuardStartupSettle: null,
|
||||
blockedMessages: [],
|
||||
observedMessagesAfterGuard: []
|
||||
},
|
||||
@@ -162,6 +163,66 @@ async function evaluate(expression) {
|
||||
return response.result?.value;
|
||||
}
|
||||
|
||||
async function waitForSettledStartup() {
|
||||
const settleDeadline = Math.min(Date.now() + 10_000, deadline);
|
||||
let stableSignature = null;
|
||||
let stableSince = 0;
|
||||
let lastSample = null;
|
||||
while (Date.now() < settleDeadline) {
|
||||
lastSample = await evaluate(`(() => {
|
||||
const currentMenu = Array.from(document.querySelectorAll(
|
||||
"#workspace-navigation .workspace-nav-item[aria-current='page']"));
|
||||
const region = document.getElementById("workspace-region");
|
||||
return {
|
||||
readyState: document.readyState,
|
||||
bodyBusy: document.body?.getAttribute("aria-busy") || null,
|
||||
bridgeReady: typeof window.chrome?.webview?.postMessage === "function",
|
||||
connectionText: (document.getElementById("playout-connection-state")?.textContent || "").trim(),
|
||||
playoutStatus: (document.getElementById("playout-status")?.textContent || "").trim(),
|
||||
activeWorkspace: region?.dataset?.activeWorkspace || null,
|
||||
activeMarketTab: region?.dataset?.activeMarketTab || null,
|
||||
startWorkspace: document.body?.dataset?.startWorkspace || null,
|
||||
workspaceTitle: (document.getElementById("workspace-title")?.textContent || "").trim(),
|
||||
currentMenu: currentMenu.map(element => element.id || element.dataset?.tabId || null)
|
||||
};
|
||||
})()`);
|
||||
const ready = lastSample?.readyState === "complete" &&
|
||||
lastSample.bodyBusy === "false" && lastSample.bridgeReady === true &&
|
||||
lastSample.connectionText === "DRY RUN" &&
|
||||
lastSample.playoutStatus.startsWith("IDLE") &&
|
||||
["stock", "catalog", "settings"].includes(lastSample.activeWorkspace) &&
|
||||
["stockCut", "lastWorkspace"].includes(lastSample.startWorkspace) &&
|
||||
lastSample.workspaceTitle.length > 0 && lastSample.currentMenu.length === 1;
|
||||
const signature = ready ? JSON.stringify({
|
||||
activeWorkspace: lastSample.activeWorkspace,
|
||||
activeMarketTab: lastSample.activeMarketTab,
|
||||
startWorkspace: lastSample.startWorkspace,
|
||||
workspaceTitle: lastSample.workspaceTitle,
|
||||
currentMenu: lastSample.currentMenu
|
||||
}) : null;
|
||||
if (signature && signature === stableSignature) {
|
||||
if (Date.now() - stableSince >= 400) {
|
||||
return {
|
||||
stableMilliseconds: Date.now() - stableSince,
|
||||
activeWorkspace: lastSample.activeWorkspace,
|
||||
activeMarketTab: lastSample.activeMarketTab,
|
||||
startWorkspace: lastSample.startWorkspace,
|
||||
workspaceTitle: lastSample.workspaceTitle,
|
||||
currentMenu: lastSample.currentMenu
|
||||
};
|
||||
}
|
||||
} else {
|
||||
stableSignature = signature;
|
||||
stableSince = signature ? Date.now() : 0;
|
||||
}
|
||||
await sleep(75);
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
fail("OUTCOME_UNKNOWN", "Startup did not settle before the observation deadline; no guard was installed.");
|
||||
}
|
||||
fail("KNOWN_FAILURE", `Startup did not reach a stable DryRun workspace: ${JSON.stringify(lastSample)}`);
|
||||
}
|
||||
|
||||
async function installNoIntentGuard() {
|
||||
const installed = await evaluate(`(() => {
|
||||
const bridge = window.chrome?.webview;
|
||||
@@ -212,6 +273,7 @@ async function readShell() {
|
||||
bounds.left < document.documentElement.clientWidth &&
|
||||
bounds.top < document.documentElement.clientHeight &&
|
||||
bounds.display !== "none" && bounds.visibility !== "hidden");
|
||||
const operatorShell = document.querySelector(".operator-shell");
|
||||
const workspaceRegion = document.getElementById("workspace-region");
|
||||
const navigationToggle = document.getElementById("workspace-nav-toggle");
|
||||
const stockTab = document.getElementById("workspace-stock-tab");
|
||||
@@ -220,8 +282,11 @@ async function readShell() {
|
||||
const stockWorkspace = document.getElementById("stock-workspace");
|
||||
const catalogWorkspace = document.getElementById("catalog-workspace");
|
||||
const settingsWorkspace = document.getElementById("settings-workspace");
|
||||
const splitter = document.getElementById("workspace-splitter");
|
||||
const schedule = document.querySelector(".playlist-panel");
|
||||
const shellBounds = rectangle(operatorShell);
|
||||
const workspaceBounds = rectangle(workspaceRegion);
|
||||
const splitterBounds = rectangle(splitter);
|
||||
const scheduleBounds = rectangle(schedule);
|
||||
const scheduleCenter = scheduleBounds ? {
|
||||
x: scheduleBounds.left + scheduleBounds.width / 2,
|
||||
@@ -249,12 +314,15 @@ async function readShell() {
|
||||
},
|
||||
layout: {
|
||||
activeWorkspace: workspaceRegion?.dataset?.activeWorkspace || null,
|
||||
navigationAriaExpanded: navigationToggle?.getAttribute("aria-expanded") || null,
|
||||
navigationCollapsedClass: workspaceRegion?.classList.contains("nav-collapsed") === true,
|
||||
navigationExpanded: navigationToggle?.getAttribute("aria-expanded") === "true" &&
|
||||
workspaceRegion?.classList.contains("nav-collapsed") !== true,
|
||||
stockTabCurrent: stockTab?.getAttribute("aria-current") || null,
|
||||
marketTabCount: marketTabs.length,
|
||||
marketTabsCurrent: marketTabs.map(button => ({
|
||||
tabId: button.dataset.tabId,
|
||||
label: (button.querySelector(".workspace-nav-label")?.textContent || "").trim(),
|
||||
current: button.getAttribute("aria-current") || null,
|
||||
hasIcon: Boolean(button.querySelector("svg")),
|
||||
hasLabel: Boolean(button.querySelector(".workspace-nav-label"))
|
||||
@@ -265,18 +333,42 @@ async function readShell() {
|
||||
stockWorkspaceVisible: isVisible(stockWorkspace, rectangle(stockWorkspace)) &&
|
||||
stockWorkspace?.getAttribute("aria-hidden") === "false" &&
|
||||
stockWorkspace?.hasAttribute("inert") !== true,
|
||||
stockWorkspaceHidden: stockWorkspace?.hidden === true &&
|
||||
stockWorkspace?.getAttribute("aria-hidden") === "true" &&
|
||||
stockWorkspace?.hasAttribute("inert") === true,
|
||||
catalogWorkspaceVisible: isVisible(catalogWorkspace, rectangle(catalogWorkspace)) &&
|
||||
catalogWorkspace?.getAttribute("aria-hidden") === "false" &&
|
||||
catalogWorkspace?.hasAttribute("inert") !== true,
|
||||
catalogWorkspaceHidden: catalogWorkspace?.hidden === true &&
|
||||
catalogWorkspace?.getAttribute("aria-hidden") === "true" &&
|
||||
catalogWorkspace?.hasAttribute("inert") === true,
|
||||
settingsWorkspaceVisible: isVisible(settingsWorkspace, rectangle(settingsWorkspace)) &&
|
||||
settingsWorkspace?.getAttribute("aria-hidden") === "false" &&
|
||||
settingsWorkspace?.hasAttribute("inert") !== true,
|
||||
settingsWorkspaceHidden: settingsWorkspace?.hidden === true &&
|
||||
settingsWorkspace?.getAttribute("aria-hidden") === "true" &&
|
||||
settingsWorkspace?.hasAttribute("inert") === true,
|
||||
workspaceTitle: text("workspace-title"),
|
||||
shell: shellBounds,
|
||||
workspace: workspaceBounds,
|
||||
splitter: splitterBounds,
|
||||
splitterState: splitter ? {
|
||||
role: splitter.getAttribute("role"),
|
||||
orientation: splitter.getAttribute("aria-orientation"),
|
||||
controls: splitter.getAttribute("aria-controls"),
|
||||
valueMinimum: Number(splitter.getAttribute("aria-valuemin")),
|
||||
valueMaximum: Number(splitter.getAttribute("aria-valuemax")),
|
||||
valueNow: Number(splitter.getAttribute("aria-valuenow")),
|
||||
tabIndex: splitter.tabIndex
|
||||
} : null,
|
||||
schedule: scheduleBounds,
|
||||
workspaceToScheduleWidthRatio: workspaceBounds && scheduleBounds &&
|
||||
scheduleBounds.width > 0
|
||||
? workspaceBounds.width / scheduleBounds.width
|
||||
: null,
|
||||
scheduleShare: shellBounds && scheduleBounds && shellBounds.width > 0
|
||||
? scheduleBounds.width / shellBounds.width
|
||||
: null,
|
||||
scheduleVisible: isVisible(schedule, scheduleBounds),
|
||||
scheduleHitTestVisible: Boolean(scheduleHit &&
|
||||
(scheduleHit === schedule || schedule?.contains(scheduleHit)))
|
||||
@@ -298,22 +390,53 @@ function assertShell(shell) {
|
||||
fail("KNOWN_FAILURE", "The package shell did not remain rendered DryRun/IDLE with zero guarded native intents.");
|
||||
}
|
||||
const layout = shell.layout;
|
||||
if (!layout || layout.activeWorkspace !== "stock" ||
|
||||
layout.navigationExpanded !== true ||
|
||||
layout.stockTabCurrent !== "page" || layout.settingsTabCurrent !== "false" ||
|
||||
layout.marketTabCount !== 10 ||
|
||||
layout.marketTabsCurrent.some(tab => tab.current !== "false" ||
|
||||
tab.hasIcon !== true || tab.hasLabel !== true) ||
|
||||
layout.currentMenuItemCount !== 1 ||
|
||||
layout.stockWorkspaceVisible !== true || layout.catalogWorkspaceHidden !== true ||
|
||||
layout.settingsWorkspaceHidden !== true) {
|
||||
fail("KNOWN_FAILURE", "The package did not render the expanded navigation with stock as the sole initial workspace.");
|
||||
const currentMarkets = layout?.marketTabsCurrent?.filter(tab => tab.current === "page") || [];
|
||||
const navigationExpansionConsistent = layout &&
|
||||
((layout.navigationAriaExpanded === "true" && layout.navigationCollapsedClass === false) ||
|
||||
(layout.navigationAriaExpanded === "false" && layout.navigationCollapsedClass === true));
|
||||
const commonNavigationOk = layout && navigationExpansionConsistent &&
|
||||
layout.marketTabCount === 10 && layout.currentMenuItemCount === 1 &&
|
||||
layout.marketTabsCurrent.every(tab => tab.hasIcon === true && tab.hasLabel === true);
|
||||
const stockWorkspaceOk = layout?.activeWorkspace === "stock" &&
|
||||
layout.stockTabCurrent === "page" && layout.settingsTabCurrent === "false" &&
|
||||
currentMarkets.length === 0 && layout.stockWorkspaceVisible === true &&
|
||||
layout.catalogWorkspaceHidden === true && layout.settingsWorkspaceHidden === true &&
|
||||
layout.workspaceTitle === "종목·컷";
|
||||
const settingsWorkspaceOk = layout?.activeWorkspace === "settings" &&
|
||||
layout.stockTabCurrent === "false" && layout.settingsTabCurrent === "page" &&
|
||||
currentMarkets.length === 0 && layout.stockWorkspaceHidden === true &&
|
||||
layout.catalogWorkspaceHidden === true && layout.settingsWorkspaceVisible === true &&
|
||||
layout.workspaceTitle === "설정";
|
||||
const catalogWorkspaceOk = layout?.activeWorkspace === "catalog" &&
|
||||
layout.stockTabCurrent === "false" && layout.settingsTabCurrent === "false" &&
|
||||
currentMarkets.length === 1 && layout.stockWorkspaceHidden === true &&
|
||||
layout.catalogWorkspaceVisible === true && layout.settingsWorkspaceHidden === true &&
|
||||
layout.workspaceTitle === currentMarkets[0]?.label;
|
||||
if (!commonNavigationOk ||
|
||||
[stockWorkspaceOk, settingsWorkspaceOk, catalogWorkspaceOk].filter(Boolean).length !== 1) {
|
||||
fail("KNOWN_FAILURE", "The package did not render one internally consistent initial workspace and menu selection.");
|
||||
}
|
||||
const splitterState = layout.splitterState;
|
||||
if (layout.scheduleVisible !== true || layout.scheduleHitTestVisible !== true ||
|
||||
!layout.shell || !layout.workspace || !layout.splitter || !layout.schedule ||
|
||||
!Number.isFinite(layout.workspaceToScheduleWidthRatio) ||
|
||||
layout.workspaceToScheduleWidthRatio < 1.60 ||
|
||||
layout.workspaceToScheduleWidthRatio > 1.90) {
|
||||
fail("KNOWN_FAILURE", "The package did not render a visible right schedule beside the approximately 1.75:1 workspace layout.");
|
||||
layout.workspaceToScheduleWidthRatio < 1.05 ||
|
||||
layout.workspaceToScheduleWidthRatio > 1.95 ||
|
||||
!Number.isFinite(layout.scheduleShare) ||
|
||||
layout.scheduleShare < 0.33 || layout.scheduleShare > 0.49 ||
|
||||
splitterState?.role !== "separator" || splitterState.orientation !== "vertical" ||
|
||||
splitterState.controls !== "workspace-region" || splitterState.tabIndex !== 0 ||
|
||||
!Number.isFinite(splitterState.valueMinimum) || splitterState.valueMinimum < 34 ||
|
||||
!Number.isFinite(splitterState.valueMaximum) || splitterState.valueMaximum > 48 ||
|
||||
!Number.isFinite(splitterState.valueNow) ||
|
||||
splitterState.valueNow < splitterState.valueMinimum ||
|
||||
splitterState.valueNow > splitterState.valueMaximum ||
|
||||
Math.abs(layout.workspace.right - layout.splitter.left) > 1.5 ||
|
||||
Math.abs(layout.splitter.right - layout.schedule.left) > 1.5 ||
|
||||
Math.abs(layout.schedule.right - layout.shell.right) > 1.5 ||
|
||||
Math.abs(layout.splitter.top - layout.shell.top) > 1.5 ||
|
||||
Math.abs(layout.splitter.bottom - layout.shell.bottom) > 1.5) {
|
||||
fail("KNOWN_FAILURE", "The package did not render a visible right schedule beside the adjustable workspace layout.");
|
||||
}
|
||||
for (const [name, control] of Object.entries(shell.controls || {})) {
|
||||
if (!control?.present) fail("KNOWN_FAILURE", `Required shell control ${name} is missing.`);
|
||||
@@ -335,6 +458,7 @@ try {
|
||||
await connect(target);
|
||||
await rpc("Runtime.enable");
|
||||
await rpc("Page.enable");
|
||||
evidence.safety.preGuardStartupSettle = await waitForSettledStartup();
|
||||
await installNoIntentGuard();
|
||||
await sleep(500);
|
||||
evidence.shell = await readShell();
|
||||
|
||||
Reference in New Issue
Block a user