Files
MBN_STOCK_WEBVIEW/scripts/Test-LegacyPackageLocalStateSmoke.mjs

894 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 expectedSourceSha256 =
"1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2";
const expectedFreshDestinationSha256 =
"13C07BA64587549EDA9C1A694F3DFA19875CB1F888F02C9E071F17C47739E087";
const expectedImportedDestinationSha256 =
"2575C2420800670B0022EBEE819BA3D9640DB14E9552426DF46075D079F1F697";
const expectedImportConfirmation =
"현재 저장된 비교쌍의 순서는 그대로 유지하고, 원본 프로그램의 비교쌍 중 없는 항목만 뒤에 추가할까요?";
const allowedOutboundTypes = new Set([
"ready",
"select-tab",
"choose-operator-folder",
"import-legacy-comparison-pairs",
"confirm-native-dialog"
]);
const forbiddenCdpMethods = new Set([
"Browser.close",
"Page.close",
"Runtime.terminateExecution",
"Target.closeTarget",
"Input.dispatchMouseEvent",
"Input.dispatchKeyEvent"
]);
const targetExpressions = Object.freeze({
"settings-navigation": 'document.getElementById("workspace-settings-tab")',
"design-folder-picker": 'document.getElementById("operator-design-folder-select")',
"resource-folder-picker": 'document.getElementById("operator-resource-folder-select")',
"background-folder-picker": 'document.getElementById("operator-background-folder-select")',
"comparison-navigation":
'document.querySelector("button[data-tab-id=\\"comparison\\"]")',
"comparison-import":
'document.querySelector("button[data-comparison-import=\\"true\\"]")',
"native-confirmation-yes": 'document.getElementById("legacy-dialog-ok")'
});
class SmokeFailure extends Error {
constructor(category, message) {
super(`${category}: ${message}`);
this.name = "SmokeFailure";
this.category = category;
}
}
function failKnown(message) {
throw new SmokeFailure("KNOWN_FAILURE", message);
}
function failUnknown(message) {
throw new SmokeFailure("OUTCOME_UNKNOWN", message);
}
function parseArguments(argv) {
const accepted = new Set([
"port", "output", "screenshot", "exchange-directory", "timeout-ms"
]);
if (argv.length === 0 || argv.length % 2 !== 0) {
failKnown(
"Usage: node scripts/Test-LegacyPackageLocalStateSmoke.mjs " +
"--port <port> --output <absolute.json> --screenshot <absolute.png> " +
"--exchange-directory <absolute-directory> [--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 ["port", "output", "screenshot", "exchange-directory"]) {
if (!values.has(required)) failKnown(`--${required} is required.`);
}
const port = Number(values.get("port"));
const timeoutMilliseconds = Number(values.get("timeout-ms") || "180000");
if (!Number.isInteger(port) || port < 1024 || port > 65535) {
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.");
}
const unique = new Set([
outputPath.toLowerCase(), screenshotPath.toLowerCase(), exchangeDirectory.toLowerCase()
]);
if (unique.size !== 3) failKnown("Evidence paths must be distinct.");
if (fs.existsSync(outputPath) || fs.existsSync(screenshotPath)) {
failKnown("Evidence files are never overwritten.");
}
if (!fs.existsSync(exchangeDirectory) ||
!fs.statSync(exchangeDirectory).isDirectory() ||
fs.readdirSync(exchangeDirectory).length !== 0) {
failKnown("The wrapper must provide one existing empty exchange directory.");
}
return { port, timeoutMilliseconds, outputPath, screenshotPath, exchangeDirectory };
}
const configuration = parseArguments(process.argv.slice(2));
const hardDeadline = Date.now() + configuration.timeoutMilliseconds;
const startedAt = new Date().toISOString();
let socket = null;
let nextRpcId = 1;
let nextInputSequence = 1;
let probeInstalled = false;
let cancellationRequested = false;
const pendingRpc = new Map();
const evidence = {
schemaVersion: 1,
profile: "local-settings-comparison-import",
result: "RUNNING",
startedAt,
completedAt: null,
target: {
expectedUrl: exactTargetUrl,
port: configuration.port,
id: null,
url: null
},
safety: {
requiredMode: "dryRun",
requiredPhase: "idle",
allowedOutboundTypes: [...allowedOutboundTypes],
observedOutboundTypes: [],
blockedOutboundMessages: [],
stateViolations: [],
inputRetryCount: 0,
appCloseRequested: false,
playoutIntentIssued: false
},
files: {
expectedSourceSha256,
expectedSourceRowCount: 8,
expectedFreshDestinationSha256,
expectedFreshDestinationRowCount: 1,
expectedImportedDestinationSha256,
expectedImportedDestinationRowCount: 9,
initialDestinationMode: null,
settingsBaseline: null,
destinationAfterFirstImport: null,
destinationAfterSecondImport: null
},
settings: {
pickerKinds: [],
allCancelled: false,
settingsFileUnchanged: false
},
comparison: {
initialPair: null,
initialPairCount: null,
afterFirstImport: null,
afterSecondImport: null,
initialPairPreserved: false,
secondImportIdempotent: false
},
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;
});
// The wrapper keeps stdin open so it can fail-stop an in-flight helper. Do not
// let that control channel keep Node alive after PASS or a sealed early failure.
if (typeof process.stdin.unref === "function") process.stdin.unref();
function assertDeadline(label) {
if (cancellationRequested) failUnknown(`The wrapper cancelled ${label}; no action is retried.`);
if (Date.now() >= hardDeadline) failUnknown(`The global deadline expired ${label}; no action is retried.`);
}
async function sleep(milliseconds) {
assertDeadline("before waiting");
await new Promise(resolve => setTimeout(resolve, Math.min(milliseconds, hardDeadline - Date.now())));
assertDeadline("after waiting");
}
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex").toUpperCase();
}
function stablePair(pair) {
if (!pair) return null;
return {
rowId: pair.rowId,
rowNumber: pair.rowNumber,
displayLabel: pair.displayLabel,
isSelected: pair.isSelected === true
};
}
async function discoverTarget() {
const discoveryUrl = `http://127.0.0.1:${configuration.port}/json/list`;
let lastError = null;
while (Date.now() < Math.min(hardDeadline, Date.now() + 15_000)) {
try {
const response = await fetch(discoveryUrl, { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
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 SmokeFailure) throw error;
lastError = String(error?.message || error);
}
await sleep(100);
}
failKnown(`The exact package target was not found${lastError ? ` (${lastError})` : ""}.`);
}
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 SmokeFailure(
"OUTCOME_UNKNOWN", "CDP socket open timed out.")), 5_000);
socket.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true });
socket.addEventListener("error", () => { clearTimeout(timer); reject(new SmokeFailure(
"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 SmokeFailure(
"KNOWN_FAILURE", `${pending.method} failed: ${message.error.message || "CDP error"}`));
else pending.resolve(message.result);
});
socket.addEventListener("close", () => {
for (const pending of pendingRpc.values()) {
clearTimeout(pending.timer);
pending.reject(new SmokeFailure(
"OUTCOME_UNKNOWN", `CDP closed while waiting for ${pending.method}.`));
}
pendingRpc.clear();
});
}
function rpc(method, params = {}, timeoutMilliseconds = 10_000) {
assertDeadline(`before ${method}`);
if (forbiddenCdpMethods.has(method)) {
if (method.includes("close") || method.includes("terminate")) {
evidence.safety.appCloseRequested = true;
}
failKnown(`Forbidden CDP method ${method} was refused.`);
}
if (!socket || socket.readyState !== WebSocket.OPEN) failUnknown("CDP socket is not open.");
const id = nextRpcId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingRpc.delete(id);
reject(new SmokeFailure("OUTCOME_UNKNOWN", `${method} timed out; no retry was attempted.`));
}, Math.min(timeoutMilliseconds, hardDeadline - Date.now()));
pendingRpc.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) {
failKnown(response.exceptionDetails.exception?.description ||
response.exceptionDetails.text || "Runtime evaluation failed.");
}
return response.result?.value;
}
async function installProbe() {
const token = `local-state-${Date.now()}-${randomBytes(8).toString("hex")}`;
const installed = await evaluate(`(() => {
if (!window.chrome?.webview) throw new Error("WebView2 bridge is unavailable.");
if (globalThis.__legacyLocalStateProbe) throw new Error("Probe already exists.");
const allowed = new Set(${JSON.stringify([...allowedOutboundTypes])});
const probe = {
token: ${JSON.stringify(token)}, states: [], outboundMessages: [], pointerEvents: [],
blockedOutboundMessages: [], stateViolations: [], originalPostMessage: null,
postMessageWrapper: null, listener: null, pointerListener: null
};
probe.listener = event => {
if (event.data?.type !== "state" || !event.data.payload) return;
const payload = event.data.payload;
probe.states.push(structuredClone(payload));
if (probe.states.length > 500) probe.states.shift();
const playout = payload.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");
if (playout.onAirCode != null) reasons.push("on-air");
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) probe.stateViolations.push({
at: new Date().toISOString(), revision: payload.revision ?? null, reasons
});
};
probe.originalPostMessage = window.chrome.webview.postMessage;
probe.pointerListener = event => {
const element = event.target instanceof Element ? event.target : null;
let targetId = null;
if (element?.closest("#workspace-settings-tab")) targetId = "settings-navigation";
else if (element?.closest("#operator-design-folder-select")) {
targetId = "design-folder-picker";
} else if (element?.closest("#operator-resource-folder-select")) {
targetId = "resource-folder-picker";
} else if (element?.closest("#operator-background-folder-select")) {
targetId = "background-folder-picker";
} else if (element?.closest('button[data-tab-id="comparison"]')) {
targetId = "comparison-navigation";
} else if (element?.closest('button[data-comparison-import="true"]')) {
targetId = "comparison-import";
} else if (element?.closest("#legacy-dialog-ok")) {
targetId = "native-confirmation-yes";
}
probe.pointerEvents.push({
sequence: probe.pointerEvents.length + 1,
type: event.type,
targetId,
isTrusted: event.isTrusted === true,
pointerType: event.pointerType,
button: event.button,
buttons: event.buttons,
at: new Date().toISOString()
});
if (probe.pointerEvents.length > 100) probe.pointerEvents.shift();
};
probe.postMessageWrapper = message => {
const record = {
sequence: probe.outboundMessages.length + 1,
type: typeof message?.type === "string" ? message.type : null,
at: new Date().toISOString()
};
probe.outboundMessages.push(record);
if (!record.type || !allowed.has(record.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.__legacyLocalStateProbe = probe;
window.chrome.webview.addEventListener("message", probe.listener);
document.addEventListener("pointerdown", probe.pointerListener, true);
document.addEventListener("pointerup", probe.pointerListener, true);
window.chrome.webview.postMessage({ type: "ready", payload: {} });
} catch (error) {
window.chrome.webview.postMessage = probe.originalPostMessage;
delete globalThis.__legacyLocalStateProbe;
throw error;
}
return probe.token;
})()`);
if (installed !== token) failKnown("The 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.__legacyLocalStateProbe;
if (!probe) return false;
window.chrome.webview.removeEventListener("message", probe.listener);
document.removeEventListener("pointerdown", probe.pointerListener, true);
document.removeEventListener("pointerup", probe.pointerListener, 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.__legacyLocalStateProbe;
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.__legacyLocalStateProbe;
const state = probe?.states?.at(-1) || null;
const active = document.activeElement;
return {
state: state ? structuredClone(state) : null,
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]
}))
},
dom: {
url: location.href,
readyState: document.readyState,
bodyBusy: document.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"),
activeTabId: state?.tabs?.find(tab => tab.isActive === true)?.id || null,
legacyDialogHidden: document.getElementById("legacy-dialog")?.hidden === true,
legacyDialogText: (document.getElementById("legacy-dialog-message")?.textContent || "").trim(),
legacyDialogOkText: (document.getElementById("legacy-dialog-ok")?.textContent || "").trim(),
importStatus: (document.querySelector(".comparison-import-status")?.textContent || "").trim(),
activeElementId: active?.id || null
}
};
})()`);
}
function assertSafety(snapshot, label, { allowBusy = false, allowDialog = false } = {}) {
if (!snapshot?.state?.playout) failKnown(`Native state is missing at ${label}.`);
const playout = snapshot.state.playout;
if (snapshot.safety.wrapped !== true) failUnknown(`The safety gate is absent at ${label}.`);
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 = snapshot.safety.outboundMessages.some(row =>
["prepare-playout", "take-in", "next-playout", "take-out",
"choose-background", "toggle-background"].includes(row.type));
if (snapshot.safety.blockedOutboundMessages.length) {
failKnown(`An unapproved outbound message was blocked at ${label}.`);
}
if (snapshot.safety.stateViolations.length) failUnknown(`DryRun left its safe state at ${label}.`);
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 UI is busy at ${label}.`);
if (!allowDialog && snapshot.dom.legacyDialogHidden !== true) {
failKnown(`An unexpected native dialog is visible at ${label}.`);
}
if (snapshot.dom.url !== exactTargetUrl || snapshot.dom.readyState !== "complete" ||
snapshot.dom.connectionText !== "DRY RUN") {
failKnown(`The exact DryRun document is not ready at ${label}.`);
}
}
async function waitFor(label, predicate, options = {}) {
const allowMissingState = options.allowMissingState === true;
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() + (options.timeoutMilliseconds || 45_000));
let last = null;
while (Date.now() < deadline) {
last = await readSnapshot();
if (!last?.state?.playout && allowMissingState) {
if (last?.safety?.wrapped !== true) {
failUnknown("The safety gate is absent while awaiting the first native state.");
}
evidence.safety.observedOutboundTypes =
last.safety.outboundMessages.map(row => row.type);
evidence.safety.blockedOutboundMessages = last.safety.blockedOutboundMessages;
evidence.safety.stateViolations = last.safety.stateViolations;
if (last.safety.blockedOutboundMessages.length) {
failKnown("An unapproved outbound message was blocked before initial state.");
}
if (last.safety.stateViolations.length) {
failUnknown("A native state violation was observed before initial state.");
}
if (last.dom.url !== exactTargetUrl || last.dom.readyState !== "complete" ||
last.dom.connectionText !== "DRY RUN") {
failKnown("The exact DryRun document changed while awaiting initial state.");
}
await sleep(100);
continue;
}
assertSafety(last, label, options);
if (predicate(last)) return last;
await sleep(100);
}
failUnknown(`Timed out waiting for ${label}; no input was retried.`);
}
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 { bytes, value, sha256: sha256(bytes) };
}
async function elementGeometry(targetId) {
const expression = targetExpressions[targetId];
if (!expression) failKnown(`Closed target ${targetId} is unavailable.`);
const value = await evaluate(`(() => {
const element = ${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,
text: (element.textContent || "").trim(),
ariaCurrent: element.getAttribute("aria-current")
};
})()`);
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(`${targetId} is not a unique visible input target.`);
}
return value;
}
async function requestExchange(operation, targetId = null, folderKind = null) {
const sequence = nextInputSequence++;
const token = randomBytes(16).toString("hex").toUpperCase();
const geometry = targetId ? await elementGeometry(targetId) : null;
const pointerEventStart = operation === "observe-local-files" ? null :
await evaluate("globalThis.__legacyLocalStateProbe?.pointerEvents?.length ?? null");
if (operation !== "observe-local-files" && !Number.isInteger(pointerEventStart)) {
failUnknown("The trusted pointer evidence baseline is unavailable.");
}
const request = {
schemaVersion: 1,
token,
sequence,
targetUrl: exactTargetUrl,
operation,
targetId,
folderKind,
point: geometry ? { x: geometry.x, y: geometry.y } : null,
viewport: geometry ? geometry.viewport : null,
devicePixelRatio: geometry ? geometry.devicePixelRatio : null,
mouseDownCalls: operation === "observe-local-files" ? 0 : 1,
mouseUpCalls: operation === "observe-local-files" ? 0 : 1,
escapeKeyCalls: operation === "folder-picker-click-cancel" ? 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(`Input exchange ${sequence} timed out; no retry was attempted.`);
await sleep(50);
}
const acknowledgement = readSmallJson(acknowledgementPath,
`Input acknowledgement ${sequence}`);
const ack = acknowledgement.value;
if (!ack || ack.schemaVersion !== 1 || ack.token !== token || ack.sequence !== sequence ||
ack.result !== "PASS" || ack.operation !== operation || ack.targetId !== targetId ||
ack.folderKind !== folderKind || ack.packageIdentityValidated !== true ||
ack.dryRunValidated !== true || ack.tornadoConnectionCount !== 0 ||
ack.inputRetryCount !== 0 || ack.mouseDownCalls !== request.mouseDownCalls ||
ack.mouseUpCalls !== request.mouseUpCalls || ack.escapeKeyCalls !== request.escapeKeyCalls ||
!ack.files) {
failKnown(`Input acknowledgement ${sequence} does not match its one-shot request.`);
}
const physicalPointer = operation === "observe-local-files"
? null : await waitForTrustedPointerClick(targetId, pointerEventStart);
evidence.exchanges.push({
sequence, operation, targetId, folderKind,
requestPath, requestSha256: sha256(requestBytes),
acknowledgementPath, acknowledgementSha256: acknowledgement.sha256,
picker: ack.picker || null,
physicalPointer,
files: ack.files
});
return ack;
}
async function waitForTrustedPointerClick(targetId, startIndex) {
const deadline = Math.min(hardDeadline, Date.now() + 10_000);
while (Date.now() < deadline) {
const events = await evaluate(`(() => {
const events = globalThis.__legacyLocalStateProbe?.pointerEvents || [];
return events.slice(${Number(startIndex)}).map(value => ({ ...value }));
})()`);
const relevant = events.filter(event => event.targetId === targetId);
if (relevant.length >= 2) {
const down = relevant.find(event => event.type === "pointerdown");
const up = relevant.find(event => event.type === "pointerup");
if (!down || !up || 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(`The ${targetId} click did not produce one trusted mouse down/up pair.`);
}
return { down, up };
}
await sleep(25);
}
failUnknown(`Trusted pointer evidence for ${targetId} did not appear; no click was retried.`);
}
function assertExactPreflightFiles(files) {
const isFresh = files.destination?.sha256 === expectedFreshDestinationSha256 &&
files.destination?.rowCount === 1;
const isAlreadyImported =
files.destination?.sha256 === expectedImportedDestinationSha256 &&
files.destination?.rowCount === 9;
if (files.source?.sha256 !== expectedSourceSha256 || files.source?.rowCount !== 8 ||
files.source?.isReadOnlySource !== true ||
(!isFresh && !isAlreadyImported) ||
files.destination?.firstPair !== "KRX100지수|코스피 지수") {
failKnown("The original source or known current comparison baseline changed.");
}
evidence.files.settingsBaseline = files.settings;
evidence.files.initialDestinationMode = isFresh ? "fresh" : "alreadyImported";
return evidence.files.initialDestinationMode;
}
function assertSettingsUnchanged(files, label) {
if (JSON.stringify(files.settings) !== JSON.stringify(evidence.files.settingsBaseline)) {
failKnown(`The settings JSON changed ${label}.`);
}
}
async function runSequence() {
await rpc("Runtime.enable");
await rpc("Page.enable");
await installProbe();
const initial = await waitFor("DryRun IDLE preflight", snapshot =>
snapshot.state?.playout?.mode === "dryRun" && snapshot.state.isBusy !== true,
{ allowBusy: false, allowMissingState: true });
const preflight = await requestExchange("observe-local-files");
const baselineMode = assertExactPreflightFiles(preflight.files);
const expectedInitialPairCount = baselineMode === "fresh" ? 1 : 9;
const expectedFirstAdded = baselineMode === "fresh" ? 8 : 0;
const expectedFirstSkipped = baselineMode === "fresh" ? 0 : 8;
evidence.checkpoints.push({ name: "exact-file-preflight", files: preflight.files });
await requestExchange("application-click", "settings-navigation");
await waitFor("settings workspace", snapshot =>
snapshot.dom.settingsVisible === true && snapshot.dom.settingsCurrent === "page",
{ allowBusy: false });
for (const [targetId, folderKind] of [
["design-folder-picker", "design"],
["resource-folder-picker", "resource"],
["background-folder-picker", "background"]
]) {
const ack = await requestExchange("folder-picker-click-cancel", targetId, folderKind);
if (!ack.picker || ack.picker.uniqueNewPicker !== true ||
ack.picker.ownerLinkedToApplication !== true ||
ack.picker.escapeCancelled !== true || ack.picker.closedAfterEscape !== true) {
failKnown(`The ${folderKind} folder picker was not proven as the app-owned cancelled picker.`);
}
assertSettingsUnchanged(ack.files, `after cancelling ${folderKind}`);
evidence.settings.pickerKinds.push(folderKind);
await waitFor(`${folderKind} picker cancellation return`, snapshot =>
snapshot.state.isBusy !== true && snapshot.dom.settingsVisible === true &&
snapshot.dom.legacyDialogHidden === true,
{ allowBusy: false });
}
const settingsObservation = await requestExchange("observe-local-files");
assertSettingsUnchanged(settingsObservation.files, "after all three picker cancellations");
evidence.settings.allCancelled = true;
evidence.settings.settingsFileUnchanged = true;
await requestExchange("application-click", "comparison-navigation");
let comparison = await waitFor(`comparison ${baselineMode} baseline`, snapshot =>
snapshot.state.isBusy !== true && snapshot.dom.activeTabId === "comparison" &&
snapshot.state.comparison?.isBusy !== true &&
snapshot.state.comparison?.savedPairs?.length === expectedInitialPairCount &&
snapshot.state.comparison?.canImportLegacyPairs === true,
{ allowBusy: true });
const initialPairs = comparison.state.comparison.savedPairs.map(stablePair);
const initialPair = initialPairs[0];
if (typeof initialPair?.rowId !== "string" || initialPair.rowId.length === 0 ||
initialPair.rowNumber !== 1 ||
initialPair.displayLabel !== "KRX100지수 ↔ 코스피 지수") {
failKnown("The visible comparison baseline does not start with the expected known pair.");
}
evidence.comparison.initialPair = initialPair;
if (initialPairs.some((pair, index) => typeof pair?.rowId !== "string" ||
pair.rowId.length === 0 || pair.rowNumber !== index + 1)) {
failKnown("The visible comparison baseline does not retain sequential rows.");
}
evidence.comparison.initialPairCount = initialPairs.length;
await requestExchange("application-click", "comparison-import");
await waitFor("first import confirmation", snapshot =>
snapshot.state.dialog?.isConfirmation === true &&
snapshot.state.dialog?.message === expectedImportConfirmation &&
snapshot.dom.legacyDialogHidden === false &&
snapshot.dom.legacyDialogText === expectedImportConfirmation &&
snapshot.dom.legacyDialogOkText === "예",
{ allowBusy: false, allowDialog: true });
await requestExchange("application-click", "native-confirmation-yes");
comparison = await waitFor("first import completion", snapshot => {
const model = snapshot.state.comparison;
const receipt = model?.lastImport;
return snapshot.state.isBusy !== true && model?.isBusy !== true &&
snapshot.dom.legacyDialogHidden === true && model?.savedPairs?.length === 9 &&
receipt?.sourceSha256 === expectedSourceSha256 && receipt?.sourceRowCount === 8 &&
receipt?.addedPairCount === expectedFirstAdded &&
receipt?.skippedDuplicateCount === expectedFirstSkipped &&
receipt?.totalPairCount === 9;
}, { allowBusy: true, allowDialog: true, timeoutMilliseconds: 90_000 });
const afterFirstPairs = comparison.state.comparison.savedPairs.map(stablePair);
if (JSON.stringify(afterFirstPairs.slice(0, initialPairs.length)) !==
JSON.stringify(initialPairs)) {
failKnown("The existing ordered comparison pairs were not preserved.");
}
evidence.comparison.initialPairPreserved = true;
evidence.comparison.afterFirstImport = {
receipt: comparison.state.comparison.lastImport,
pairCount: afterFirstPairs.length,
pairs: afterFirstPairs
};
const afterFirstFiles = await requestExchange("observe-local-files");
assertSettingsUnchanged(afterFirstFiles.files, "after the first comparison import");
if (afterFirstFiles.files.destination?.rowCount !== 9 ||
afterFirstFiles.files.destination?.firstPair !== "KRX100지수|코스피 지수" ||
afterFirstFiles.files.destination?.sha256 !== expectedImportedDestinationSha256) {
failKnown("The first comparison import did not retain the known nine-row result.");
}
evidence.files.destinationAfterFirstImport = afterFirstFiles.files.destination;
await requestExchange("application-click", "comparison-import");
await waitFor("second import confirmation", snapshot =>
snapshot.state.dialog?.isConfirmation === true &&
snapshot.state.dialog?.message === expectedImportConfirmation &&
snapshot.dom.legacyDialogHidden === false,
{ allowBusy: false, allowDialog: true });
await requestExchange("application-click", "native-confirmation-yes");
comparison = await waitFor("second import idempotent completion", snapshot => {
const model = snapshot.state.comparison;
const receipt = model?.lastImport;
return snapshot.state.isBusy !== true && model?.isBusy !== true &&
snapshot.dom.legacyDialogHidden === true && model?.savedPairs?.length === 9 &&
receipt?.sourceSha256 === expectedSourceSha256 && receipt?.sourceRowCount === 8 &&
receipt?.addedPairCount === 0 && receipt?.skippedDuplicateCount === 8 &&
receipt?.totalPairCount === 9;
}, { allowBusy: true, allowDialog: true, timeoutMilliseconds: 90_000 });
const afterSecondPairs = comparison.state.comparison.savedPairs.map(stablePair);
if (JSON.stringify(afterSecondPairs) !== JSON.stringify(afterFirstPairs)) {
failKnown("The second import changed the ordered comparison pair list.");
}
const finalFiles = await requestExchange("observe-local-files");
assertSettingsUnchanged(finalFiles.files, "after the second comparison import");
if (JSON.stringify(finalFiles.files.destination) !==
JSON.stringify(afterFirstFiles.files.destination)) {
failKnown("The second import changed the comparison file hash or contents.");
}
if (finalFiles.files.source?.sha256 !== expectedSourceSha256 ||
finalFiles.files.source?.rowCount !== 8) {
failUnknown("The read-only original source changed during the smoke.");
}
evidence.files.destinationAfterSecondImport = finalFiles.files.destination;
evidence.comparison.afterSecondImport = {
receipt: comparison.state.comparison.lastImport,
pairCount: afterSecondPairs.length,
pairs: afterSecondPairs
};
evidence.comparison.secondImportIdempotent = true;
const finalSnapshot = await readSnapshot();
assertSafety(finalSnapshot, "final state", { allowBusy: false });
evidence.finalSnapshot = finalSnapshot;
await captureScreenshot();
await removeProbe();
if (nextInputSequence !== 14 || evidence.exchanges.length !== 13) {
failUnknown("The closed thirteen-step exchange sequence did not complete exactly once.");
}
if (evidence.safety.observedOutboundTypes.filter(type =>
type === "import-legacy-comparison-pairs").length !== 2 ||
evidence.safety.observedOutboundTypes.filter(type =>
type === "confirm-native-dialog").length !== 2 ||
evidence.safety.observedOutboundTypes.filter(type =>
type === "choose-operator-folder").length !== 3) {
failKnown("The two import confirmations did not emit exactly two request/Yes pairs.");
}
evidence.result = "PASS";
}
async function captureScreenshot() {
const response = await rpc("Page.captureScreenshot", {
format: "png", fromSurface: true, captureBeyondViewport: false
}, 20_000);
if (!response?.data) failKnown("CDP returned no screenshot bytes.");
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)
};
}
function errorProjection(error) {
return {
category: error instanceof SmokeFailure ? error.category : "HARNESS_ERROR",
name: error?.name || "Error",
message: String(error?.message || error)
};
}
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 = error instanceof SmokeFailure && error.category === "OUTCOME_UNKNOWN"
? "OUTCOME_UNKNOWN" : "FAIL";
evidence.error = errorProjection(error);
try {
if (!evidence.screenshot && socket?.readyState === WebSocket.OPEN) await captureScreenshot();
} catch { /* Preserve the first failure. */ }
try {
if (probeInstalled && socket?.readyState === WebSocket.OPEN) await removeProbe();
} catch { /* Preserve the first failure and leave state observable. */ }
} finally {
evidence.completedAt = new Date().toISOString();
try {
const bytes = Buffer.from(`${JSON.stringify(evidence, null, 2)}\n`, "utf8");
fs.writeFileSync(configuration.outputPath, bytes, { flag: "wx" });
} catch {
exitCode = 1;
}
if (socket?.readyState === WebSocket.OPEN) socket.close();
}
process.exitCode = exitCode;