355 lines
16 KiB
JavaScript
355 lines
16 KiB
JavaScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { createHash } from "node:crypto";
|
|
|
|
const exactUrl = "https://legacy-parity.mbn.local/index.html";
|
|
const allowedArguments = new Set(["port", "output", "screenshot", "timeout-ms"]);
|
|
const forbiddenCdpMethods = new Set([
|
|
"Browser.close", "Page.close", "Target.closeTarget", "Runtime.terminateExecution",
|
|
"Input.dispatchMouseEvent", "Input.dispatchKeyEvent", "Input.insertText"
|
|
]);
|
|
|
|
class SmokeFailure extends Error {
|
|
constructor(kind, message) {
|
|
super(`${kind}: ${message}`);
|
|
this.name = "SmokeFailure";
|
|
this.kind = kind;
|
|
}
|
|
}
|
|
|
|
function fail(kind, message) { throw new SmokeFailure(kind, message); }
|
|
|
|
function parseArguments(argv) {
|
|
if (argv.length === 0 || argv.length % 2 !== 0) {
|
|
fail("KNOWN_FAILURE", "Use --port, --output, --screenshot, and optional --timeout-ms pairs.");
|
|
}
|
|
const values = new Map();
|
|
for (let index = 0; index < argv.length; index += 2) {
|
|
const key = argv[index]?.replace(/^--/u, "");
|
|
const value = argv[index + 1];
|
|
if (!key || !argv[index].startsWith("--") || !value || !allowedArguments.has(key) || values.has(key)) {
|
|
fail("KNOWN_FAILURE", "Arguments must be unique supported --name value pairs.");
|
|
}
|
|
values.set(key, value);
|
|
}
|
|
for (const key of ["port", "output", "screenshot"]) {
|
|
if (!values.has(key)) fail("KNOWN_FAILURE", `Missing --${key}.`);
|
|
}
|
|
const port = Number(values.get("port"));
|
|
if (!Number.isInteger(port) || port < 1024 || port > 65535 || port === 30001) {
|
|
fail("KNOWN_FAILURE", "--port must be a non-Tornado port from 1024 through 65535.");
|
|
}
|
|
const outputPath = path.resolve(values.get("output"));
|
|
const screenshotPath = path.resolve(values.get("screenshot"));
|
|
if (!path.isAbsolute(values.get("output")) || path.extname(outputPath).toLowerCase() !== ".json" ||
|
|
!path.isAbsolute(values.get("screenshot")) || path.extname(screenshotPath).toLowerCase() !== ".png" ||
|
|
outputPath.toLowerCase() === screenshotPath.toLowerCase()) {
|
|
fail("KNOWN_FAILURE", "Output must be a distinct absolute .json and screenshot a distinct absolute .png.");
|
|
}
|
|
const timeoutMilliseconds = values.has("timeout-ms") ? Number(values.get("timeout-ms")) : 30000;
|
|
if (!Number.isInteger(timeoutMilliseconds) || timeoutMilliseconds < 5000 || timeoutMilliseconds > 120000) {
|
|
fail("KNOWN_FAILURE", "--timeout-ms must be from 5000 through 120000.");
|
|
}
|
|
if (fs.existsSync(outputPath) || fs.existsSync(screenshotPath)) {
|
|
fail("KNOWN_FAILURE", "Evidence files must not already exist.");
|
|
}
|
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
fs.mkdirSync(path.dirname(screenshotPath), { recursive: true });
|
|
return { port, outputPath, screenshotPath, timeoutMilliseconds };
|
|
}
|
|
|
|
const configuration = parseArguments(process.argv.slice(2));
|
|
const deadline = Date.now() + configuration.timeoutMilliseconds;
|
|
const evidence = {
|
|
schemaVersion: 1,
|
|
result: "RUNNING",
|
|
startedAt: new Date().toISOString(),
|
|
completedAt: null,
|
|
target: { expectedUrl: exactUrl, port: configuration.port, id: null, url: null },
|
|
safety: {
|
|
noInputDispatched: true,
|
|
noNativeIntentAfterGuard: true,
|
|
// app.js emits this empty, read-only bootstrap before CDP can attach.
|
|
// Seeing native-rendered DRY RUN / IDLE below proves that its state reply arrived;
|
|
// this harness does not falsely claim to have observed the earlier outbound call.
|
|
bootstrapReadyContract: {
|
|
type: "ready",
|
|
payload: {},
|
|
inferredFromAuthoritativeState: true,
|
|
nativeRoute: "LegacyReadyIntent => _controller.Current"
|
|
},
|
|
blockedMessages: [],
|
|
observedMessagesAfterGuard: []
|
|
},
|
|
shell: null,
|
|
screenshot: null,
|
|
error: null
|
|
};
|
|
|
|
let socket;
|
|
let nextId = 1;
|
|
const pending = new Map();
|
|
let guardInstalled = false;
|
|
|
|
function remaining() {
|
|
const value = deadline - Date.now();
|
|
if (value <= 0) fail("OUTCOME_UNKNOWN", "Observation deadline expired; nothing was retried.");
|
|
return value;
|
|
}
|
|
|
|
function sleep(milliseconds) {
|
|
return new Promise(resolve => setTimeout(resolve, Math.min(milliseconds, remaining())));
|
|
}
|
|
|
|
async function discoverTarget() {
|
|
const discoveryUrl = `http://127.0.0.1:${configuration.port}/json/list`;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const response = await fetch(discoveryUrl, { redirect: "error", signal: AbortSignal.timeout(1000) });
|
|
const targets = await response.json();
|
|
const matches = Array.isArray(targets) ? targets.filter(target =>
|
|
target?.type === "page" && target.url === exactUrl) : [];
|
|
if (matches.length > 1) fail("KNOWN_FAILURE", "More than one exact package document was exposed.");
|
|
if (matches.length === 1 && matches[0].id && matches[0].webSocketDebuggerUrl) return matches[0];
|
|
} catch (error) {
|
|
if (error instanceof SmokeFailure) throw error;
|
|
}
|
|
await sleep(100);
|
|
}
|
|
fail("KNOWN_FAILURE", "The exact package WebView target was not found.");
|
|
}
|
|
|
|
async function connect(target) {
|
|
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}`) {
|
|
fail("KNOWN_FAILURE", "The target CDP endpoint is not the exact loopback page endpoint.");
|
|
}
|
|
socket = new WebSocket(endpoint.href);
|
|
await new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new SmokeFailure("OUTCOME_UNKNOWN", "CDP open timed out.")), 5000);
|
|
socket.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true });
|
|
socket.addEventListener("error", () => { clearTimeout(timer); reject(new SmokeFailure("OUTCOME_UNKNOWN", "CDP open failed.")); }, { once: true });
|
|
});
|
|
socket.addEventListener("message", event => {
|
|
const message = JSON.parse(String(event.data));
|
|
if (!Object.hasOwn(message, "id")) return;
|
|
const request = pending.get(message.id);
|
|
if (!request) return;
|
|
pending.delete(message.id);
|
|
clearTimeout(request.timer);
|
|
message.error ? request.reject(new SmokeFailure("KNOWN_FAILURE", `${request.method}: ${message.error.message || "CDP error"}`)) : request.resolve(message.result);
|
|
});
|
|
}
|
|
|
|
function rpc(method, params = {}) {
|
|
if (forbiddenCdpMethods.has(method)) fail("KNOWN_FAILURE", `Forbidden CDP method ${method}.`);
|
|
if (!socket || socket.readyState !== WebSocket.OPEN) fail("OUTCOME_UNKNOWN", "CDP is unavailable.");
|
|
const id = nextId++;
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
pending.delete(id);
|
|
reject(new SmokeFailure("OUTCOME_UNKNOWN", `${method} timed out; nothing was retried.`));
|
|
}, Math.min(10000, remaining()));
|
|
pending.set(id, { method, resolve, reject, timer });
|
|
socket.send(JSON.stringify({ id, method, params }));
|
|
});
|
|
}
|
|
|
|
async function evaluate(expression) {
|
|
const response = await rpc("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true, userGesture: false });
|
|
if (response.exceptionDetails) fail("KNOWN_FAILURE", response.exceptionDetails.text || "Runtime evaluation failed.");
|
|
return response.result?.value;
|
|
}
|
|
|
|
async function installNoIntentGuard() {
|
|
const installed = await evaluate(`(() => {
|
|
const bridge = window.chrome?.webview;
|
|
if (!bridge || globalThis.__legacyUiOnlySmoke) return false;
|
|
const probe = { original: bridge.postMessage, blocked: [], observed: [] };
|
|
probe.wrapper = message => {
|
|
const record = { type: typeof message?.type === "string" ? message.type : null, at: new Date().toISOString() };
|
|
probe.observed.push(record);
|
|
probe.blocked.push(record);
|
|
return undefined;
|
|
};
|
|
bridge.postMessage = probe.wrapper;
|
|
if (bridge.postMessage !== probe.wrapper) throw new Error("postMessage guard identity changed");
|
|
globalThis.__legacyUiOnlySmoke = probe;
|
|
return true;
|
|
})()`);
|
|
if (installed !== true) fail("KNOWN_FAILURE", "The no-intent WebView guard could not be installed exactly once.");
|
|
guardInstalled = true;
|
|
}
|
|
|
|
async function readShell() {
|
|
return evaluate(`(() => {
|
|
const probe = globalThis.__legacyUiOnlySmoke;
|
|
const text = id => document.getElementById(id)?.textContent?.trim() || null;
|
|
const button = id => {
|
|
const element = document.getElementById(id);
|
|
return element ? { present: true, disabled: element.disabled === true, text: element.textContent.trim() } : { present: false };
|
|
};
|
|
const rectangle = element => {
|
|
if (!element) return null;
|
|
const bounds = element.getBoundingClientRect();
|
|
const style = getComputedStyle(element);
|
|
return {
|
|
left: bounds.left,
|
|
top: bounds.top,
|
|
right: bounds.right,
|
|
bottom: bounds.bottom,
|
|
width: bounds.width,
|
|
height: bounds.height,
|
|
display: style.display,
|
|
visibility: style.visibility
|
|
};
|
|
};
|
|
const isVisible = (element, bounds) => Boolean(element && bounds &&
|
|
element.hidden !== true && !element.closest("[hidden]") &&
|
|
bounds.width > 0 && bounds.height > 0 &&
|
|
bounds.right > 0 && bounds.bottom > 0 &&
|
|
bounds.left < document.documentElement.clientWidth &&
|
|
bounds.top < document.documentElement.clientHeight &&
|
|
bounds.display !== "none" && bounds.visibility !== "hidden");
|
|
const workspaceRegion = document.getElementById("workspace-region");
|
|
const navigationToggle = document.getElementById("workspace-nav-toggle");
|
|
const stockTab = document.getElementById("workspace-stock-tab");
|
|
const marketTabs = Array.from(document.querySelectorAll("#market-tabs > button[data-tab-id]"));
|
|
const settingsTab = document.getElementById("workspace-settings-tab");
|
|
const stockWorkspace = document.getElementById("stock-workspace");
|
|
const catalogWorkspace = document.getElementById("catalog-workspace");
|
|
const settingsWorkspace = document.getElementById("settings-workspace");
|
|
const schedule = document.querySelector(".playlist-panel");
|
|
const workspaceBounds = rectangle(workspaceRegion);
|
|
const scheduleBounds = rectangle(schedule);
|
|
const scheduleCenter = scheduleBounds ? {
|
|
x: scheduleBounds.left + scheduleBounds.width / 2,
|
|
y: scheduleBounds.top + scheduleBounds.height / 2
|
|
} : null;
|
|
const scheduleHit = scheduleCenter &&
|
|
scheduleCenter.x >= 0 && scheduleCenter.y >= 0 &&
|
|
scheduleCenter.x < document.documentElement.clientWidth &&
|
|
scheduleCenter.y < document.documentElement.clientHeight
|
|
? document.elementFromPoint(scheduleCenter.x, scheduleCenter.y)
|
|
: null;
|
|
return {
|
|
url: location.href,
|
|
readyState: document.readyState,
|
|
bodyBusy: document.body?.getAttribute("aria-busy") || null,
|
|
connectionText: text("playout-connection-state"),
|
|
playoutStatus: text("playout-status"),
|
|
title: document.title,
|
|
controls: {
|
|
search: button("stock-search-button"),
|
|
prepare: button("playout-prepare"),
|
|
takeIn: button("playout-take-in"),
|
|
next: button("playout-next"),
|
|
takeOut: button("playout-take-out")
|
|
},
|
|
layout: {
|
|
activeWorkspace: workspaceRegion?.dataset?.activeWorkspace || null,
|
|
navigationExpanded: navigationToggle?.getAttribute("aria-expanded") === "true" &&
|
|
workspaceRegion?.classList.contains("nav-collapsed") !== true,
|
|
stockTabCurrent: stockTab?.getAttribute("aria-current") || null,
|
|
marketTabCount: marketTabs.length,
|
|
marketTabsCurrent: marketTabs.map(button => ({
|
|
tabId: button.dataset.tabId,
|
|
current: button.getAttribute("aria-current") || null,
|
|
hasIcon: Boolean(button.querySelector("svg")),
|
|
hasLabel: Boolean(button.querySelector(".workspace-nav-label"))
|
|
})),
|
|
settingsTabCurrent: settingsTab?.getAttribute("aria-current") || null,
|
|
currentMenuItemCount: document.querySelectorAll(
|
|
"#workspace-navigation .workspace-nav-item[aria-current='page']").length,
|
|
stockWorkspaceVisible: isVisible(stockWorkspace, rectangle(stockWorkspace)) &&
|
|
stockWorkspace?.getAttribute("aria-hidden") === "false" &&
|
|
stockWorkspace?.hasAttribute("inert") !== true,
|
|
catalogWorkspaceHidden: catalogWorkspace?.hidden === true &&
|
|
catalogWorkspace?.getAttribute("aria-hidden") === "true" &&
|
|
catalogWorkspace?.hasAttribute("inert") === true,
|
|
settingsWorkspaceHidden: settingsWorkspace?.hidden === true &&
|
|
settingsWorkspace?.getAttribute("aria-hidden") === "true" &&
|
|
settingsWorkspace?.hasAttribute("inert") === true,
|
|
workspace: workspaceBounds,
|
|
schedule: scheduleBounds,
|
|
workspaceToScheduleWidthRatio: workspaceBounds && scheduleBounds &&
|
|
scheduleBounds.width > 0
|
|
? workspaceBounds.width / scheduleBounds.width
|
|
: null,
|
|
scheduleVisible: isVisible(schedule, scheduleBounds),
|
|
scheduleHitTestVisible: Boolean(scheduleHit &&
|
|
(scheduleHit === schedule || schedule?.contains(scheduleHit)))
|
|
},
|
|
visibleDialogs: Array.from(document.querySelectorAll("[role='dialog'], [role='alertdialog']"))
|
|
.filter(element => !element.hidden && !element.closest("[hidden]")).map(element => element.id || element.getAttribute("aria-label") || "dialog"),
|
|
postMessageGuardInstalled: window.chrome?.webview?.postMessage === probe?.wrapper,
|
|
observedMessagesAfterGuard: probe?.observed || [],
|
|
blockedMessages: probe?.blocked || []
|
|
};
|
|
})()`);
|
|
}
|
|
|
|
function assertShell(shell) {
|
|
if (!shell || shell.url !== exactUrl || shell.readyState !== "complete" || shell.bodyBusy !== "false" ||
|
|
shell.connectionText !== "DRY RUN" || !shell.playoutStatus?.startsWith("IDLE") ||
|
|
shell.visibleDialogs.length !== 0 ||
|
|
shell.postMessageGuardInstalled !== true || shell.observedMessagesAfterGuard.length !== 0 || shell.blockedMessages.length !== 0) {
|
|
fail("KNOWN_FAILURE", "The package shell did not remain rendered DryRun/IDLE with zero guarded native intents.");
|
|
}
|
|
const layout = shell.layout;
|
|
if (!layout || layout.activeWorkspace !== "stock" ||
|
|
layout.navigationExpanded !== true ||
|
|
layout.stockTabCurrent !== "page" || layout.settingsTabCurrent !== "false" ||
|
|
layout.marketTabCount !== 10 ||
|
|
layout.marketTabsCurrent.some(tab => tab.current !== "false" ||
|
|
tab.hasIcon !== true || tab.hasLabel !== true) ||
|
|
layout.currentMenuItemCount !== 1 ||
|
|
layout.stockWorkspaceVisible !== true || layout.catalogWorkspaceHidden !== true ||
|
|
layout.settingsWorkspaceHidden !== true) {
|
|
fail("KNOWN_FAILURE", "The package did not render the expanded navigation with stock as the sole initial workspace.");
|
|
}
|
|
if (layout.scheduleVisible !== true || layout.scheduleHitTestVisible !== true ||
|
|
!Number.isFinite(layout.workspaceToScheduleWidthRatio) ||
|
|
layout.workspaceToScheduleWidthRatio < 1.8 ||
|
|
layout.workspaceToScheduleWidthRatio > 2.2) {
|
|
fail("KNOWN_FAILURE", "The package did not render a visible right schedule beside the approximately 2:1 workspace layout.");
|
|
}
|
|
for (const [name, control] of Object.entries(shell.controls || {})) {
|
|
if (!control?.present) fail("KNOWN_FAILURE", `Required shell control ${name} is missing.`);
|
|
}
|
|
}
|
|
|
|
async function captureScreenshot() {
|
|
const response = await rpc("Page.captureScreenshot", { format: "png", fromSurface: true, captureBeyondViewport: false });
|
|
if (typeof response.data !== "string" || response.data.length === 0) fail("KNOWN_FAILURE", "CDP returned no screenshot bytes.");
|
|
const bytes = Buffer.from(response.data, "base64");
|
|
fs.writeFileSync(configuration.screenshotPath, bytes, { flag: "wx" });
|
|
return { path: configuration.screenshotPath, bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex").toUpperCase() };
|
|
}
|
|
|
|
try {
|
|
const target = await discoverTarget();
|
|
evidence.target.id = target.id;
|
|
evidence.target.url = target.url;
|
|
await connect(target);
|
|
await rpc("Runtime.enable");
|
|
await rpc("Page.enable");
|
|
await installNoIntentGuard();
|
|
await sleep(500);
|
|
evidence.shell = await readShell();
|
|
assertShell(evidence.shell);
|
|
evidence.safety.observedMessagesAfterGuard = evidence.shell.observedMessagesAfterGuard;
|
|
evidence.safety.blockedMessages = evidence.shell.blockedMessages;
|
|
evidence.screenshot = await captureScreenshot();
|
|
evidence.result = "PASS";
|
|
} catch (error) {
|
|
evidence.result = "FAIL";
|
|
evidence.error = { kind: error instanceof SmokeFailure ? error.kind : "HARNESS_ERROR", message: String(error?.message || error) };
|
|
process.exitCode = 1;
|
|
} finally {
|
|
evidence.completedAt = new Date().toISOString();
|
|
if (socket?.readyState === WebSocket.OPEN) socket.close();
|
|
fs.writeFileSync(configuration.outputPath, `${JSON.stringify(evidence, null, 2)}\n`, { flag: "wx" });
|
|
}
|