feat: add configurable operator appearance and layout
This commit is contained in:
998
scripts/Test-LegacyPackageAppearanceSmoke.mjs
Normal file
998
scripts/Test-LegacyPackageAppearanceSmoke.mjs
Normal file
@@ -0,0 +1,998 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
const exactTargetUrl = "https://legacy-parity.mbn.local/index.html";
|
||||
const allowedOutboundTypes = new Set([
|
||||
"ready",
|
||||
"select-tab",
|
||||
"set-operator-appearance"
|
||||
]);
|
||||
const forbiddenCdpMethods = new Set([
|
||||
"Browser.close",
|
||||
"Page.close",
|
||||
"Runtime.terminateExecution",
|
||||
"Target.closeTarget",
|
||||
"Input.dispatchMouseEvent",
|
||||
"Input.dispatchKeyEvent",
|
||||
"Input.insertText"
|
||||
]);
|
||||
const appearanceValues = Object.freeze({
|
||||
colorTheme: Object.freeze(["system", "light", "dark"]),
|
||||
viewMode: Object.freeze(["automatic", "compact", "cards"]),
|
||||
startWorkspace: Object.freeze(["stockCut", "lastWorkspace"])
|
||||
});
|
||||
const controls = Object.freeze({
|
||||
settings: Object.freeze({
|
||||
id: "workspace-settings-tab",
|
||||
expression: 'document.getElementById("workspace-settings-tab")'
|
||||
}),
|
||||
colorTheme: Object.freeze({
|
||||
id: "operator-color-theme",
|
||||
expression: 'document.getElementById("operator-color-theme")'
|
||||
}),
|
||||
viewMode: Object.freeze({
|
||||
id: "operator-view-mode",
|
||||
expression: 'document.getElementById("operator-view-mode")'
|
||||
}),
|
||||
startWorkspace: Object.freeze({
|
||||
id: "operator-start-workspace",
|
||||
expression: 'document.getElementById("operator-start-workspace")'
|
||||
})
|
||||
});
|
||||
|
||||
class AppearanceFailure extends Error {
|
||||
constructor(category, message) {
|
||||
super(`${category}: ${message}`);
|
||||
this.name = "AppearanceFailure";
|
||||
this.category = category;
|
||||
}
|
||||
}
|
||||
|
||||
function failKnown(message) {
|
||||
throw new AppearanceFailure("KNOWN_FAILURE", message);
|
||||
}
|
||||
|
||||
function failUnknown(message) {
|
||||
throw new AppearanceFailure("OUTCOME_UNKNOWN", message);
|
||||
}
|
||||
|
||||
function parseClosedValue(values, name, allowed) {
|
||||
const value = values.get(name);
|
||||
if (!allowed.includes(value)) failKnown(`--${name} is outside the closed value set.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const accepted = new Set([
|
||||
"phase",
|
||||
"port",
|
||||
"output",
|
||||
"screenshot",
|
||||
"exchange-directory",
|
||||
"expected-color-theme",
|
||||
"expected-view-mode",
|
||||
"expected-start-workspace",
|
||||
"final-color-theme",
|
||||
"final-view-mode",
|
||||
"final-start-workspace",
|
||||
"timeout-ms"
|
||||
]);
|
||||
if (argv.length === 0 || argv.length % 2 !== 0) {
|
||||
failKnown(
|
||||
"Usage: node scripts/Test-LegacyPackageAppearanceSmoke.mjs " +
|
||||
"--phase mutate|verify-restore --port <port> --output <absolute.json> " +
|
||||
"--screenshot <absolute.png> --exchange-directory <absolute-directory> " +
|
||||
"--expected-color-theme <value> --expected-view-mode <value> " +
|
||||
"--expected-start-workspace <value> --final-color-theme <value> " +
|
||||
"--final-view-mode <value> --final-start-workspace <value> " +
|
||||
"[--timeout-ms <milliseconds>]");
|
||||
}
|
||||
const values = new Map();
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const rawName = argv[index];
|
||||
const value = argv[index + 1];
|
||||
if (!rawName?.startsWith("--") || !value) failKnown("Arguments must be --name value pairs.");
|
||||
const name = rawName.slice(2);
|
||||
if (!accepted.has(name) || values.has(name)) failKnown(`Invalid argument --${name}.`);
|
||||
values.set(name, value);
|
||||
}
|
||||
for (const required of [
|
||||
"phase", "port", "output", "screenshot", "exchange-directory",
|
||||
"expected-color-theme", "expected-view-mode", "expected-start-workspace",
|
||||
"final-color-theme", "final-view-mode", "final-start-workspace"
|
||||
]) {
|
||||
if (!values.has(required)) failKnown(`--${required} is required.`);
|
||||
}
|
||||
const phase = values.get("phase");
|
||||
if (!new Set(["mutate", "verify-restore"]).has(phase)) {
|
||||
failKnown("--phase must be mutate or verify-restore.");
|
||||
}
|
||||
const port = Number(values.get("port"));
|
||||
const timeoutMilliseconds = Number(values.get("timeout-ms") || "120000");
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65_535) {
|
||||
failKnown("--port must be from 1024 through 65535.");
|
||||
}
|
||||
if (!Number.isInteger(timeoutMilliseconds) ||
|
||||
timeoutMilliseconds < 30_000 || timeoutMilliseconds > 900_000) {
|
||||
failKnown("--timeout-ms must be from 30000 through 900000.");
|
||||
}
|
||||
const outputPath = path.resolve(values.get("output"));
|
||||
const screenshotPath = path.resolve(values.get("screenshot"));
|
||||
const exchangeDirectory = path.resolve(values.get("exchange-directory"));
|
||||
if (!path.isAbsolute(values.get("output")) ||
|
||||
path.extname(outputPath).toLowerCase() !== ".json" ||
|
||||
!path.isAbsolute(values.get("screenshot")) ||
|
||||
path.extname(screenshotPath).toLowerCase() !== ".png" ||
|
||||
!path.isAbsolute(values.get("exchange-directory"))) {
|
||||
failKnown("Output, screenshot and exchange paths must be absolute and typed correctly.");
|
||||
}
|
||||
if (new Set([
|
||||
outputPath.toLowerCase(), screenshotPath.toLowerCase(), exchangeDirectory.toLowerCase()
|
||||
]).size !== 3) {
|
||||
failKnown("Evidence paths must be distinct.");
|
||||
}
|
||||
if (fs.existsSync(outputPath) || fs.existsSync(screenshotPath) ||
|
||||
!fs.existsSync(exchangeDirectory) ||
|
||||
!fs.statSync(exchangeDirectory).isDirectory() ||
|
||||
fs.readdirSync(exchangeDirectory).length !== 0) {
|
||||
failKnown("Evidence is never overwritten and the exchange directory must be empty.");
|
||||
}
|
||||
const expected = Object.freeze({
|
||||
colorTheme: parseClosedValue(
|
||||
values, "expected-color-theme", appearanceValues.colorTheme),
|
||||
viewMode: parseClosedValue(values, "expected-view-mode", appearanceValues.viewMode),
|
||||
startWorkspace: parseClosedValue(
|
||||
values, "expected-start-workspace", appearanceValues.startWorkspace)
|
||||
});
|
||||
const final = Object.freeze({
|
||||
colorTheme: parseClosedValue(values, "final-color-theme", appearanceValues.colorTheme),
|
||||
viewMode: parseClosedValue(values, "final-view-mode", appearanceValues.viewMode),
|
||||
startWorkspace: parseClosedValue(
|
||||
values, "final-start-workspace", appearanceValues.startWorkspace)
|
||||
});
|
||||
if (Object.keys(expected).some(key => expected[key] === final[key])) {
|
||||
failKnown("Every phase must physically change all three appearance values.");
|
||||
}
|
||||
return {
|
||||
phase,
|
||||
port,
|
||||
timeoutMilliseconds,
|
||||
outputPath,
|
||||
screenshotPath,
|
||||
exchangeDirectory,
|
||||
expected,
|
||||
final
|
||||
};
|
||||
}
|
||||
|
||||
const configuration = parseArguments(process.argv.slice(2));
|
||||
const hardDeadline = Date.now() + configuration.timeoutMilliseconds;
|
||||
let socket = null;
|
||||
let nextRpcId = 1;
|
||||
let nextExchangeSequence = 1;
|
||||
let probeInstalled = false;
|
||||
let cancellationRequested = false;
|
||||
const pendingRpc = new Map();
|
||||
const evidence = {
|
||||
schemaVersion: 1,
|
||||
profile: "operator-appearance-persistence",
|
||||
phase: configuration.phase,
|
||||
result: "RUNNING",
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: null,
|
||||
target: {
|
||||
expectedUrl: exactTargetUrl,
|
||||
port: configuration.port,
|
||||
id: null,
|
||||
url: null
|
||||
},
|
||||
expectedInitial: configuration.expected,
|
||||
expectedFinal: configuration.final,
|
||||
safety: {
|
||||
requiredMode: "dryRun",
|
||||
requiredPhase: "idle",
|
||||
inputRetryCount: 0,
|
||||
appCloseRequested: false,
|
||||
playoutIntentIssued: false,
|
||||
allowedOutboundTypes: [...allowedOutboundTypes],
|
||||
observedOutboundTypes: [],
|
||||
blockedOutboundMessages: [],
|
||||
stateViolations: []
|
||||
},
|
||||
persistenceVerifiedBeforeInput: false,
|
||||
startWorkspaceBehaviorVerifiedBeforeInput: false,
|
||||
selections: [],
|
||||
exchanges: [],
|
||||
checkpoints: [],
|
||||
finalSnapshot: null,
|
||||
screenshot: null,
|
||||
error: null
|
||||
};
|
||||
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", chunk => {
|
||||
if (String(chunk).split(/\r?\n/u).includes("CANCEL")) cancellationRequested = true;
|
||||
});
|
||||
if (typeof process.stdin.unref === "function") process.stdin.unref();
|
||||
|
||||
function assertDeadline(label) {
|
||||
if (cancellationRequested) failUnknown(`The wrapper cancelled ${label}; no input was retried.`);
|
||||
if (Date.now() >= hardDeadline) {
|
||||
failUnknown(`The global deadline expired ${label}; no input was retried.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function sleep(milliseconds) {
|
||||
assertDeadline("before waiting");
|
||||
await new Promise(resolve => setTimeout(
|
||||
resolve,
|
||||
Math.max(0, Math.min(milliseconds, hardDeadline - Date.now()))));
|
||||
assertDeadline("after waiting");
|
||||
}
|
||||
|
||||
function sha256(bytes) {
|
||||
return createHash("sha256").update(bytes).digest("hex").toUpperCase();
|
||||
}
|
||||
|
||||
function exactAppearance(value) {
|
||||
return value ? {
|
||||
colorTheme: value.colorTheme,
|
||||
viewMode: value.viewMode,
|
||||
startWorkspace: value.startWorkspace
|
||||
} : null;
|
||||
}
|
||||
|
||||
function appearanceEquals(left, right) {
|
||||
return Boolean(left && right &&
|
||||
left.colorTheme === right.colorTheme &&
|
||||
left.viewMode === right.viewMode &&
|
||||
left.startWorkspace === right.startWorkspace);
|
||||
}
|
||||
|
||||
async function discoverTarget() {
|
||||
const discoveryUrl = `http://127.0.0.1:${configuration.port}/json/list`;
|
||||
const deadline = Math.min(hardDeadline, Date.now() + 15_000);
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(discoveryUrl, { cache: "no-store" });
|
||||
if (response.ok) {
|
||||
const targets = await response.json();
|
||||
const exact = targets.filter(target =>
|
||||
target?.type === "page" && target.url === exactTargetUrl);
|
||||
if (exact.length > 1) failKnown("More than one exact package WebView target exists.");
|
||||
if (exact.length === 1) return exact[0];
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AppearanceFailure) throw error;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
failKnown("The exact package target was not found.");
|
||||
}
|
||||
|
||||
function validateEndpoint(target) {
|
||||
if (!target?.id || !target.webSocketDebuggerUrl) failKnown("The target has no debugger URL.");
|
||||
const endpoint = new URL(target.webSocketDebuggerUrl);
|
||||
if (endpoint.protocol !== "ws:" || endpoint.hostname !== "127.0.0.1" ||
|
||||
Number(endpoint.port) !== configuration.port ||
|
||||
endpoint.pathname !== `/devtools/page/${target.id}`) {
|
||||
failKnown("The debugger endpoint is not the exact loopback page endpoint.");
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
async function connect(endpoint) {
|
||||
if (typeof WebSocket !== "function") failKnown("This Node runtime has no WebSocket API.");
|
||||
socket = new WebSocket(endpoint.href);
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new AppearanceFailure(
|
||||
"OUTCOME_UNKNOWN", "CDP socket open timed out.")), 5_000);
|
||||
socket.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true });
|
||||
socket.addEventListener("error", () => { clearTimeout(timer); reject(new AppearanceFailure(
|
||||
"OUTCOME_UNKNOWN", "CDP socket failed to open.")); }, { once: true });
|
||||
});
|
||||
socket.addEventListener("message", event => {
|
||||
const message = JSON.parse(String(event.data));
|
||||
if (!Object.hasOwn(message, "id")) return;
|
||||
const pending = pendingRpc.get(message.id);
|
||||
if (!pending) return;
|
||||
pendingRpc.delete(message.id);
|
||||
clearTimeout(pending.timer);
|
||||
if (message.error) pending.reject(new AppearanceFailure(
|
||||
"KNOWN_FAILURE", `${pending.method} failed: ${message.error.message || "CDP error"}`));
|
||||
else pending.resolve(message.result);
|
||||
});
|
||||
}
|
||||
|
||||
async function rpc(method, params = {}, timeoutMilliseconds = 10_000) {
|
||||
assertDeadline(`before ${method}`);
|
||||
if (forbiddenCdpMethods.has(method)) failKnown(`CDP method ${method} is forbidden.`);
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) failUnknown("CDP socket is not open.");
|
||||
const id = nextRpcId++;
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingRpc.delete(id);
|
||||
reject(new AppearanceFailure(
|
||||
"OUTCOME_UNKNOWN", `${method} timed out; no input was retried.`));
|
||||
}, Math.min(timeoutMilliseconds, Math.max(1, hardDeadline - Date.now())));
|
||||
pendingRpc.set(id, { resolve, reject, timer, method });
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
}
|
||||
|
||||
async function evaluate(expression) {
|
||||
const result = await rpc("Runtime.evaluate", {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true
|
||||
});
|
||||
if (result?.exceptionDetails) failKnown("A read-only page inspection failed.");
|
||||
return result?.result?.value;
|
||||
}
|
||||
|
||||
async function installProbe() {
|
||||
const token = `appearance-${Date.now()}-${randomBytes(8).toString("hex")}`;
|
||||
const installed = await evaluate(`(() => {
|
||||
if (!window.chrome?.webview) throw new Error("WebView2 bridge is unavailable.");
|
||||
if (globalThis.__legacyAppearanceProbe) throw new Error("Appearance probe already exists.");
|
||||
const probe = {
|
||||
token: ${JSON.stringify(token)},
|
||||
states: [],
|
||||
outboundMessages: [],
|
||||
blockedOutboundMessages: [],
|
||||
stateViolations: [],
|
||||
inputEvents: [],
|
||||
originalPostMessage: window.chrome.webview.postMessage,
|
||||
postMessageWrapper: null,
|
||||
stateListener: null,
|
||||
inputListener: null
|
||||
};
|
||||
const allowed = new Set(${JSON.stringify([...allowedOutboundTypes])});
|
||||
probe.stateListener = event => {
|
||||
if (event.data?.type !== "state" || !event.data.payload) return;
|
||||
const state = event.data.payload;
|
||||
probe.states.push(structuredClone(state));
|
||||
if (probe.states.length > 100) probe.states.shift();
|
||||
const playout = state.playout;
|
||||
const reasons = [];
|
||||
if (!playout) reasons.push("missing-playout");
|
||||
else {
|
||||
if (playout.mode !== "dryRun") reasons.push("mode");
|
||||
if (playout.phase !== "idle") reasons.push("phase");
|
||||
if (playout.isConnected === true) reasons.push("connected");
|
||||
if (playout.preparedCode != null) reasons.push("prepared-code");
|
||||
if (playout.onAirCode != null) reasons.push("on-air-code");
|
||||
if (playout.outcomeUnknown === true) reasons.push("outcome-unknown");
|
||||
if (playout.isPlayCompletionPending === true) reasons.push("play-pending");
|
||||
if (playout.isTakeOutCompletionPending === true) reasons.push("takeout-pending");
|
||||
if (playout.isBusy === true) reasons.push("playout-busy");
|
||||
if (playout.refreshActive === true) reasons.push("refresh-active");
|
||||
}
|
||||
if (reasons.length > 0) probe.stateViolations.push({
|
||||
at: new Date().toISOString(),
|
||||
revision: state.revision ?? null,
|
||||
reasons
|
||||
});
|
||||
};
|
||||
probe.inputListener = event => {
|
||||
const element = event.target instanceof Element ? event.target : null;
|
||||
const controlled = element?.closest?.(
|
||||
"#workspace-settings-tab, #operator-color-theme, " +
|
||||
"#operator-view-mode, #operator-start-workspace");
|
||||
if (!controlled) return;
|
||||
probe.inputEvents.push({
|
||||
sequence: probe.inputEvents.length + 1,
|
||||
type: event.type,
|
||||
targetId: controlled.id || null,
|
||||
isTrusted: event.isTrusted === true,
|
||||
pointerType: event.pointerType || null,
|
||||
button: Number.isInteger(event.button) ? event.button : null,
|
||||
buttons: Number.isInteger(event.buttons) ? event.buttons : null,
|
||||
key: typeof event.key === "string" ? event.key : null,
|
||||
value: controlled instanceof HTMLSelectElement ? controlled.value : null,
|
||||
at: new Date().toISOString()
|
||||
});
|
||||
if (probe.inputEvents.length > 150) probe.inputEvents.shift();
|
||||
};
|
||||
probe.postMessageWrapper = message => {
|
||||
const type = typeof message?.type === "string" ? message.type : null;
|
||||
const record = {
|
||||
sequence: probe.outboundMessages.length + 1,
|
||||
type,
|
||||
appearance: type === "set-operator-appearance" ? {
|
||||
colorTheme: message?.payload?.colorTheme ?? null,
|
||||
viewMode: message?.payload?.viewMode ?? null,
|
||||
startWorkspace: message?.payload?.startWorkspace ?? null
|
||||
} : null,
|
||||
at: new Date().toISOString()
|
||||
};
|
||||
probe.outboundMessages.push(record);
|
||||
if (!type || !allowed.has(type)) {
|
||||
probe.blockedOutboundMessages.push(record);
|
||||
return;
|
||||
}
|
||||
return probe.originalPostMessage.call(window.chrome.webview, message);
|
||||
};
|
||||
try {
|
||||
window.chrome.webview.postMessage = probe.postMessageWrapper;
|
||||
if (window.chrome.webview.postMessage !== probe.postMessageWrapper) {
|
||||
throw new Error("The outbound safety gate could not be installed.");
|
||||
}
|
||||
globalThis.__legacyAppearanceProbe = probe;
|
||||
window.chrome.webview.addEventListener("message", probe.stateListener);
|
||||
["pointerdown", "pointerup", "keydown", "keyup", "input", "change"].forEach(type =>
|
||||
document.addEventListener(type, probe.inputListener, true));
|
||||
window.chrome.webview.postMessage({ type: "ready", payload: {} });
|
||||
} catch (error) {
|
||||
window.chrome.webview.postMessage = probe.originalPostMessage;
|
||||
delete globalThis.__legacyAppearanceProbe;
|
||||
throw error;
|
||||
}
|
||||
return probe.token;
|
||||
})()`);
|
||||
if (installed !== token) failKnown("The safety probe was not installed exactly once.");
|
||||
probeInstalled = true;
|
||||
}
|
||||
|
||||
async function removeProbe() {
|
||||
if (!probeInstalled || !socket || socket.readyState !== WebSocket.OPEN) return;
|
||||
const removed = await evaluate(`(() => {
|
||||
const probe = globalThis.__legacyAppearanceProbe;
|
||||
if (!probe) return false;
|
||||
window.chrome.webview.removeEventListener("message", probe.stateListener);
|
||||
["pointerdown", "pointerup", "keydown", "keyup", "input", "change"].forEach(type =>
|
||||
document.removeEventListener(type, probe.inputListener, true));
|
||||
if (window.chrome.webview.postMessage !== probe.postMessageWrapper) {
|
||||
throw new Error("The outbound safety gate identity changed.");
|
||||
}
|
||||
window.chrome.webview.postMessage = probe.originalPostMessage;
|
||||
delete globalThis.__legacyAppearanceProbe;
|
||||
return true;
|
||||
})()`);
|
||||
if (removed !== true) failUnknown("The safety probe was not restored exactly once.");
|
||||
probeInstalled = false;
|
||||
}
|
||||
|
||||
async function readSnapshot() {
|
||||
return await evaluate(`(() => {
|
||||
const probe = globalThis.__legacyAppearanceProbe;
|
||||
const state = probe?.states?.at(-1) || null;
|
||||
const html = document.documentElement;
|
||||
const body = document.body;
|
||||
const active = document.activeElement;
|
||||
const appearance = state?.operatorSettings ? {
|
||||
colorTheme: state.operatorSettings.colorTheme,
|
||||
viewMode: state.operatorSettings.viewMode,
|
||||
startWorkspace: state.operatorSettings.startWorkspace
|
||||
} : null;
|
||||
return {
|
||||
state: state ? {
|
||||
revision: state.revision,
|
||||
isBusy: state.isBusy === true,
|
||||
playout: structuredClone(state.playout),
|
||||
operatorSettings: state.operatorSettings ? {
|
||||
revision: state.operatorSettings.revision,
|
||||
colorTheme: state.operatorSettings.colorTheme,
|
||||
viewMode: state.operatorSettings.viewMode,
|
||||
startWorkspace: state.operatorSettings.startWorkspace,
|
||||
restartRequired: state.operatorSettings.restartRequired === true,
|
||||
message: state.operatorSettings.message || "",
|
||||
messageKind: state.operatorSettings.messageKind || null
|
||||
} : null
|
||||
} : null,
|
||||
appearance,
|
||||
safety: {
|
||||
wrapped: probe?.postMessageWrapper != null &&
|
||||
window.chrome?.webview?.postMessage === probe.postMessageWrapper,
|
||||
outboundMessages: (probe?.outboundMessages || []).map(value => ({ ...value })),
|
||||
blockedOutboundMessages: (probe?.blockedOutboundMessages || []).map(value => ({ ...value })),
|
||||
stateViolations: (probe?.stateViolations || []).map(value => ({
|
||||
...value,
|
||||
reasons: [...value.reasons]
|
||||
})),
|
||||
inputEvents: (probe?.inputEvents || []).map(value => ({ ...value }))
|
||||
},
|
||||
dom: {
|
||||
url: location.href,
|
||||
readyState: document.readyState,
|
||||
bodyBusy: body.getAttribute("aria-busy"),
|
||||
connectionText: (document.getElementById("playout-connection-state")?.textContent || "").trim(),
|
||||
settingsVisible: document.getElementById("settings-workspace")?.hidden === false &&
|
||||
document.getElementById("settings-workspace")?.inert !== true,
|
||||
settingsCurrent: document.getElementById("workspace-settings-tab")
|
||||
?.getAttribute("aria-current") || null,
|
||||
activeWorkspace: document.getElementById("workspace-region")
|
||||
?.dataset?.activeWorkspace || null,
|
||||
activeElementId: active?.id || null,
|
||||
selects: {
|
||||
colorTheme: document.getElementById("operator-color-theme")?.value ?? null,
|
||||
viewMode: document.getElementById("operator-view-mode")?.value ?? null,
|
||||
startWorkspace: document.getElementById("operator-start-workspace")?.value ?? null
|
||||
},
|
||||
htmlAppearance: {
|
||||
colorTheme: html.dataset.colorTheme || null,
|
||||
viewMode: html.dataset.viewMode || null,
|
||||
startWorkspace: html.dataset.startWorkspace || null
|
||||
},
|
||||
bodyAppearance: {
|
||||
colorTheme: body.dataset.colorTheme || null,
|
||||
viewMode: body.dataset.viewMode || null,
|
||||
startWorkspace: body.dataset.startWorkspace || null
|
||||
},
|
||||
computed: {
|
||||
colorScheme: getComputedStyle(html).colorScheme || null,
|
||||
bodyBackground: getComputedStyle(body).backgroundColor || null
|
||||
},
|
||||
legacyDialogHidden: document.getElementById("legacy-dialog")?.hidden === true,
|
||||
namedModalHidden: document.getElementById("named-playlist-modal")?.hidden === true,
|
||||
namedConfirmationHidden:
|
||||
document.getElementById("named-playlist-confirmation")?.hidden === true
|
||||
}
|
||||
};
|
||||
})()`);
|
||||
}
|
||||
|
||||
function updateSafety(snapshot) {
|
||||
evidence.safety.observedOutboundTypes =
|
||||
snapshot?.safety?.outboundMessages?.map(row => row.type) || [];
|
||||
evidence.safety.blockedOutboundMessages =
|
||||
snapshot?.safety?.blockedOutboundMessages || [];
|
||||
evidence.safety.stateViolations = snapshot?.safety?.stateViolations || [];
|
||||
evidence.safety.playoutIntentIssued = evidence.safety.observedOutboundTypes.some(type =>
|
||||
["prepare-playout", "take-in", "next-playout", "take-out",
|
||||
"choose-background", "toggle-background"].includes(type));
|
||||
}
|
||||
|
||||
function assertSafety(snapshot, label, allowBusy = false) {
|
||||
updateSafety(snapshot);
|
||||
if (!snapshot?.state?.playout || snapshot.safety.wrapped !== true) {
|
||||
failUnknown(`The native DryRun state or safety gate is missing at ${label}.`);
|
||||
}
|
||||
if (snapshot.safety.blockedOutboundMessages.length > 0) {
|
||||
failKnown(`An unapproved outbound message was blocked at ${label}.`);
|
||||
}
|
||||
if (snapshot.safety.stateViolations.length > 0) {
|
||||
failUnknown(`DryRun left its safe state at ${label}.`);
|
||||
}
|
||||
const playout = snapshot.state.playout;
|
||||
if (playout.mode !== "dryRun" || playout.phase !== "idle" ||
|
||||
playout.isConnected === true || playout.preparedCode != null ||
|
||||
playout.onAirCode != null || playout.outcomeUnknown === true ||
|
||||
playout.isPlayCompletionPending === true ||
|
||||
playout.isTakeOutCompletionPending === true || playout.isBusy === true ||
|
||||
playout.refreshActive === true) {
|
||||
failUnknown(`Playout is not exact DryRun IDLE at ${label}.`);
|
||||
}
|
||||
if (!allowBusy && snapshot.state.isBusy === true) {
|
||||
failKnown(`The operator UI is busy at ${label}.`);
|
||||
}
|
||||
if (snapshot.dom.url !== exactTargetUrl || snapshot.dom.readyState !== "complete" ||
|
||||
snapshot.dom.connectionText !== "DRY RUN" ||
|
||||
snapshot.dom.legacyDialogHidden !== true ||
|
||||
snapshot.dom.namedModalHidden !== true ||
|
||||
snapshot.dom.namedConfirmationHidden !== true) {
|
||||
failKnown(`The exact package document is not ready at ${label}.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(label, predicate, { allowBusy = false, allowMissingState = false } = {}) {
|
||||
if (allowMissingState && label !== "DryRun IDLE preflight") {
|
||||
failKnown("Only the input-free initial preflight may await the first native state.");
|
||||
}
|
||||
const deadline = Math.min(hardDeadline, Date.now() + 45_000);
|
||||
let last = null;
|
||||
while (Date.now() < deadline) {
|
||||
last = await readSnapshot();
|
||||
if (!last?.state?.playout && allowMissingState) {
|
||||
if (last?.safety?.wrapped !== true || last?.dom?.url !== exactTargetUrl ||
|
||||
last?.dom?.readyState !== "complete" || last?.dom?.connectionText !== "DRY RUN") {
|
||||
failUnknown("The exact DryRun document changed before the first state.");
|
||||
}
|
||||
updateSafety(last);
|
||||
if (evidence.safety.blockedOutboundMessages.length > 0 ||
|
||||
evidence.safety.stateViolations.length > 0) {
|
||||
failUnknown("The safety gate observed a violation before initial state.");
|
||||
}
|
||||
await sleep(100);
|
||||
continue;
|
||||
}
|
||||
assertSafety(last, label, allowBusy);
|
||||
if (predicate(last)) return last;
|
||||
await sleep(100);
|
||||
}
|
||||
failUnknown(`Timed out waiting for ${label}; no input was retried.`);
|
||||
}
|
||||
|
||||
function assertAppearanceSnapshot(snapshot, expected, label, requireNeutralLoad = false) {
|
||||
if (!appearanceEquals(snapshot.appearance, expected) ||
|
||||
!appearanceEquals(snapshot.dom.selects, expected) ||
|
||||
!appearanceEquals(snapshot.dom.htmlAppearance, expected) ||
|
||||
!appearanceEquals(snapshot.dom.bodyAppearance, expected) ||
|
||||
snapshot.state.operatorSettings?.restartRequired !== false) {
|
||||
failKnown(`The native and rendered appearance do not agree at ${label}.`);
|
||||
}
|
||||
if (requireNeutralLoad && new Set(["warning", "error"])
|
||||
.has(snapshot.state.operatorSettings?.messageKind)) {
|
||||
failKnown("The existing operator settings could not be loaded cleanly before input.");
|
||||
}
|
||||
}
|
||||
|
||||
async function geometryFor(control) {
|
||||
const value = await evaluate(`(() => {
|
||||
const element = ${control.expression};
|
||||
if (!element) return null;
|
||||
element.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
viewport: {
|
||||
width: document.documentElement.clientWidth,
|
||||
height: document.documentElement.clientHeight
|
||||
},
|
||||
devicePixelRatio: window.devicePixelRatio,
|
||||
disabled: element.disabled === true,
|
||||
hidden: element.hidden === true || element.closest("[hidden]") != null,
|
||||
optionValues: element instanceof HTMLSelectElement
|
||||
? [...element.options].map(option => option.value) : null
|
||||
};
|
||||
})()`);
|
||||
if (!value || value.disabled || value.hidden || value.width <= 0 || value.height <= 0 ||
|
||||
!Number.isFinite(value.x) || !Number.isFinite(value.y) ||
|
||||
value.x < 0 || value.y < 0 ||
|
||||
value.x >= value.viewport.width || value.y >= value.viewport.height ||
|
||||
value.devicePixelRatio < 0.25 || value.devicePixelRatio > 5) {
|
||||
failKnown(`#${control.id} is not a unique visible physical-input target.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exchangePath(sequence, suffix) {
|
||||
return path.join(configuration.exchangeDirectory,
|
||||
`${String(sequence).padStart(2, "0")}.${suffix}.json`);
|
||||
}
|
||||
|
||||
function readSmallJson(filePath, label) {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (!stat.isFile() || stat.size <= 0 || stat.size > 64 * 1024) {
|
||||
failKnown(`${label} is empty or too large.`);
|
||||
}
|
||||
const bytes = fs.readFileSync(filePath);
|
||||
let value;
|
||||
try { value = JSON.parse(bytes.toString("utf8")); }
|
||||
catch { failKnown(`${label} is not strict JSON.`); }
|
||||
return { value, bytes, sha256: sha256(bytes) };
|
||||
}
|
||||
|
||||
async function waitForTrustedInput(control, startIndex, targetValue) {
|
||||
const deadline = Math.min(hardDeadline, Date.now() + 10_000);
|
||||
while (Date.now() < deadline) {
|
||||
const events = await evaluate(`(() =>
|
||||
(globalThis.__legacyAppearanceProbe?.inputEvents || [])
|
||||
.slice(${Number(startIndex)}).map(value => ({ ...value })))()`);
|
||||
const relevant = events.filter(event => event.targetId === control.id);
|
||||
const down = relevant.find(event => event.type === "pointerdown");
|
||||
const up = relevant.find(event => event.type === "pointerup");
|
||||
if (down && up) {
|
||||
if (down.sequence >= up.sequence || down.isTrusted !== true || up.isTrusted !== true ||
|
||||
down.pointerType !== "mouse" || up.pointerType !== "mouse" ||
|
||||
down.button !== 0 || up.button !== 0 || down.buttons !== 1 || up.buttons !== 0) {
|
||||
failKnown(`#${control.id} did not receive one trusted mouse down/up pair.`);
|
||||
}
|
||||
if (targetValue === null) return { pointerDown: down, pointerUp: up, change: null };
|
||||
const change = relevant.find(event =>
|
||||
event.type === "change" && event.isTrusted === true && event.value === targetValue);
|
||||
const trustedKeyDowns = relevant.filter(event =>
|
||||
event.type === "keydown" && event.isTrusted === true);
|
||||
// A native HTML <select> popup consumes Home/ArrowDown/Enter before the
|
||||
// page can observe DOM keydown events. The wrapper's sealed one-shot ack
|
||||
// proves those physical key counts; the page proves the trusted change.
|
||||
if (change) {
|
||||
return {
|
||||
pointerDown: down,
|
||||
pointerUp: up,
|
||||
change,
|
||||
observedTrustedKeyDownCount: trustedKeyDowns.length,
|
||||
observedKeys: trustedKeyDowns.map(event => event.key)
|
||||
};
|
||||
}
|
||||
}
|
||||
await sleep(25);
|
||||
}
|
||||
failUnknown(`#${control.id} trusted input evidence did not appear; no input was retried.`);
|
||||
}
|
||||
|
||||
async function requestPhysicalInput({
|
||||
operation,
|
||||
control,
|
||||
appearanceKey = null,
|
||||
targetValue = null,
|
||||
expectedBefore,
|
||||
expectedAfter
|
||||
}) {
|
||||
const sequence = nextExchangeSequence++;
|
||||
const token = randomBytes(16).toString("hex").toUpperCase();
|
||||
const geometry = await geometryFor(control);
|
||||
let optionIndex = null;
|
||||
if (appearanceKey) {
|
||||
if (!Array.isArray(geometry.optionValues) ||
|
||||
JSON.stringify(geometry.optionValues) !==
|
||||
JSON.stringify(appearanceValues[appearanceKey])) {
|
||||
failKnown(`#${control.id} option order is outside the closed contract.`);
|
||||
}
|
||||
optionIndex = geometry.optionValues.indexOf(targetValue);
|
||||
if (optionIndex < 0) failKnown(`#${control.id} lacks the requested option.`);
|
||||
}
|
||||
const eventStart = await evaluate(
|
||||
"globalThis.__legacyAppearanceProbe?.inputEvents?.length ?? null");
|
||||
const outboundStart = await evaluate(
|
||||
"globalThis.__legacyAppearanceProbe?.outboundMessages?.length ?? null");
|
||||
if (!Number.isInteger(eventStart) || !Number.isInteger(outboundStart)) {
|
||||
failUnknown("The trusted input/outbound evidence baseline is unavailable.");
|
||||
}
|
||||
const request = {
|
||||
schemaVersion: 1,
|
||||
phase: configuration.phase,
|
||||
sequence,
|
||||
token,
|
||||
targetUrl: exactTargetUrl,
|
||||
operation,
|
||||
targetId: control.id,
|
||||
appearanceKey,
|
||||
targetValue,
|
||||
optionIndex,
|
||||
expectedBefore,
|
||||
expectedAfter,
|
||||
point: { x: geometry.x, y: geometry.y },
|
||||
viewport: geometry.viewport,
|
||||
devicePixelRatio: geometry.devicePixelRatio,
|
||||
mouseDownCalls: 1,
|
||||
mouseUpCalls: 1,
|
||||
homeKeyCalls: appearanceKey ? 1 : 0,
|
||||
arrowDownKeyCalls: appearanceKey ? optionIndex : 0,
|
||||
enterKeyCalls: appearanceKey ? 1 : 0,
|
||||
inputRetryCount: 0
|
||||
};
|
||||
const requestPath = exchangePath(sequence, "request");
|
||||
const acknowledgementPath = exchangePath(sequence, "ack");
|
||||
const requestBytes = Buffer.from(`${JSON.stringify(request)}\n`, "utf8");
|
||||
fs.writeFileSync(requestPath, requestBytes, { flag: "wx" });
|
||||
const deadline = Math.min(hardDeadline, Date.now() + 45_000);
|
||||
while (!fs.existsSync(acknowledgementPath)) {
|
||||
if (Date.now() >= deadline) {
|
||||
failUnknown(`Physical input exchange ${sequence} timed out; no input was retried.`);
|
||||
}
|
||||
await sleep(50);
|
||||
}
|
||||
const acknowledgement = readSmallJson(
|
||||
acknowledgementPath,
|
||||
`Physical input acknowledgement ${sequence}`);
|
||||
const ack = acknowledgement.value;
|
||||
if (!ack || ack.schemaVersion !== 1 || ack.phase !== configuration.phase ||
|
||||
ack.sequence !== sequence || ack.token !== token || ack.result !== "PASS" ||
|
||||
ack.operation !== operation || ack.targetId !== control.id ||
|
||||
ack.appearanceKey !== appearanceKey || ack.targetValue !== targetValue ||
|
||||
ack.optionIndex !== optionIndex || ack.packageIdentityValidated !== true ||
|
||||
ack.dryRunValidated !== true || ack.tornadoConnectionCount !== 0 ||
|
||||
ack.inputRetryCount !== 0 || ack.mouseDownCalls !== 1 || ack.mouseUpCalls !== 1 ||
|
||||
ack.homeKeyCalls !== request.homeKeyCalls ||
|
||||
ack.arrowDownKeyCalls !== request.arrowDownKeyCalls ||
|
||||
ack.enterKeyCalls !== request.enterKeyCalls ||
|
||||
!appearanceEquals(ack.settings?.appearance, expectedAfter)) {
|
||||
failKnown(`Physical input acknowledgement ${sequence} is outside its one-shot contract.`);
|
||||
}
|
||||
const physicalInput = await waitForTrustedInput(
|
||||
control,
|
||||
eventStart,
|
||||
targetValue);
|
||||
if (appearanceKey) {
|
||||
const current = await waitFor(`#${control.id} native save`, snapshot =>
|
||||
appearanceEquals(snapshot.appearance, expectedAfter) &&
|
||||
appearanceEquals(snapshot.dom.selects, expectedAfter) &&
|
||||
appearanceEquals(snapshot.dom.htmlAppearance, expectedAfter) &&
|
||||
appearanceEquals(snapshot.dom.bodyAppearance, expectedAfter) &&
|
||||
snapshot.state.isBusy !== true,
|
||||
{ allowBusy: true });
|
||||
const outbound = current.safety.outboundMessages.slice(outboundStart);
|
||||
if (outbound.length !== 1 || outbound[0].type !== "set-operator-appearance" ||
|
||||
!appearanceEquals(outbound[0].appearance, expectedAfter)) {
|
||||
failKnown(`#${control.id} did not issue exactly one closed appearance intent.`);
|
||||
}
|
||||
evidence.selections.push({
|
||||
appearanceKey,
|
||||
targetValue,
|
||||
expectedAfter,
|
||||
trustedChange: true,
|
||||
physicalKeyCounts: {
|
||||
home: ack.homeKeyCalls,
|
||||
arrowDown: ack.arrowDownKeyCalls,
|
||||
enter: ack.enterKeyCalls,
|
||||
total: ack.homeKeyCalls + ack.arrowDownKeyCalls + ack.enterKeyCalls
|
||||
},
|
||||
observedDomKeyDownCount: physicalInput.observedTrustedKeyDownCount,
|
||||
outboundIntentCount: 1,
|
||||
nativeRevision: current.state.operatorSettings.revision,
|
||||
restartRequired: current.state.operatorSettings.restartRequired
|
||||
});
|
||||
}
|
||||
evidence.exchanges.push({
|
||||
sequence,
|
||||
operation,
|
||||
targetId: control.id,
|
||||
appearanceKey,
|
||||
targetValue,
|
||||
optionIndex,
|
||||
requestPath,
|
||||
requestSha256: sha256(requestBytes),
|
||||
acknowledgementPath,
|
||||
acknowledgementSha256: acknowledgement.sha256,
|
||||
physicalKeyCounts: {
|
||||
home: ack.homeKeyCalls,
|
||||
arrowDown: ack.arrowDownKeyCalls,
|
||||
enter: ack.enterKeyCalls
|
||||
},
|
||||
physicalInput,
|
||||
settings: ack.settings
|
||||
});
|
||||
}
|
||||
|
||||
async function checkpoint(name, details = null) {
|
||||
const snapshot = await readSnapshot();
|
||||
assertSafety(snapshot, name, false);
|
||||
evidence.checkpoints.push({ name, details, snapshot });
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function captureScreenshot() {
|
||||
const response = await rpc("Page.captureScreenshot", {
|
||||
format: "png",
|
||||
fromSurface: true,
|
||||
captureBeyondViewport: false
|
||||
}, 20_000);
|
||||
if (!response?.data) failKnown("CDP did not return screenshot data.");
|
||||
const bytes = Buffer.from(response.data, "base64");
|
||||
fs.writeFileSync(configuration.screenshotPath, bytes, { flag: "wx" });
|
||||
evidence.screenshot = {
|
||||
path: configuration.screenshotPath,
|
||||
bytes: bytes.length,
|
||||
sha256: sha256(bytes)
|
||||
};
|
||||
}
|
||||
|
||||
async function runSequence() {
|
||||
await rpc("Runtime.enable");
|
||||
await rpc("Page.enable");
|
||||
await installProbe();
|
||||
let current = await waitFor("DryRun IDLE preflight", snapshot =>
|
||||
snapshot.state?.playout?.mode === "dryRun" &&
|
||||
snapshot.state.isBusy !== true &&
|
||||
snapshot.state.operatorSettings != null,
|
||||
{ allowMissingState: true });
|
||||
assertAppearanceSnapshot(
|
||||
current,
|
||||
configuration.expected,
|
||||
"input-free initial appearance",
|
||||
true);
|
||||
evidence.persistenceVerifiedBeforeInput = configuration.phase === "verify-restore";
|
||||
if (configuration.phase === "verify-restore") {
|
||||
const expectedStartupWorkspace = configuration.expected.startWorkspace === "lastWorkspace"
|
||||
? "settings"
|
||||
: "stock";
|
||||
current = await waitFor("persisted start workspace behavior", snapshot =>
|
||||
appearanceEquals(snapshot.appearance, configuration.expected) &&
|
||||
snapshot.dom.activeWorkspace === expectedStartupWorkspace,
|
||||
{ allowBusy: false });
|
||||
evidence.startWorkspaceBehaviorVerifiedBeforeInput = true;
|
||||
}
|
||||
evidence.checkpoints.push({
|
||||
name: configuration.phase === "verify-restore"
|
||||
? "persisted-appearance-after-relaunch"
|
||||
: "baseline-appearance-before-mutation",
|
||||
expectedStartupWorkspace: configuration.phase === "verify-restore"
|
||||
? (configuration.expected.startWorkspace === "lastWorkspace" ? "settings" : "stock")
|
||||
: null,
|
||||
snapshot: current
|
||||
});
|
||||
|
||||
await requestPhysicalInput({
|
||||
operation: "application-click",
|
||||
control: controls.settings,
|
||||
expectedBefore: configuration.expected,
|
||||
expectedAfter: configuration.expected
|
||||
});
|
||||
current = await waitFor("settings workspace", snapshot =>
|
||||
snapshot.dom.settingsVisible === true &&
|
||||
snapshot.dom.settingsCurrent === "page" &&
|
||||
snapshot.dom.activeWorkspace === "settings",
|
||||
{ allowBusy: false });
|
||||
assertAppearanceSnapshot(current, configuration.expected, "settings workspace preflight");
|
||||
|
||||
let before = configuration.expected;
|
||||
for (const appearanceKey of ["colorTheme", "viewMode", "startWorkspace"]) {
|
||||
const after = Object.freeze({
|
||||
...before,
|
||||
[appearanceKey]: configuration.final[appearanceKey]
|
||||
});
|
||||
await requestPhysicalInput({
|
||||
operation: "select-appearance-option",
|
||||
control: controls[appearanceKey],
|
||||
appearanceKey,
|
||||
targetValue: configuration.final[appearanceKey],
|
||||
expectedBefore: before,
|
||||
expectedAfter: after
|
||||
});
|
||||
before = after;
|
||||
}
|
||||
|
||||
current = await checkpoint(
|
||||
configuration.phase === "verify-restore"
|
||||
? "baseline-restored-by-physical-input"
|
||||
: "alternate-appearance-persisted",
|
||||
{ appearance: configuration.final });
|
||||
assertAppearanceSnapshot(current, configuration.final, "final appearance");
|
||||
if (evidence.exchanges.length !== 4 || evidence.selections.length !== 3 ||
|
||||
nextExchangeSequence !== 5 ||
|
||||
evidence.selections.some(selection => selection.trustedChange !== true ||
|
||||
selection.physicalKeyCounts.home !== 1 ||
|
||||
selection.physicalKeyCounts.enter !== 1 ||
|
||||
selection.physicalKeyCounts.total !==
|
||||
2 + selection.physicalKeyCounts.arrowDown ||
|
||||
selection.outboundIntentCount !== 1 || selection.restartRequired !== false)) {
|
||||
failKnown("The closed four-step appearance input plan did not complete exactly once.");
|
||||
}
|
||||
await captureScreenshot();
|
||||
evidence.finalSnapshot = current;
|
||||
evidence.result = "PASS";
|
||||
evidence.completedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
async function writeEvidence() {
|
||||
const bytes = Buffer.from(`${JSON.stringify(evidence, null, 2)}\n`, "utf8");
|
||||
fs.writeFileSync(configuration.outputPath, bytes, { flag: "wx" });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const target = await discoverTarget();
|
||||
evidence.target.id = target.id;
|
||||
evidence.target.url = target.url;
|
||||
await connect(validateEndpoint(target));
|
||||
await runSequence();
|
||||
} catch (error) {
|
||||
exitCode = 1;
|
||||
evidence.result = "FAIL";
|
||||
evidence.completedAt = new Date().toISOString();
|
||||
evidence.error = {
|
||||
category: error instanceof AppearanceFailure ? error.category : "OUTCOME_UNKNOWN",
|
||||
message: error instanceof AppearanceFailure
|
||||
? error.message
|
||||
: "OUTCOME_UNKNOWN: Unexpected appearance harness failure."
|
||||
};
|
||||
} finally {
|
||||
try {
|
||||
await removeProbe();
|
||||
} catch (cleanupError) {
|
||||
exitCode = 1;
|
||||
evidence.result = "FAIL";
|
||||
evidence.completedAt = new Date().toISOString();
|
||||
evidence.error = {
|
||||
category: "OUTCOME_UNKNOWN",
|
||||
message: cleanupError instanceof Error
|
||||
? cleanupError.message
|
||||
: "OUTCOME_UNKNOWN: Appearance probe cleanup failed."
|
||||
};
|
||||
}
|
||||
if (socket && socket.readyState === WebSocket.OPEN) socket.close();
|
||||
if (!fs.existsSync(configuration.outputPath)) {
|
||||
try { await writeEvidence(); }
|
||||
catch { exitCode = 1; }
|
||||
}
|
||||
}
|
||||
process.exitCode = exitCode;
|
||||
}
|
||||
|
||||
await main();
|
||||
Reference in New Issue
Block a user