999 lines
39 KiB
JavaScript
999 lines
39 KiB
JavaScript
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();
|