feat: complete legacy parity and modernize operator UI
This commit is contained in:
870
scripts/Test-LegacyPackageLocalStateSmoke.mjs
Normal file
870
scripts/Test-LegacyPackageLocalStateSmoke.mjs
Normal file
@@ -0,0 +1,870 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user