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 --output " + "--screenshot --exchange-directory " + "--expected-color-theme --expected-view-mode " + "--expected-start-workspace --final-color-theme " + "--final-view-mode --final-start-workspace " + "[--timeout-ms ]"); } 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