871 lines
38 KiB
JavaScript
871 lines
38 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 expectedInitialDestinationSha256 =
|
|
"13C07BA64587549EDA9C1A694F3DFA19875CB1F888F02C9E071F17C47739E087";
|
|
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,
|
|
expectedInitialDestinationSha256,
|
|
expectedInitialDestinationRowCount: 1,
|
|
settingsBaseline: null,
|
|
destinationAfterFirstImport: null,
|
|
destinationAfterSecondImport: null
|
|
},
|
|
settings: {
|
|
pickerKinds: [],
|
|
allCancelled: false,
|
|
settingsFileUnchanged: false
|
|
},
|
|
comparison: {
|
|
initialPair: 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) {
|
|
if (files.source?.sha256 !== expectedSourceSha256 || files.source?.rowCount !== 8 ||
|
|
files.source?.isReadOnlySource !== true ||
|
|
files.destination?.sha256 !== expectedInitialDestinationSha256 ||
|
|
files.destination?.rowCount !== 1 ||
|
|
files.destination?.firstPair !== "KRX100지수|코스피 지수") {
|
|
failKnown("The original source or one-row current comparison baseline changed.");
|
|
}
|
|
evidence.files.settingsBaseline = files.settings;
|
|
}
|
|
|
|
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");
|
|
assertExactPreflightFiles(preflight.files);
|
|
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 one-row baseline", snapshot =>
|
|
snapshot.state.isBusy !== true && snapshot.dom.activeTabId === "comparison" &&
|
|
snapshot.state.comparison?.isBusy !== true &&
|
|
snapshot.state.comparison?.savedPairs?.length === 1 &&
|
|
snapshot.state.comparison?.canImportLegacyPairs === true,
|
|
{ allowBusy: false });
|
|
const initialPair = stablePair(comparison.state.comparison.savedPairs[0]);
|
|
if (typeof initialPair?.rowId !== "string" || initialPair.rowId.length === 0 ||
|
|
initialPair.rowNumber !== 1 ||
|
|
initialPair.displayLabel !== "KRX100지수 ↔ 코스피 지수") {
|
|
failKnown("The visible one-row comparison baseline is not the expected known pair.");
|
|
}
|
|
evidence.comparison.initialPair = initialPair;
|
|
|
|
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 === 8 && receipt?.skippedDuplicateCount === 0 &&
|
|
receipt?.totalPairCount === 9;
|
|
}, { allowBusy: true, allowDialog: true, timeoutMilliseconds: 90_000 });
|
|
const afterFirstPairs = comparison.state.comparison.savedPairs.map(stablePair);
|
|
if (JSON.stringify(afterFirstPairs[0]) !== JSON.stringify(initialPair)) {
|
|
failKnown("The original current comparison pair was not preserved at the front.");
|
|
}
|
|
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 === expectedInitialDestinationSha256) {
|
|
failKnown("The first comparison import file is not an appended 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;
|