feat: add configurable operator appearance and layout

This commit is contained in:
2026-07-23 11:14:10 +09:00
parent cf117de144
commit f43483533a
34 changed files with 5942 additions and 740 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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)

View File

@@ -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

View 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();

View File

@@ -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 },

View File

@@ -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;

View File

@@ -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();