2037 lines
78 KiB
JavaScript
2037 lines
78 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 ctrlModifier = 2;
|
|
const shiftModifier = 8;
|
|
const defaultObservationTimeoutMilliseconds = 45_000;
|
|
const minimumObservationTimeoutMilliseconds = 5_000;
|
|
const maximumObservationTimeoutMilliseconds = 300_000;
|
|
const acceptedArgumentNames = new Set([
|
|
"port",
|
|
"output",
|
|
"screenshot",
|
|
"windows-input-request",
|
|
"windows-input-ack",
|
|
"timeout-ms"
|
|
]);
|
|
const forbiddenControlIds = Object.freeze([
|
|
"playout-prepare",
|
|
"playout-take-in",
|
|
"playout-next",
|
|
"playout-take-out",
|
|
"named-playlist-create-button",
|
|
"named-playlist-delete-button",
|
|
"named-playlist-confirmation-yes",
|
|
"operator-catalog-save",
|
|
"operator-catalog-delete"
|
|
]);
|
|
const allowedOutboundMessageTypes = Object.freeze([
|
|
"ready",
|
|
"search-stocks",
|
|
"select-stock",
|
|
"cut-pointer-down",
|
|
"cut-key-down",
|
|
"drop-selected-cuts",
|
|
"activate-playlist-row",
|
|
"select-playlist-row",
|
|
"reorder-playlist-rows",
|
|
"select-playlist-boundary",
|
|
"refresh-named-playlists"
|
|
]);
|
|
const playoutIntentMessageTypes = Object.freeze([
|
|
"prepare-playout",
|
|
"take-in",
|
|
"next-playout",
|
|
"take-out",
|
|
"choose-background",
|
|
"toggle-background"
|
|
]);
|
|
const databaseWriteIntentMessageTypes = Object.freeze([
|
|
"create-named-playlist",
|
|
"delete-selected-named-playlist",
|
|
"save-current-named-playlist-to",
|
|
"save-manual-financial",
|
|
"delete-manual-financial",
|
|
"delete-all-manual-financial",
|
|
"import-manual-lists",
|
|
"confirm-manual-list",
|
|
"save-operator-catalog",
|
|
"delete-operator-catalog",
|
|
"delete-uc4-selected-theme",
|
|
"delete-uc6-selected-expert"
|
|
]);
|
|
const forbiddenCdpMethods = new Set([
|
|
"Browser.close",
|
|
"Page.close",
|
|
"Runtime.terminateExecution",
|
|
"Target.closeTarget"
|
|
]);
|
|
|
|
class HarnessFailure extends Error {
|
|
constructor(category, message) {
|
|
super(`${category}: ${message}`);
|
|
this.name = "HarnessFailure";
|
|
this.category = category;
|
|
}
|
|
}
|
|
|
|
function failKnown(message) {
|
|
throw new HarnessFailure("KNOWN_FAILURE", message);
|
|
}
|
|
|
|
function failUnknown(message) {
|
|
throw new HarnessFailure("OUTCOME_UNKNOWN", message);
|
|
}
|
|
|
|
function parseArguments(argv) {
|
|
if (argv.length === 0 || argv.length % 2 !== 0) {
|
|
failKnown(
|
|
"Usage: node scripts/Test-LegacyPackageInput.mjs --port <port> " +
|
|
"--output <absolute-result.json> --screenshot <absolute-screenshot.png> " +
|
|
"--windows-input-request <absolute-request.json> " +
|
|
"--windows-input-ack <absolute-ack.json> " +
|
|
"[--timeout-ms <milliseconds>]");
|
|
}
|
|
|
|
const values = new Map();
|
|
for (let index = 0; index < argv.length; index += 2) {
|
|
const name = argv[index];
|
|
const value = argv[index + 1];
|
|
if (!name?.startsWith("--") || value === undefined || value.length === 0) {
|
|
failKnown("Arguments must be non-empty --name value pairs.");
|
|
}
|
|
|
|
const key = name.slice(2);
|
|
if (!acceptedArgumentNames.has(key)) {
|
|
failKnown(`Unknown argument --${key}.`);
|
|
}
|
|
if (values.has(key)) {
|
|
failKnown(`Argument --${key} was supplied more than once.`);
|
|
}
|
|
values.set(key, value);
|
|
}
|
|
|
|
if (!values.has("port") || !values.has("output") ||
|
|
!values.has("screenshot") || !values.has("windows-input-request") ||
|
|
!values.has("windows-input-ack")) {
|
|
failKnown(
|
|
"--port, --output, --screenshot, --windows-input-request, and " +
|
|
"--windows-input-ack are required.");
|
|
}
|
|
|
|
const port = Number(values.get("port"));
|
|
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
failKnown("--port must be an integer from 1 through 65535.");
|
|
}
|
|
|
|
const outputPath = path.resolve(values.get("output"));
|
|
if (!path.isAbsolute(values.get("output")) ||
|
|
path.extname(outputPath).toLowerCase() !== ".json") {
|
|
failKnown("--output must be an absolute .json path.");
|
|
}
|
|
|
|
const screenshotPath = path.resolve(values.get("screenshot"));
|
|
if (!path.isAbsolute(values.get("screenshot")) ||
|
|
path.extname(screenshotPath).toLowerCase() !== ".png") {
|
|
failKnown("--screenshot must be an absolute .png path.");
|
|
}
|
|
if (outputPath.toLowerCase() === screenshotPath.toLowerCase()) {
|
|
failKnown("--output and --screenshot must name different files.");
|
|
}
|
|
|
|
const windowsInputRequestPath = path.resolve(values.get("windows-input-request"));
|
|
const windowsInputAckPath = path.resolve(values.get("windows-input-ack"));
|
|
for (const [name, candidate] of [
|
|
["--windows-input-request", windowsInputRequestPath],
|
|
["--windows-input-ack", windowsInputAckPath]
|
|
]) {
|
|
if (!path.isAbsolute(values.get(name.slice(2))) ||
|
|
path.extname(candidate).toLowerCase() !== ".json") {
|
|
failKnown(`${name} must be an absolute .json path.`);
|
|
}
|
|
}
|
|
const uniqueEvidencePaths = new Set([
|
|
outputPath.toLowerCase(),
|
|
screenshotPath.toLowerCase(),
|
|
windowsInputRequestPath.toLowerCase(),
|
|
windowsInputAckPath.toLowerCase()
|
|
]);
|
|
if (uniqueEvidencePaths.size !== 4) {
|
|
failKnown("All result, screenshot, Windows-input request, and ack paths must differ.");
|
|
}
|
|
|
|
const timeoutMilliseconds = values.has("timeout-ms")
|
|
? Number(values.get("timeout-ms"))
|
|
: defaultObservationTimeoutMilliseconds;
|
|
if (!Number.isInteger(timeoutMilliseconds) ||
|
|
timeoutMilliseconds < minimumObservationTimeoutMilliseconds ||
|
|
timeoutMilliseconds > maximumObservationTimeoutMilliseconds) {
|
|
failKnown(
|
|
`--timeout-ms must be an integer from ${minimumObservationTimeoutMilliseconds} ` +
|
|
`through ${maximumObservationTimeoutMilliseconds}.`);
|
|
}
|
|
|
|
if (fs.existsSync(outputPath) || fs.existsSync(screenshotPath) ||
|
|
fs.existsSync(windowsInputRequestPath) || fs.existsSync(windowsInputAckPath)) {
|
|
failKnown("An evidence path already exists; evidence is never overwritten.");
|
|
}
|
|
|
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
fs.mkdirSync(path.dirname(screenshotPath), { recursive: true });
|
|
fs.mkdirSync(path.dirname(windowsInputRequestPath), { recursive: true });
|
|
fs.mkdirSync(path.dirname(windowsInputAckPath), { recursive: true });
|
|
return {
|
|
port,
|
|
outputPath,
|
|
screenshotPath,
|
|
windowsInputRequestPath,
|
|
windowsInputAckPath,
|
|
timeoutMilliseconds
|
|
};
|
|
}
|
|
|
|
function assertWithinHardDeadline(label) {
|
|
if (externalCancellationRequested && !cleanupMode) {
|
|
failUnknown(`The wrapper requested cancellation ${label}; no action is retried.`);
|
|
}
|
|
const deadline = cleanupMode
|
|
? cleanupDeadlineEpochMilliseconds
|
|
: hardDeadlineEpochMilliseconds;
|
|
if (Date.now() >= deadline) {
|
|
failUnknown(`The global input deadline expired ${label}; no action is retried.`);
|
|
}
|
|
}
|
|
|
|
async function sleep(milliseconds) {
|
|
assertWithinHardDeadline("before waiting");
|
|
const activeDeadline = cleanupMode
|
|
? cleanupDeadlineEpochMilliseconds
|
|
: hardDeadlineEpochMilliseconds;
|
|
const remaining = activeDeadline - Date.now();
|
|
await new Promise(resolve => setTimeout(resolve, Math.min(milliseconds, remaining)));
|
|
assertWithinHardDeadline("after waiting");
|
|
}
|
|
|
|
function sha256(bytes) {
|
|
return createHash("sha256").update(bytes).digest("hex").toUpperCase();
|
|
}
|
|
|
|
function errorProjection(error) {
|
|
return {
|
|
category: error instanceof HarnessFailure ? error.category : "HARNESS_ERROR",
|
|
name: error?.name || "Error",
|
|
message: String(error?.message || error)
|
|
};
|
|
}
|
|
|
|
const configuration = parseArguments(process.argv.slice(2));
|
|
const hardDeadlineEpochMilliseconds = Date.now() + configuration.timeoutMilliseconds;
|
|
const startedAt = new Date().toISOString();
|
|
const evidence = {
|
|
schemaVersion: 1,
|
|
result: "RUNNING",
|
|
startedAt,
|
|
completedAt: null,
|
|
target: {
|
|
expectedUrl: exactTargetUrl,
|
|
port: configuration.port,
|
|
timeoutMilliseconds: configuration.timeoutMilliseconds,
|
|
id: null,
|
|
url: null
|
|
},
|
|
safety: {
|
|
requiredMode: "dryRun",
|
|
requiredPhase: "idle",
|
|
forbiddenControlIds,
|
|
allowedOutboundMessageTypes,
|
|
appCloseRequested: false,
|
|
playoutIntentIssued: false,
|
|
databaseWriteIntentIssued: false,
|
|
observedOutboundMessageTypes: [],
|
|
blockedOutboundMessages: [],
|
|
stateViolations: [],
|
|
externalCancellationRequested: false
|
|
},
|
|
fixture: {
|
|
query: "삼성",
|
|
stockDisplayName: "삼성출판사"
|
|
},
|
|
checkpoints: [],
|
|
inputs: [],
|
|
warnings: [],
|
|
windowsInput: {
|
|
required: true,
|
|
requestPath: configuration.windowsInputRequestPath,
|
|
acknowledgementPath: configuration.windowsInputAckPath,
|
|
requestSha256: null,
|
|
acknowledgementSha256: null,
|
|
result: "PENDING"
|
|
},
|
|
screenshot: null,
|
|
finalSnapshot: null,
|
|
error: null
|
|
};
|
|
|
|
let socket = null;
|
|
let nextRequestId = 1;
|
|
const pendingRequests = new Map();
|
|
let externalCancellationRequested = false;
|
|
let cancellationInputBuffer = "";
|
|
let pointerIsPressed = false;
|
|
let pointerReleasePoint = null;
|
|
let pointerReleaseModifiers = 0;
|
|
let probeInstalled = false;
|
|
let cleanupMode = false;
|
|
let cleanupDeadlineEpochMilliseconds = 0;
|
|
|
|
process.stdin.setEncoding("utf8");
|
|
process.stdin.on("data", chunk => {
|
|
cancellationInputBuffer += chunk;
|
|
if (!cancellationInputBuffer.split(/\r?\n/u).some(line => line === "CANCEL")) {
|
|
if (cancellationInputBuffer.length > 64) cancellationInputBuffer = "";
|
|
return;
|
|
}
|
|
externalCancellationRequested = true;
|
|
evidence.safety.externalCancellationRequested = true;
|
|
for (const pending of pendingRequests.values()) {
|
|
clearTimeout(pending.timer);
|
|
pending.reject(new HarnessFailure(
|
|
"OUTCOME_UNKNOWN",
|
|
`Wrapper cancellation interrupted ${pending.method}; the command is not retried.`));
|
|
}
|
|
pendingRequests.clear();
|
|
});
|
|
if (typeof process.stdin.unref === "function") process.stdin.unref();
|
|
|
|
async function discoverExactTarget() {
|
|
const deadline = Date.now() + Math.min(
|
|
configuration.timeoutMilliseconds,
|
|
30_000);
|
|
const discoveryUrl = `http://127.0.0.1:${configuration.port}/json/list`;
|
|
let lastFailure = null;
|
|
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const response = await fetch(discoveryUrl, {
|
|
redirect: "error",
|
|
signal: AbortSignal.timeout(1_000)
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
const targets = await response.json();
|
|
if (!Array.isArray(targets)) {
|
|
failKnown("CDP discovery did not return a target array.");
|
|
}
|
|
|
|
const exactTargets = targets.filter(target =>
|
|
target?.type === "page" && target.url === exactTargetUrl);
|
|
if (exactTargets.length > 1) {
|
|
failKnown("More than one exact LegacyParityApp WebView target exists.");
|
|
}
|
|
if (exactTargets.length === 1) {
|
|
return exactTargets[0];
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof HarnessFailure) throw error;
|
|
lastFailure = String(error?.message || error);
|
|
}
|
|
await sleep(100);
|
|
}
|
|
|
|
failKnown(`The exact package WebView target was not found at ${discoveryUrl}` +
|
|
(lastFailure ? ` (${lastFailure}).` : "."));
|
|
}
|
|
|
|
function validateWebSocketTarget(target) {
|
|
if (!target?.id || !target.webSocketDebuggerUrl) {
|
|
failKnown("The exact target does not expose a WebSocket 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 CDP endpoint is not the exact loopback page endpoint.");
|
|
}
|
|
return endpoint;
|
|
}
|
|
|
|
async function connect(endpoint) {
|
|
if (typeof WebSocket !== "function") {
|
|
failKnown("This Node runtime does not provide the WebSocket API.");
|
|
}
|
|
|
|
socket = new WebSocket(endpoint.href);
|
|
await new Promise((resolve, reject) => {
|
|
const timer = setTimeout(
|
|
() => reject(new HarnessFailure("OUTCOME_UNKNOWN", "CDP socket open timed out.")),
|
|
5_000);
|
|
socket.addEventListener("open", () => {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
}, { once: true });
|
|
socket.addEventListener("error", () => {
|
|
clearTimeout(timer);
|
|
reject(new HarnessFailure("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 = pendingRequests.get(message.id);
|
|
if (!pending) return;
|
|
pendingRequests.delete(message.id);
|
|
clearTimeout(pending.timer);
|
|
if (message.error) {
|
|
pending.reject(new HarnessFailure(
|
|
"KNOWN_FAILURE",
|
|
`${pending.method} failed: ${message.error.message || "CDP error"}`));
|
|
} else {
|
|
pending.resolve(message.result);
|
|
}
|
|
});
|
|
|
|
socket.addEventListener("close", () => {
|
|
for (const pending of pendingRequests.values()) {
|
|
clearTimeout(pending.timer);
|
|
pending.reject(new HarnessFailure(
|
|
"OUTCOME_UNKNOWN",
|
|
`CDP socket closed while waiting for ${pending.method}.`));
|
|
}
|
|
pendingRequests.clear();
|
|
});
|
|
}
|
|
|
|
function rpc(method, params = {}, timeoutMilliseconds = 10_000) {
|
|
assertWithinHardDeadline(`before ${method}`);
|
|
if (cleanupMode && method !== "Input.dispatchMouseEvent" &&
|
|
method !== "Runtime.evaluate") {
|
|
failKnown(`Cleanup refused unexpected CDP method ${method}.`);
|
|
}
|
|
if (forbiddenCdpMethods.has(method)) {
|
|
evidence.safety.appCloseRequested = true;
|
|
failKnown(`The harness refused forbidden CDP method ${method}.`);
|
|
}
|
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
failUnknown(`CDP socket is not open for ${method}.`);
|
|
}
|
|
|
|
const id = nextRequestId++;
|
|
return new Promise((resolve, reject) => {
|
|
const activeDeadline = cleanupMode
|
|
? cleanupDeadlineEpochMilliseconds
|
|
: hardDeadlineEpochMilliseconds;
|
|
const remaining = activeDeadline - Date.now();
|
|
const effectiveTimeout = cleanupMode
|
|
? Math.min(timeoutMilliseconds, 2_000, remaining)
|
|
: Math.min(timeoutMilliseconds, remaining);
|
|
const timer = setTimeout(() => {
|
|
pendingRequests.delete(id);
|
|
reject(new HarnessFailure(
|
|
"OUTCOME_UNKNOWN",
|
|
`${method} timed out; the command is not retried.`));
|
|
}, effectiveTimeout);
|
|
pendingRequests.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) {
|
|
const description = response.exceptionDetails.exception?.description ||
|
|
response.exceptionDetails.text || "Runtime evaluation failed.";
|
|
failKnown(description);
|
|
}
|
|
return response.result?.value;
|
|
}
|
|
|
|
function probeSnapshotExpression() {
|
|
return `(() => {
|
|
const probe = globalThis.__legacyPackageInputProbe;
|
|
const state = probe?.states?.at(-1) || null;
|
|
const pointerEvents = probe?.pointerEvents || [];
|
|
const lastPointerDown = [...pointerEvents].reverse().find(event =>
|
|
event.type === "pointerdown") || null;
|
|
const activePointerId = Number.isInteger(lastPointerDown?.pointerId)
|
|
? lastPointerDown.pointerId
|
|
: null;
|
|
const capturedCutPhysicalIndices = activePointerId === null
|
|
? []
|
|
: Array.from(document.querySelectorAll("#cut-list .cut-row"))
|
|
.filter(row => typeof row.hasPointerCapture === "function" &&
|
|
row.hasPointerCapture(activePointerId))
|
|
.map(row => Number(row.dataset.physicalIndex));
|
|
const capturedPlaylistRowIds = activePointerId === null
|
|
? []
|
|
: Array.from(document.querySelectorAll(
|
|
"#playlist-rows .playlist-data-row .playlist-row-header"))
|
|
.filter(header => typeof header.hasPointerCapture === "function" &&
|
|
header.hasPointerCapture(activePointerId))
|
|
.map(header => header.closest(".playlist-data-row")?.dataset?.rowId || null)
|
|
.filter(Boolean);
|
|
const active = document.activeElement;
|
|
const rowForActive = active?.closest?.(".playlist-data-row") || null;
|
|
const visibleModal = Array.from(document.querySelectorAll(
|
|
"[role='dialog'], [role='alertdialog']"))
|
|
.filter(element => !element.hidden && !element.closest("[hidden]")).at(-1) || null;
|
|
const focusables = visibleModal ? Array.from(visibleModal.querySelectorAll(
|
|
"button:not([disabled]), input:not([disabled]):not([type='hidden']), " +
|
|
"select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])"))
|
|
.filter(element => !element.hidden && !element.closest("[hidden]") &&
|
|
getComputedStyle(element).display !== "none" &&
|
|
getComputedStyle(element).visibility !== "hidden") : [];
|
|
return {
|
|
capturedAt: new Date().toISOString(),
|
|
devicePixelRatio: window.devicePixelRatio,
|
|
stateCount: probe?.stateCount ?? null,
|
|
safety: {
|
|
postMessageWrapped: probe?.postMessageWrapper != null &&
|
|
window.chrome?.webview?.postMessage === probe.postMessageWrapper,
|
|
outboundMessages: (probe?.outboundMessages || []).map(message => ({ ...message })),
|
|
blockedOutboundMessages: (probe?.blockedOutboundMessages || [])
|
|
.map(message => ({ ...message })),
|
|
stateViolations: (probe?.stateViolations || []).map(violation => ({
|
|
...violation,
|
|
reasons: [...violation.reasons]
|
|
}))
|
|
},
|
|
pointerEvents: pointerEvents.slice(-40).map(event => ({ ...event })),
|
|
state: state ? {
|
|
revision: state.revision,
|
|
isBusy: state.isBusy,
|
|
searchText: state.searchText,
|
|
searchResults: (state.searchResults || []).map(row => ({
|
|
index: row.index,
|
|
displayName: row.displayName
|
|
})),
|
|
selectedStockIndex: state.selectedStockIndex,
|
|
cutRows: (state.cutRows || []).map(row => ({
|
|
physicalIndex: row.physicalIndex,
|
|
displayOrdinal: row.displayOrdinal,
|
|
rawLabel: row.rawLabel,
|
|
isSeparator: row.isSeparator,
|
|
isSelected: row.isSelected,
|
|
isFocused: row.isFocused
|
|
})),
|
|
playlist: (state.playlist || []).map(row => ({
|
|
rowId: row.rowId,
|
|
isEnabled: row.isEnabled,
|
|
isSelected: row.isSelected,
|
|
isActive: row.isActive,
|
|
marketText: row.marketText,
|
|
stockName: row.stockName,
|
|
graphicType: row.graphicType,
|
|
subtype: row.subtype,
|
|
pageText: row.pageText
|
|
})),
|
|
dialog: state.dialog,
|
|
statusMessage: state.statusMessage,
|
|
statusKind: state.statusKind,
|
|
commandResult: state.commandResult,
|
|
namedPlaylist: state.namedPlaylist ? {
|
|
revision: state.namedPlaylist.revision,
|
|
definitions: (state.namedPlaylist.definitions || []).map(definition => ({
|
|
definitionId: definition.definitionId,
|
|
title: definition.title,
|
|
isSelected: definition.isSelected
|
|
})),
|
|
selectedDefinitionId: state.namedPlaylist.selectedDefinitionId,
|
|
selectedTitle: state.namedPlaylist.selectedTitle,
|
|
rowCount: state.namedPlaylist.rowCount,
|
|
isWriteInProgress: state.namedPlaylist.isWriteInProgress,
|
|
isWriteQuarantined: state.namedPlaylist.isWriteQuarantined,
|
|
canMutate: state.namedPlaylist.canMutate,
|
|
canCreate: state.namedPlaylist.canCreate,
|
|
canLoad: state.namedPlaylist.canLoad,
|
|
canSave: state.namedPlaylist.canSave,
|
|
canDelete: state.namedPlaylist.canDelete,
|
|
lastMutationOutcome: state.namedPlaylist.lastMutationOutcome,
|
|
statusMessage: state.namedPlaylist.statusMessage,
|
|
statusKind: state.namedPlaylist.statusKind
|
|
} : null,
|
|
interactionMetrics: state.interactionMetrics,
|
|
playout: state.playout ? {
|
|
mode: state.playout.mode,
|
|
connectionState: state.playout.connectionState,
|
|
phase: state.playout.phase,
|
|
isConnected: state.playout.isConnected,
|
|
isCommandAvailable: state.playout.isCommandAvailable,
|
|
liveTakeInAllowed: state.playout.liveTakeInAllowed,
|
|
isPlayCompletionPending: state.playout.isPlayCompletionPending,
|
|
isTakeOutCompletionPending: state.playout.isTakeOutCompletionPending,
|
|
outcomeUnknown: state.playout.outcomeUnknown,
|
|
preparedCode: state.playout.preparedCode,
|
|
onAirCode: state.playout.onAirCode,
|
|
currentEntryId: state.playout.currentEntryId,
|
|
isBusy: state.playout.isBusy,
|
|
refreshActive: state.playout.refreshActive,
|
|
message: state.playout.message
|
|
} : null
|
|
} : null,
|
|
dom: {
|
|
readyState: document.readyState,
|
|
url: location.href,
|
|
viewport: {
|
|
width: document.documentElement.clientWidth,
|
|
height: document.documentElement.clientHeight
|
|
},
|
|
bodyBusy: document.body.getAttribute("aria-busy"),
|
|
connectionText: (document.getElementById("playout-connection-state")?.textContent || "").trim(),
|
|
playoutStatusText: (document.getElementById("playout-status")?.textContent || "").trim(),
|
|
operatorStatusText: (document.getElementById("operator-status")?.textContent || "").trim(),
|
|
legacyDialogHidden: document.getElementById("legacy-dialog")?.hidden === true,
|
|
legacyDialogText: (document.getElementById("legacy-dialog-message")?.textContent || "").trim(),
|
|
stockOptions: Array.from(document.querySelectorAll(
|
|
"#stock-results button[data-result-index]"))
|
|
.map(button => ({
|
|
index: Number(button.dataset.resultIndex),
|
|
text: (button.textContent || "").trim(),
|
|
selected: button.getAttribute("aria-selected") === "true",
|
|
tabIndex: button.tabIndex,
|
|
focused: button === active
|
|
})),
|
|
cuts: Array.from(document.querySelectorAll("#cut-list .cut-row"))
|
|
.map(row => ({
|
|
physicalIndex: Number(row.dataset.physicalIndex),
|
|
selected: row.getAttribute("aria-selected") === "true",
|
|
tabIndex: row.tabIndex,
|
|
focused: row === active,
|
|
dragging: row.classList.contains("dragging")
|
|
})),
|
|
playlist: Array.from(document.querySelectorAll(
|
|
"#playlist-rows .playlist-data-row"))
|
|
.map(row => ({
|
|
rowId: row.dataset.rowId,
|
|
selected: row.getAttribute("aria-selected") === "true",
|
|
active: row.dataset.active === "true",
|
|
current: row.getAttribute("aria-current") === "true",
|
|
enabled: row.querySelector("input[type='checkbox']")?.checked === true,
|
|
dragging: row.classList.contains("dragging"),
|
|
dragBefore: row.classList.contains("drag-before"),
|
|
dragAfter: row.classList.contains("drag-after"),
|
|
rowHeaderText: (row.querySelector(".playlist-row-header")?.textContent || "").trim(),
|
|
rowHeaderWidth: row.querySelector(".playlist-row-header")
|
|
?.getBoundingClientRect().width ?? null,
|
|
rowHeaderDragEnabled: row.querySelector(".playlist-row-header")
|
|
?.dataset?.dragEnabled === "true",
|
|
focused: row === active || row.contains(active)
|
|
})),
|
|
cutDropHighlighted: document.getElementById("playlist-drop-zone")
|
|
?.classList.contains("cut-drag-over") === true,
|
|
cutPointerCapture: {
|
|
pointerId: activePointerId,
|
|
physicalIndices: capturedCutPhysicalIndices
|
|
},
|
|
playlistPointerCapture: {
|
|
pointerId: activePointerId,
|
|
rowIds: capturedPlaylistRowIds
|
|
},
|
|
namedModalHidden: document.getElementById("named-playlist-modal")?.hidden === true,
|
|
namedConfirmationHidden: document.getElementById("named-playlist-confirmation")?.hidden === true,
|
|
namedTitle: (document.getElementById("named-playlist-title")?.textContent || "").trim(),
|
|
namedConfirmationMessage: (document.getElementById(
|
|
"named-playlist-confirmation-message")?.textContent || "").trim(),
|
|
namedConfirmDisabled: document.getElementById(
|
|
"named-playlist-confirm-button")?.disabled === true,
|
|
namedDefinitionCount: document.querySelectorAll(
|
|
"#named-playlist-definitions button[data-named-playlist-definition-id]").length,
|
|
activeElement: active ? {
|
|
id: active.id || null,
|
|
tagName: active.tagName,
|
|
resultIndex: active.dataset?.resultIndex ?? null,
|
|
physicalIndex: active.dataset?.physicalIndex ?? null,
|
|
playlistRowId: rowForActive?.dataset?.rowId ?? null
|
|
} : null,
|
|
visibleModalRole: visibleModal?.getAttribute("role") || null,
|
|
visibleModalLabelledBy: visibleModal?.getAttribute("aria-labelledby") || null,
|
|
activeInsideVisibleModal: visibleModal ? visibleModal.contains(active) : false,
|
|
modalFocusableIds: focusables.map(element => element.id || null)
|
|
}
|
|
};
|
|
})()`;
|
|
}
|
|
|
|
async function readSnapshot() {
|
|
return await evaluate(probeSnapshotExpression());
|
|
}
|
|
|
|
function assertSafety(snapshot, label, allowUiBusy = true) {
|
|
if (!snapshot?.state?.playout) {
|
|
failKnown(`Native playout state is missing at ${label}.`);
|
|
}
|
|
const state = snapshot.state;
|
|
const playout = state.playout;
|
|
if (snapshot.safety?.postMessageWrapped !== true) {
|
|
failUnknown(`The outbound WebView safety gate is not installed at ${label}.`);
|
|
}
|
|
const outboundMessages = snapshot.safety.outboundMessages || [];
|
|
const blockedOutboundMessages = snapshot.safety.blockedOutboundMessages || [];
|
|
const stateViolations = snapshot.safety.stateViolations || [];
|
|
evidence.safety.observedOutboundMessageTypes = outboundMessages
|
|
.map(message => message.type);
|
|
evidence.safety.blockedOutboundMessages = blockedOutboundMessages
|
|
.map(message => ({ ...message }));
|
|
evidence.safety.stateViolations = stateViolations.map(violation => ({
|
|
...violation,
|
|
reasons: [...violation.reasons]
|
|
}));
|
|
evidence.safety.playoutIntentIssued = blockedOutboundMessages.some(message =>
|
|
message.category === "playout");
|
|
evidence.safety.databaseWriteIntentIssued = blockedOutboundMessages.some(message =>
|
|
message.category === "database-write");
|
|
if (blockedOutboundMessages.length > 0) {
|
|
failKnown(`The outbound WebView safety gate blocked an unapproved message at ${label}.`);
|
|
}
|
|
if (stateViolations.length > 0) {
|
|
failUnknown(`A transient unsafe playout state was observed before ${label}.`);
|
|
}
|
|
if (playout.mode !== "dryRun") {
|
|
failKnown(`Expected DryRun at ${label}, received ${String(playout.mode)}.`);
|
|
}
|
|
if (playout.phase !== "idle" || playout.preparedCode != null ||
|
|
playout.onAirCode != null) {
|
|
failKnown(`Playout left IDLE at ${label}.`);
|
|
}
|
|
if (playout.outcomeUnknown === true ||
|
|
playout.isPlayCompletionPending === true ||
|
|
playout.isTakeOutCompletionPending === true ||
|
|
playout.isBusy === true || playout.refreshActive === true) {
|
|
failUnknown(`Playout became busy, pending, refreshing, or unknown at ${label}.`);
|
|
}
|
|
if (!allowUiBusy && state.isBusy === true) {
|
|
failKnown(`The operator UI is still busy at ${label}.`);
|
|
}
|
|
if (snapshot.dom.url !== exactTargetUrl || snapshot.dom.readyState !== "complete") {
|
|
failKnown(`The exact package document is not ready at ${label}.`);
|
|
}
|
|
if (snapshot.dom.connectionText !== "DRY RUN") {
|
|
failKnown(`The visible connection badge is not DRY RUN at ${label}.`);
|
|
}
|
|
if (!snapshot.dom.legacyDialogHidden) {
|
|
failKnown(`A legacy alert is visible at ${label}: ${snapshot.dom.legacyDialogText}`);
|
|
}
|
|
}
|
|
|
|
async function waitFor(label, predicate, options = {}) {
|
|
const timeoutMilliseconds = options.timeoutMilliseconds ||
|
|
configuration.timeoutMilliseconds;
|
|
const deadline = Date.now() + timeoutMilliseconds;
|
|
let lastSnapshot = null;
|
|
|
|
while (Date.now() < deadline) {
|
|
lastSnapshot = await readSnapshot();
|
|
if (lastSnapshot?.state?.playout) {
|
|
assertSafety(lastSnapshot, label, options.allowUiBusy !== false);
|
|
}
|
|
if (predicate(lastSnapshot)) return lastSnapshot;
|
|
await sleep(100);
|
|
}
|
|
|
|
failUnknown(`Timed out waiting for ${label}; no action was retried. Last snapshot: ` +
|
|
JSON.stringify(lastSnapshot));
|
|
}
|
|
|
|
async function checkpoint(name, details = null) {
|
|
const snapshot = await readSnapshot();
|
|
assertSafety(snapshot, name, false);
|
|
evidence.checkpoints.push({ name, details, snapshot });
|
|
return snapshot;
|
|
}
|
|
|
|
async function elementGeometry(expression, label, verticalFraction = 0.5) {
|
|
const geometry = await evaluate(`(() => {
|
|
const element = ${expression};
|
|
if (!element) return null;
|
|
const rectangle = element.getBoundingClientRect();
|
|
return {
|
|
x: rectangle.left + rectangle.width / 2,
|
|
y: rectangle.top + rectangle.height * ${Number(verticalFraction)},
|
|
left: rectangle.left,
|
|
top: rectangle.top,
|
|
right: rectangle.right,
|
|
bottom: rectangle.bottom,
|
|
width: rectangle.width,
|
|
height: rectangle.height,
|
|
disabled: element.disabled === true,
|
|
ariaDisabled: element.getAttribute("aria-disabled")
|
|
};
|
|
})()`);
|
|
if (!geometry || geometry.disabled || geometry.ariaDisabled === "true" ||
|
|
!Number.isFinite(geometry.x) || !Number.isFinite(geometry.y) ||
|
|
geometry.width <= 0 || geometry.height <= 0 ||
|
|
geometry.right <= 0 || geometry.bottom <= 0) {
|
|
failKnown(`${label} is missing, disabled, or outside the rendered viewport.`);
|
|
}
|
|
return geometry;
|
|
}
|
|
|
|
async function playlistDropGeometry(label) {
|
|
const geometry = await evaluate(`(() => {
|
|
const zone = document.getElementById("playlist-drop-zone");
|
|
if (!zone) return null;
|
|
const zoneRectangle = zone.getBoundingClientRect();
|
|
const headerRectangle = zone.querySelector(".playlist-header")
|
|
?.getBoundingClientRect() || null;
|
|
const viewport = {
|
|
width: document.documentElement.clientWidth,
|
|
height: document.documentElement.clientHeight
|
|
};
|
|
const inset = 8;
|
|
const left = Math.max(0, zoneRectangle.left + inset);
|
|
const right = Math.min(viewport.width - 1, zoneRectangle.right - inset);
|
|
const top = Math.max(
|
|
0,
|
|
(headerRectangle ? headerRectangle.bottom : zoneRectangle.top) + inset);
|
|
const bottom = Math.min(viewport.height - 1, zoneRectangle.bottom - inset);
|
|
const fractions = [0.5, 0.25, 0.75];
|
|
const points = right > left && bottom > top
|
|
? fractions.map(fraction => {
|
|
const x = left + (right - left) / 2;
|
|
const y = top + (bottom - top) * fraction;
|
|
const hit = document.elementFromPoint(x, y);
|
|
return {
|
|
x,
|
|
y,
|
|
hitId: hit?.id || null,
|
|
hitClass: hit?.className || null,
|
|
insideDropZone: hit === zone || zone.contains(hit)
|
|
};
|
|
})
|
|
: [];
|
|
return {
|
|
zone: {
|
|
left: zoneRectangle.left,
|
|
top: zoneRectangle.top,
|
|
right: zoneRectangle.right,
|
|
bottom: zoneRectangle.bottom,
|
|
width: zoneRectangle.width,
|
|
height: zoneRectangle.height
|
|
},
|
|
headerBottom: headerRectangle?.bottom ?? null,
|
|
viewport,
|
|
visibleInterior: { left, top, right, bottom },
|
|
points
|
|
};
|
|
})()`);
|
|
if (!geometry || geometry.zone.width <= 0 || geometry.zone.height <= 0 ||
|
|
geometry.points.length === 0 ||
|
|
geometry.points.some(point => !Number.isFinite(point.x) ||
|
|
!Number.isFinite(point.y) || point.insideDropZone !== true)) {
|
|
failKnown(`${label} has no verified visible interior drop point.`);
|
|
}
|
|
return geometry;
|
|
}
|
|
|
|
async function moveMouse(point, modifiers = 0, buttons = 0) {
|
|
await rpc("Input.dispatchMouseEvent", {
|
|
type: "mouseMoved",
|
|
x: point.x,
|
|
y: point.y,
|
|
// Edge releases explicit pointer capture if a CDP mouseMoved event says
|
|
// no button while its buttons bitmask says the left button is held.
|
|
// Keep both fields consistent with the physical state during a drag.
|
|
button: (buttons & 1) === 1 ? "left" : "none",
|
|
buttons,
|
|
modifiers,
|
|
pointerType: "mouse"
|
|
});
|
|
}
|
|
|
|
async function pressMouse(point, modifiers = 0) {
|
|
if (pointerIsPressed) failKnown("A second pointer press was attempted before release.");
|
|
await moveMouse(point, modifiers, 0);
|
|
await rpc("Input.dispatchMouseEvent", {
|
|
type: "mousePressed",
|
|
x: point.x,
|
|
y: point.y,
|
|
button: "left",
|
|
buttons: 1,
|
|
clickCount: 1,
|
|
modifiers,
|
|
pointerType: "mouse"
|
|
});
|
|
pointerIsPressed = true;
|
|
pointerReleasePoint = point;
|
|
pointerReleaseModifiers = modifiers;
|
|
}
|
|
|
|
async function releaseMouse(point, modifiers = 0) {
|
|
if (!pointerIsPressed) failKnown("A pointer release was attempted without a press.");
|
|
await rpc("Input.dispatchMouseEvent", {
|
|
type: "mouseReleased",
|
|
x: point.x,
|
|
y: point.y,
|
|
button: "left",
|
|
buttons: 0,
|
|
clickCount: 1,
|
|
modifiers,
|
|
pointerType: "mouse"
|
|
});
|
|
pointerIsPressed = false;
|
|
pointerReleasePoint = null;
|
|
pointerReleaseModifiers = 0;
|
|
}
|
|
|
|
async function pointerClick(expression, label, modifiers = 0) {
|
|
const forbiddenId = await evaluate(`(() => {
|
|
const element = ${expression};
|
|
return element?.closest?.("button")?.id || element?.id || null;
|
|
})()`);
|
|
if (forbiddenControlIds.includes(forbiddenId)) {
|
|
failKnown(`The harness refused forbidden control #${forbiddenId}.`);
|
|
}
|
|
|
|
const point = await elementGeometry(expression, label);
|
|
evidence.inputs.push({ type: "pointer-click", label, modifiers, point });
|
|
await pressMouse(point, modifiers);
|
|
await sleep(25);
|
|
await releaseMouse(point, modifiers);
|
|
return point;
|
|
}
|
|
|
|
const keyDefinitions = Object.freeze({
|
|
ArrowDown: { key: "ArrowDown", code: "ArrowDown", virtualKey: 40 },
|
|
ArrowUp: { key: "ArrowUp", code: "ArrowUp", virtualKey: 38 },
|
|
Backspace: { key: "Backspace", code: "Backspace", virtualKey: 8 },
|
|
End: { key: "End", code: "End", virtualKey: 35 },
|
|
Escape: { key: "Escape", code: "Escape", virtualKey: 27 },
|
|
Home: { key: "Home", code: "Home", virtualKey: 36 },
|
|
Space: { key: " ", code: "Space", virtualKey: 32 },
|
|
Tab: { key: "Tab", code: "Tab", virtualKey: 9 },
|
|
SelectAll: { key: "a", code: "KeyA", virtualKey: 65 }
|
|
});
|
|
|
|
async function pressKey(definitionName, label, modifiers = 0) {
|
|
const definition = keyDefinitions[definitionName];
|
|
if (!definition) failKnown(`Unknown key definition ${definitionName}.`);
|
|
if (new Set(["F2", "F3", "F8"]).has(definition.key)) {
|
|
failKnown(`The harness refused playout shortcut ${definition.key}.`);
|
|
}
|
|
if (definition.key === "Escape") {
|
|
const modalState = await evaluate(`(() => ({
|
|
named: document.getElementById("named-playlist-modal")?.hidden === false,
|
|
confirmation: document.getElementById("named-playlist-confirmation")?.hidden === false
|
|
}))()`);
|
|
if (!modalState?.named && !modalState?.confirmation) {
|
|
failKnown("Escape is allowed only while the named-playlist modal owns input.");
|
|
}
|
|
}
|
|
|
|
evidence.inputs.push({
|
|
type: "key",
|
|
label,
|
|
key: definition.key,
|
|
modifiers
|
|
});
|
|
const common = {
|
|
key: definition.key,
|
|
code: definition.code,
|
|
windowsVirtualKeyCode: definition.virtualKey,
|
|
nativeVirtualKeyCode: definition.virtualKey,
|
|
modifiers
|
|
};
|
|
await rpc("Input.dispatchKeyEvent", { type: "keyDown", ...common });
|
|
await rpc("Input.dispatchKeyEvent", { type: "keyUp", ...common });
|
|
}
|
|
|
|
async function setSearchText(text) {
|
|
await pointerClick(
|
|
'document.getElementById("stock-search")',
|
|
"focus stock search");
|
|
await pressKey("SelectAll", "select existing stock query", ctrlModifier);
|
|
await pressKey("Backspace", "clear existing stock query");
|
|
evidence.inputs.push({ type: "text", label: "stock query", text });
|
|
await rpc("Input.insertText", { text });
|
|
}
|
|
|
|
function selectedPhysicalIndices(snapshot) {
|
|
return snapshot.state.cutRows
|
|
.filter(row => row.isSelected)
|
|
.map(row => row.physicalIndex);
|
|
}
|
|
|
|
function selectedPlaylistIds(snapshot) {
|
|
return snapshot.state.playlist
|
|
.filter(row => row.isSelected)
|
|
.map(row => row.rowId);
|
|
}
|
|
|
|
function activePlaylistId(snapshot) {
|
|
return snapshot.state.playlist.find(row => row.isActive)?.rowId || null;
|
|
}
|
|
|
|
function focusedCutIndex(snapshot) {
|
|
return snapshot.state.cutRows.find(row => row.isFocused)?.physicalIndex ?? null;
|
|
}
|
|
|
|
function assertExactArray(actual, expected, label) {
|
|
if (actual.length !== expected.length ||
|
|
actual.some((value, index) => value !== expected[index])) {
|
|
failKnown(`${label}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}.`);
|
|
}
|
|
}
|
|
|
|
function cssDragSize(snapshot) {
|
|
const metrics = snapshot.state.interactionMetrics;
|
|
const devicePixelRatio = snapshot.devicePixelRatio;
|
|
if (!metrics || metrics.coordinateSpace !== "host-dip" ||
|
|
!Number.isInteger(metrics.cutDragWidthDips) || metrics.cutDragWidthDips <= 0 ||
|
|
!Number.isInteger(metrics.cutDragHeightDips) || metrics.cutDragHeightDips <= 0) {
|
|
failKnown("Valid host-DIP cut drag metrics are missing.");
|
|
}
|
|
const hostScale = Number.isFinite(metrics.hostRasterizationScale) &&
|
|
metrics.hostRasterizationScale > 0 ? metrics.hostRasterizationScale : 1;
|
|
const deviceScale = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0
|
|
? devicePixelRatio : hostScale;
|
|
const candidateZoom = deviceScale / hostScale;
|
|
const browserZoom = Number.isFinite(candidateZoom) &&
|
|
candidateZoom >= 0.25 && candidateZoom <= 5 ? candidateZoom : 1;
|
|
return {
|
|
width: metrics.cutDragWidthDips / browserZoom,
|
|
height: metrics.cutDragHeightDips / browserZoom
|
|
};
|
|
}
|
|
|
|
async function dragCutsToPlaylist(physicalIndex, modifiers, expectedAppendCount, label) {
|
|
const before = await readSnapshot();
|
|
assertSafety(before, `${label} preflight`, false);
|
|
const beforeCount = before.state.playlist.length;
|
|
const beforeRevision = before.state.revision;
|
|
const sourceExpression =
|
|
`document.querySelector('#cut-list .cut-row[data-physical-index="${physicalIndex}"]')`;
|
|
const source = await elementGeometry(sourceExpression, `${label} source`);
|
|
const dropGeometry = await playlistDropGeometry(`${label} target`);
|
|
const dragInput = {
|
|
type: "cut-drag",
|
|
label,
|
|
physicalIndex,
|
|
modifiers,
|
|
source,
|
|
dropGeometry,
|
|
targetSamples: [],
|
|
acceptedTarget: null,
|
|
expectedAppendCount
|
|
};
|
|
evidence.inputs.push(dragInput);
|
|
|
|
await pressMouse(source, modifiers);
|
|
try {
|
|
const held = await waitFor(`${label} pointer-down receipt`, snapshot =>
|
|
snapshot.state.revision > beforeRevision &&
|
|
focusedCutIndex(snapshot) === physicalIndex,
|
|
{ allowUiBusy: false });
|
|
const dragSize = cssDragSize(held);
|
|
const inside = {
|
|
x: source.x - dragSize.width / 2,
|
|
y: source.y - dragSize.height / 2
|
|
};
|
|
const threshold = {
|
|
x: Math.min(source.right - 4, source.x + Math.max(8, dragSize.width + 2)),
|
|
y: source.y
|
|
};
|
|
pointerReleasePoint = source;
|
|
pointerReleaseModifiers = modifiers;
|
|
await moveMouse(inside, modifiers, 1);
|
|
await sleep(30);
|
|
const beforeThreshold = await readSnapshot();
|
|
const sourceBeforeThreshold = beforeThreshold.dom.cuts.find(row =>
|
|
row.physicalIndex === physicalIndex);
|
|
if (!sourceBeforeThreshold || sourceBeforeThreshold.dragging ||
|
|
beforeThreshold.dom.cutDropHighlighted) {
|
|
failKnown(`${label} started inside the original drag rectangle.`);
|
|
}
|
|
|
|
await moveMouse(threshold, modifiers, 1);
|
|
await sleep(50);
|
|
const thresholdSnapshot = await readSnapshot();
|
|
const thresholdSource = thresholdSnapshot.dom.cuts.find(row =>
|
|
row.physicalIndex === physicalIndex);
|
|
const capturedAtThreshold = thresholdSnapshot.dom.cutPointerCapture
|
|
?.physicalIndices?.includes(physicalIndex) === true;
|
|
dragInput.threshold = {
|
|
point: threshold,
|
|
dragging: thresholdSource?.dragging === true,
|
|
captured: capturedAtThreshold,
|
|
pointerEvents: thresholdSnapshot.pointerEvents.slice(-12)
|
|
};
|
|
if (!thresholdSource?.dragging) {
|
|
failKnown(
|
|
`${label} did not cross the Windows drag threshold on the source row; ` +
|
|
`pointer diagnostics: ${JSON.stringify(dragInput.threshold.pointerEvents)}.`);
|
|
}
|
|
if (!capturedAtThreshold) {
|
|
failKnown(
|
|
`${label} crossed the drag threshold but WebView2 did not retain pointer ` +
|
|
`capture on the source row; pointer diagnostics: ` +
|
|
`${JSON.stringify(dragInput.threshold.pointerEvents)}.`);
|
|
}
|
|
if (thresholdSnapshot.dom.cutDropHighlighted) {
|
|
failKnown(`${label} highlighted the remote drop zone while still on the source row.`);
|
|
}
|
|
|
|
let overTarget = null;
|
|
let acceptedTarget = null;
|
|
for (const candidate of dropGeometry.points) {
|
|
await moveMouse(candidate, modifiers, 1);
|
|
await sleep(50);
|
|
const sample = await readSnapshot();
|
|
const sourceAtCandidate = sample.dom.cuts.find(row =>
|
|
row.physicalIndex === physicalIndex);
|
|
const sampleEvidence = {
|
|
point: candidate,
|
|
dragging: sourceAtCandidate?.dragging === true,
|
|
highlighted: sample.dom.cutDropHighlighted,
|
|
captured: sample.dom.cutPointerCapture?.physicalIndices
|
|
?.includes(physicalIndex) === true,
|
|
pointerEvents: sample.pointerEvents.slice(-12)
|
|
};
|
|
dragInput.targetSamples.push(sampleEvidence);
|
|
if (sampleEvidence.dragging && sampleEvidence.highlighted &&
|
|
sampleEvidence.captured) {
|
|
overTarget = sample;
|
|
acceptedTarget = candidate;
|
|
break;
|
|
}
|
|
}
|
|
if (!overTarget || !acceptedTarget) {
|
|
failKnown(
|
|
`${label} did not enter the real captured-pointer drop state; samples: ` +
|
|
`${JSON.stringify(dragInput.targetSamples)}.`);
|
|
}
|
|
|
|
dragInput.acceptedTarget = acceptedTarget;
|
|
await releaseMouse(acceptedTarget, modifiers);
|
|
} finally {
|
|
if (pointerIsPressed) {
|
|
const safePoint = pointerReleasePoint || source;
|
|
await releaseMouse(safePoint, pointerReleaseModifiers);
|
|
}
|
|
}
|
|
|
|
const completed = await waitFor(`${label} append`, snapshot =>
|
|
snapshot.state.isBusy !== true &&
|
|
snapshot.state.playlist.length === beforeCount + expectedAppendCount,
|
|
{ allowUiBusy: false });
|
|
if (!completed.dom.cuts.every(row => !row.dragging) ||
|
|
completed.dom.cutDropHighlighted) {
|
|
failKnown(`${label} left drag visuals active after release.`);
|
|
}
|
|
return completed;
|
|
}
|
|
|
|
async function rowCellGeometry(rowId, label) {
|
|
return await elementGeometry(`(() => {
|
|
const row = Array.from(document.querySelectorAll(
|
|
"#playlist-rows .playlist-data-row"))
|
|
.find(candidate => candidate.dataset.rowId === ${JSON.stringify(rowId)});
|
|
return row?.children?.item(3) || null;
|
|
})()`, label);
|
|
}
|
|
|
|
async function playlistRowHeaderGeometry(rowId, label, verticalFraction = 0.5) {
|
|
return await elementGeometry(`(() => {
|
|
const row = Array.from(document.querySelectorAll(
|
|
"#playlist-rows .playlist-data-row"))
|
|
.find(candidate => candidate.dataset.rowId === ${JSON.stringify(rowId)});
|
|
return row?.querySelector(".playlist-row-header") || null;
|
|
})()`, label, verticalFraction);
|
|
}
|
|
|
|
async function playlistCheckboxGeometry(rowId, label) {
|
|
return await elementGeometry(`(() => {
|
|
const row = Array.from(document.querySelectorAll(
|
|
"#playlist-rows .playlist-data-row"))
|
|
.find(candidate => candidate.dataset.rowId === ${JSON.stringify(rowId)});
|
|
return row?.querySelector("input[type='checkbox']") || null;
|
|
})()`, label);
|
|
}
|
|
|
|
async function pointerClickPlaylistRow(rowId, label, modifiers = 0) {
|
|
const point = await rowCellGeometry(rowId, label);
|
|
evidence.inputs.push({ type: "pointer-click", label, modifiers, point, rowId });
|
|
await pressMouse(point, modifiers);
|
|
await sleep(25);
|
|
await releaseMouse(point, modifiers);
|
|
}
|
|
|
|
async function dragPlaylistRow(sourceRowId, targetRowId, label) {
|
|
const source = await playlistRowHeaderGeometry(sourceRowId, `${label} source`);
|
|
const target = await playlistRowHeaderGeometry(targetRowId, `${label} target`, 0.75);
|
|
evidence.inputs.push({
|
|
type: "playlist-row-drag",
|
|
label,
|
|
sourceRowId,
|
|
targetRowId,
|
|
position: "after",
|
|
source,
|
|
target
|
|
});
|
|
|
|
await pressMouse(source, 0);
|
|
try {
|
|
pointerReleasePoint = source;
|
|
await moveMouse({ x: source.x + 8, y: source.y }, 0, 1);
|
|
await sleep(50);
|
|
await moveMouse({
|
|
x: (source.x + target.x) / 2,
|
|
y: (source.y + target.y) / 2
|
|
}, 0, 1);
|
|
await sleep(50);
|
|
await moveMouse(target, 0, 1);
|
|
await sleep(75);
|
|
|
|
const overTarget = await readSnapshot();
|
|
const sourceDom = overTarget.dom.playlist.find(row => row.rowId === sourceRowId);
|
|
const targetDom = overTarget.dom.playlist.find(row => row.rowId === targetRowId);
|
|
const captured = overTarget.dom.playlistPointerCapture?.rowIds
|
|
?.includes(sourceRowId) === true;
|
|
if (!sourceDom?.dragging || !targetDom?.dragAfter || !captured) {
|
|
failKnown(`${label} did not produce the captured-pointer row-header drag state.`);
|
|
}
|
|
await releaseMouse(target, 0);
|
|
} finally {
|
|
if (pointerIsPressed) {
|
|
await releaseMouse(pointerReleasePoint || source, 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function attemptCheckboxOriginDrag(sourceRowId, targetRowId, label) {
|
|
const source = await playlistCheckboxGeometry(sourceRowId, `${label} checkbox`);
|
|
const target = await playlistRowHeaderGeometry(targetRowId, `${label} target`, 0.75);
|
|
evidence.inputs.push({
|
|
type: "playlist-checkbox-drag-attempt",
|
|
label,
|
|
sourceRowId,
|
|
targetRowId,
|
|
source,
|
|
target
|
|
});
|
|
|
|
await pressMouse(source, 0);
|
|
try {
|
|
pointerReleasePoint = source;
|
|
await moveMouse({ x: source.x + 8, y: source.y }, 0, 1);
|
|
await sleep(50);
|
|
await moveMouse(target, 0, 1);
|
|
await sleep(75);
|
|
const during = await readSnapshot();
|
|
if (during.dom.playlist.some(row =>
|
|
row.dragging || row.dragBefore || row.dragAfter)) {
|
|
failKnown(`${label} exposed a row drag visual from a checkbox origin.`);
|
|
}
|
|
await releaseMouse(target, 0);
|
|
} finally {
|
|
if (pointerIsPressed) {
|
|
await releaseMouse(pointerReleasePoint || source, 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
function readSmallJsonFile(filePath, label) {
|
|
let stat;
|
|
try {
|
|
stat = fs.lstatSync(filePath);
|
|
} catch (_) {
|
|
failKnown(`${label} is unavailable.`);
|
|
}
|
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > 16_384) {
|
|
failKnown(`${label} is not a small regular file.`);
|
|
}
|
|
try {
|
|
const bytes = fs.readFileSync(filePath);
|
|
if (bytes.length !== stat.size) failKnown(`${label} changed while it was read.`);
|
|
return {
|
|
value: JSON.parse(bytes.toString("utf8")),
|
|
bytes
|
|
};
|
|
} catch (_) {
|
|
failKnown(`${label} is not valid UTF-8 JSON.`);
|
|
}
|
|
}
|
|
|
|
async function requestWindowsInput(
|
|
current,
|
|
sourceRowId,
|
|
targetRowId,
|
|
expectedFirstRowId,
|
|
label) {
|
|
assertSafety(current, `${label} preflight`, false);
|
|
const preflightSignature = JSON.stringify({
|
|
revision: current.state.revision,
|
|
order: current.state.playlist.map(row => row.rowId),
|
|
selected: selectedPlaylistIds(current),
|
|
active: activePlaylistId(current),
|
|
focused: current.dom.activeElement?.playlistRowId ?? null
|
|
});
|
|
await sleep(350);
|
|
let stabilized = await readSnapshot();
|
|
assertSafety(stabilized, `${label} stabilized preflight`, false);
|
|
const stabilizedSignature = JSON.stringify({
|
|
revision: stabilized.state.revision,
|
|
order: stabilized.state.playlist.map(row => row.rowId),
|
|
selected: selectedPlaylistIds(stabilized),
|
|
active: activePlaylistId(stabilized),
|
|
focused: stabilized.dom.activeElement?.playlistRowId ?? null
|
|
});
|
|
if (stabilizedSignature !== preflightSignature) {
|
|
failKnown(`${label} state changed before the one-shot request was published.`);
|
|
}
|
|
current = stabilized;
|
|
const source = await playlistRowHeaderGeometry(sourceRowId, `${label} source`);
|
|
const target = await playlistRowHeaderGeometry(targetRowId, `${label} target`, 0.75);
|
|
await sleep(100);
|
|
const geometryGuard = await readSnapshot();
|
|
const guardedSource = await playlistRowHeaderGeometry(
|
|
sourceRowId, `${label} guarded source`);
|
|
const guardedTarget = await playlistRowHeaderGeometry(
|
|
targetRowId, `${label} guarded target`, 0.75);
|
|
const geometryStable = ["x", "y", "left", "top", "right", "bottom"]
|
|
.every(key => Math.abs(source[key] - guardedSource[key]) <= 0.5 &&
|
|
Math.abs(target[key] - guardedTarget[key]) <= 0.5);
|
|
const guardSignature = JSON.stringify({
|
|
revision: geometryGuard.state.revision,
|
|
order: geometryGuard.state.playlist.map(row => row.rowId),
|
|
selected: selectedPlaylistIds(geometryGuard),
|
|
active: activePlaylistId(geometryGuard),
|
|
focused: geometryGuard.dom.activeElement?.playlistRowId ?? null
|
|
});
|
|
if (!geometryStable || guardSignature !== stabilizedSignature) {
|
|
failKnown(`${label} DOM geometry or native state changed before publication.`);
|
|
}
|
|
current = geometryGuard;
|
|
const token = randomBytes(16).toString("hex").toUpperCase();
|
|
const beforeOrder = current.state.playlist.map(row => row.rowId);
|
|
const afterOrder = beforeOrder.filter(rowId => rowId !== sourceRowId);
|
|
const targetIndex = afterOrder.indexOf(targetRowId);
|
|
if (targetIndex < 0 || sourceRowId === targetRowId ||
|
|
new Set(beforeOrder).size !== beforeOrder.length) {
|
|
failKnown(`${label} row identities are not safe for a one-row move.`);
|
|
}
|
|
afterOrder.splice(targetIndex + 1, 0, sourceRowId);
|
|
if (afterOrder[0] !== expectedFirstRowId) {
|
|
failKnown(`${label} computed an unexpected Home boundary row.`);
|
|
}
|
|
const request = {
|
|
schemaVersion: 1,
|
|
token,
|
|
targetUrl: exactTargetUrl,
|
|
operation: "row-header-drag-after-then-home",
|
|
sourceRowId,
|
|
targetRowId,
|
|
position: "after",
|
|
source: { x: source.x, y: source.y },
|
|
target: { x: target.x, y: target.y },
|
|
viewport: { ...current.dom.viewport },
|
|
devicePixelRatio: current.devicePixelRatio,
|
|
beforeOrder,
|
|
afterOrder,
|
|
expectedSelectedRowId: sourceRowId,
|
|
expectedActiveRowIdAfterHome: expectedFirstRowId,
|
|
expectedFocusedRowId: sourceRowId,
|
|
mouseDownCalls: 1,
|
|
mouseUpCalls: 1,
|
|
homeKeyCalls: 1
|
|
};
|
|
const requestBytes = Buffer.from(`${JSON.stringify(request, null, 2)}\n`, "utf8");
|
|
fs.writeFileSync(configuration.windowsInputRequestPath, requestBytes, { flag: "wx" });
|
|
evidence.windowsInput.requestSha256 = sha256(requestBytes);
|
|
evidence.inputs.push({
|
|
type: "windows-send-input-request",
|
|
label,
|
|
sourceRowId,
|
|
targetRowId,
|
|
position: "after"
|
|
});
|
|
|
|
while (!fs.existsSync(configuration.windowsInputAckPath)) {
|
|
await sleep(50);
|
|
}
|
|
const acknowledgement = readSmallJsonFile(
|
|
configuration.windowsInputAckPath,
|
|
"Windows input acknowledgement");
|
|
const ack = acknowledgement.value;
|
|
if (!ack || ack.schemaVersion !== 1 || ack.token !== token ||
|
|
ack.result !== "PASS" || ack.operation !== request.operation ||
|
|
ack.foregroundValidated !== true || ack.packageIdentityValidated !== true ||
|
|
ack.mouseDownCalls !== 1 || ack.mouseUpCalls !== 1 ||
|
|
ack.homeKeyCalls !== 1 || ack.inputRetryCount !== 0) {
|
|
failKnown("Windows input acknowledgement does not match the one-shot request.");
|
|
}
|
|
evidence.windowsInput.acknowledgementSha256 = sha256(acknowledgement.bytes);
|
|
|
|
const completed = await waitFor(label, snapshot =>
|
|
JSON.stringify(snapshot.state.playlist.map(row => row.rowId)) ===
|
|
JSON.stringify(afterOrder) &&
|
|
JSON.stringify(selectedPlaylistIds(snapshot)) === JSON.stringify([sourceRowId]) &&
|
|
activePlaylistId(snapshot) === expectedFirstRowId &&
|
|
snapshot.dom.activeElement?.playlistRowId === sourceRowId,
|
|
{
|
|
timeoutMilliseconds: Math.min(configuration.timeoutMilliseconds, 15_000),
|
|
allowUiBusy: false
|
|
});
|
|
evidence.windowsInput.result = "PASS";
|
|
evidence.windowsInput.beforeOrder = beforeOrder;
|
|
evidence.windowsInput.afterOrder = afterOrder;
|
|
evidence.windowsInput.activeRowIdAfterHome = expectedFirstRowId;
|
|
await checkpoint("windows-sendinput-row-header-drag-and-home", {
|
|
sourceRowId,
|
|
targetRowId,
|
|
beforeOrder,
|
|
afterOrder,
|
|
expectedFirstRowId
|
|
});
|
|
return completed;
|
|
}
|
|
|
|
async function installProbe() {
|
|
const token = `legacy-package-input-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
const installed = await evaluate(`(() => {
|
|
if (!window.chrome?.webview) {
|
|
throw new Error("WebView2 bridge is unavailable.");
|
|
}
|
|
if (globalThis.__legacyPackageInputProbe) {
|
|
throw new Error("A legacy package input probe is already installed; restart the app.");
|
|
}
|
|
const probe = {
|
|
token: ${JSON.stringify(token)},
|
|
stateCount: 0,
|
|
states: [],
|
|
listener: null,
|
|
pointerEventTypes: [
|
|
"pointerdown",
|
|
"gotpointercapture",
|
|
"pointermove",
|
|
"pointerup",
|
|
"pointercancel",
|
|
"lostpointercapture"
|
|
],
|
|
pointerEvents: [],
|
|
pointerListener: null,
|
|
originalPostMessage: null,
|
|
postMessageWrapper: null,
|
|
outboundMessages: [],
|
|
blockedOutboundMessages: [],
|
|
stateViolations: []
|
|
};
|
|
probe.listener = event => {
|
|
if (event.data?.type !== "state" || !event.data.payload) return;
|
|
const payload = event.data.payload;
|
|
probe.stateCount += 1;
|
|
probe.states.push(structuredClone(payload));
|
|
if (probe.states.length > 750) 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.preparedCode != null) reasons.push("prepared-code");
|
|
if (playout.onAirCode != null) reasons.push("on-air-code");
|
|
if (playout.isConnected === true) reasons.push("connected");
|
|
if (playout.outcomeUnknown === true) reasons.push("outcome-unknown");
|
|
if (playout.isPlayCompletionPending === true) reasons.push("play-pending");
|
|
if (playout.isTakeOutCompletionPending === true) reasons.push("take-out-pending");
|
|
if (playout.isBusy === true) reasons.push("playout-busy");
|
|
if (playout.refreshActive === true) reasons.push("refresh-active");
|
|
}
|
|
if (reasons.length > 0) {
|
|
probe.stateViolations.push({
|
|
at: new Date().toISOString(),
|
|
revision: payload.revision ?? null,
|
|
reasons
|
|
});
|
|
}
|
|
};
|
|
probe.pointerListener = event => {
|
|
const target = event.target instanceof Element ? event.target : null;
|
|
const cut = target?.closest?.("#cut-list .cut-row") || null;
|
|
const playlistRow = target?.closest?.("#playlist-rows .playlist-data-row") || null;
|
|
const capturedCuts = Number.isInteger(event.pointerId)
|
|
? Array.from(document.querySelectorAll("#cut-list .cut-row"))
|
|
.filter(row => typeof row.hasPointerCapture === "function" &&
|
|
row.hasPointerCapture(event.pointerId))
|
|
.map(row => Number(row.dataset.physicalIndex))
|
|
: [];
|
|
probe.pointerEvents.push({
|
|
type: event.type,
|
|
pointerId: event.pointerId,
|
|
pointerType: event.pointerType,
|
|
clientX: event.clientX,
|
|
clientY: event.clientY,
|
|
button: event.button,
|
|
buttons: event.buttons,
|
|
ctrlKey: event.ctrlKey,
|
|
shiftKey: event.shiftKey,
|
|
targetId: target?.id || null,
|
|
targetClass: target?.className || null,
|
|
cutPhysicalIndex: cut ? Number(cut.dataset.physicalIndex) : null,
|
|
playlistRowId: playlistRow?.dataset?.rowId || null,
|
|
capturedCutPhysicalIndices: capturedCuts,
|
|
timeStamp: event.timeStamp
|
|
});
|
|
if (probe.pointerEvents.length > 200) probe.pointerEvents.shift();
|
|
};
|
|
const allowedTypes = new Set(${JSON.stringify(allowedOutboundMessageTypes)});
|
|
const playoutTypes = new Set(${JSON.stringify(playoutIntentMessageTypes)});
|
|
const databaseWriteTypes = new Set(${JSON.stringify(databaseWriteIntentMessageTypes)});
|
|
probe.originalPostMessage = window.chrome.webview.postMessage;
|
|
probe.postMessageWrapper = message => {
|
|
const type = typeof message?.type === "string" ? message.type : null;
|
|
const record = {
|
|
type,
|
|
at: new Date().toISOString()
|
|
};
|
|
probe.outboundMessages.push(record);
|
|
if (probe.outboundMessages.length > 250) probe.outboundMessages.shift();
|
|
if (!type || !allowedTypes.has(type)) {
|
|
const blocked = {
|
|
...record,
|
|
category: playoutTypes.has(type)
|
|
? "playout"
|
|
: databaseWriteTypes.has(type)
|
|
? "database-write"
|
|
: "unapproved"
|
|
};
|
|
probe.blockedOutboundMessages.push(blocked);
|
|
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 WebView safety gate could not be installed.");
|
|
}
|
|
globalThis.__legacyPackageInputProbe = probe;
|
|
window.chrome.webview.addEventListener("message", probe.listener);
|
|
probe.pointerEventTypes.forEach(type =>
|
|
document.addEventListener(type, probe.pointerListener, true));
|
|
window.chrome.webview.postMessage({ type: "ready", payload: {} });
|
|
} catch (error) {
|
|
window.chrome.webview.postMessage = probe.originalPostMessage;
|
|
delete globalThis.__legacyPackageInputProbe;
|
|
throw error;
|
|
}
|
|
return { token: probe.token };
|
|
})()`);
|
|
if (installed?.token !== token) failKnown("The state observer was not installed exactly once.");
|
|
}
|
|
|
|
async function removeProbe() {
|
|
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
|
const removed = await evaluate(`(() => {
|
|
const probe = globalThis.__legacyPackageInputProbe;
|
|
if (!probe) return false;
|
|
if (probe.listener) {
|
|
window.chrome.webview.removeEventListener("message", probe.listener);
|
|
}
|
|
if (probe.pointerListener) {
|
|
probe.pointerEventTypes.forEach(type =>
|
|
document.removeEventListener(type, probe.pointerListener, true));
|
|
}
|
|
if (probe.originalPostMessage) {
|
|
if (window.chrome?.webview?.postMessage !== probe.postMessageWrapper) {
|
|
throw new Error("The outbound WebView safety gate identity changed.");
|
|
}
|
|
window.chrome.webview.postMessage = probe.originalPostMessage;
|
|
}
|
|
delete globalThis.__legacyPackageInputProbe;
|
|
return true;
|
|
})()`);
|
|
if (removed !== true) {
|
|
failUnknown("The outbound WebView safety probe was not restored exactly once.");
|
|
}
|
|
probeInstalled = false;
|
|
}
|
|
|
|
async function captureScreenshot() {
|
|
const response = await rpc("Page.captureScreenshot", {
|
|
format: "png",
|
|
fromSurface: true,
|
|
captureBeyondViewport: false
|
|
}, 20_000);
|
|
if (!response?.data) failKnown("CDP did not return screenshot data.");
|
|
const bytes = Buffer.from(response.data, "base64");
|
|
fs.writeFileSync(configuration.screenshotPath, bytes, { flag: "wx" });
|
|
evidence.screenshot = {
|
|
path: configuration.screenshotPath,
|
|
bytes: bytes.length,
|
|
sha256: sha256(bytes)
|
|
};
|
|
}
|
|
|
|
async function executeSafeSequence() {
|
|
await rpc("Runtime.enable");
|
|
await rpc("Page.enable");
|
|
await installProbe();
|
|
probeInstalled = true;
|
|
|
|
const preflight = await waitFor("DryRun IDLE preflight", snapshot =>
|
|
snapshot?.state?.playout?.mode === "dryRun" &&
|
|
snapshot.state.playout.phase === "idle" &&
|
|
snapshot.state.isBusy !== true &&
|
|
snapshot.dom.connectionText === "DRY RUN",
|
|
{ allowUiBusy: false });
|
|
assertSafety(preflight, "DryRun IDLE preflight", false);
|
|
if (preflight.state.playlist.length !== 0) {
|
|
failKnown("The package input test requires an initially empty in-memory playlist.");
|
|
}
|
|
if (!preflight.dom.namedModalHidden || !preflight.dom.namedConfirmationHidden) {
|
|
failKnown("A child modal was already open at preflight.");
|
|
}
|
|
await checkpoint("preflight");
|
|
|
|
await setSearchText(evidence.fixture.query);
|
|
await pointerClick(
|
|
'document.getElementById("stock-search-button")',
|
|
"submit stock search");
|
|
let current = await waitFor("stock search result", snapshot =>
|
|
snapshot?.state?.isBusy === false &&
|
|
snapshot.state.searchText === evidence.fixture.query &&
|
|
snapshot.state.searchResults.some(row =>
|
|
row.displayName === evidence.fixture.stockDisplayName),
|
|
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
|
|
|
|
const exactStocks = current.state.searchResults.filter(row =>
|
|
row.displayName === evidence.fixture.stockDisplayName);
|
|
if (exactStocks.length !== 1) {
|
|
failKnown(`Expected exactly one ${evidence.fixture.stockDisplayName} row, received ${exactStocks.length}.`);
|
|
}
|
|
const exactStock = exactStocks[0];
|
|
const exactStockExpression =
|
|
`document.querySelector('#stock-results button[data-result-index="${exactStock.index}"]')`;
|
|
await pointerClick(exactStockExpression, `select ${evidence.fixture.stockDisplayName}`);
|
|
current = await waitFor("exact stock selection", snapshot =>
|
|
snapshot.state.isBusy === false &&
|
|
snapshot.state.selectedStockIndex === exactStock.index,
|
|
{ allowUiBusy: false });
|
|
|
|
const selectedPosition = current.state.searchResults.findIndex(row =>
|
|
row.index === exactStock.index);
|
|
const forward = selectedPosition < current.state.searchResults.length - 1;
|
|
const firstKey = forward ? "ArrowDown" : "ArrowUp";
|
|
const returnKey = forward ? "ArrowUp" : "ArrowDown";
|
|
const neighbor = current.state.searchResults[selectedPosition + (forward ? 1 : -1)];
|
|
if (!neighbor) failKnown("The exact stock has no adjacent keyboard result.");
|
|
await pressKey(firstKey, "move stock result selection to adjacent row");
|
|
current = await waitFor("adjacent stock keyboard selection", snapshot =>
|
|
snapshot.state.selectedStockIndex === neighbor.index &&
|
|
snapshot.dom.stockOptions.filter(row => row.selected).length === 1 &&
|
|
snapshot.dom.stockOptions.some(row =>
|
|
row.index === neighbor.index && row.selected && row.tabIndex === 0 && row.focused),
|
|
{ allowUiBusy: false });
|
|
await pressKey(returnKey, "return stock result selection");
|
|
current = await waitFor("returned stock keyboard selection", snapshot =>
|
|
snapshot.state.selectedStockIndex === exactStock.index &&
|
|
snapshot.dom.stockOptions.filter(row => row.selected).length === 1 &&
|
|
snapshot.dom.stockOptions.some(row =>
|
|
row.index === exactStock.index && row.selected && row.tabIndex === 0 && row.focused),
|
|
{ allowUiBusy: false });
|
|
await checkpoint("stock-search-and-keyboard", {
|
|
resultCount: current.state.searchResults.length,
|
|
exactStockIndex: exactStock.index
|
|
});
|
|
|
|
const firstThreeCuts = current.state.cutRows.slice(0, 3);
|
|
if (firstThreeCuts.length !== 3 ||
|
|
firstThreeCuts.some((row, index) =>
|
|
row.physicalIndex !== index || row.isSeparator === true)) {
|
|
failKnown("The first three physical cuts are not the expected non-separator fixture.");
|
|
}
|
|
assertExactArray(
|
|
firstThreeCuts.map(row => row.rawLabel),
|
|
[
|
|
"1열판기본_예상체결가",
|
|
"1열판기본_현재가",
|
|
"1열판기본_시간외단일가"
|
|
],
|
|
"first three physical cut labels");
|
|
|
|
await pointerClick(
|
|
'document.querySelector("#cut-list .cut-row[data-physical-index=\'0\']")',
|
|
"select first cut");
|
|
current = await waitFor("first cut selection", snapshot =>
|
|
focusedCutIndex(snapshot) === 0 &&
|
|
JSON.stringify(selectedPhysicalIndices(snapshot)) === JSON.stringify([0]),
|
|
{ allowUiBusy: false });
|
|
await pressKey("ArrowDown", "move cut focus without changing selection", ctrlModifier);
|
|
current = await waitFor("Ctrl ArrowDown cut focus", snapshot =>
|
|
focusedCutIndex(snapshot) === 1 &&
|
|
JSON.stringify(selectedPhysicalIndices(snapshot)) === JSON.stringify([0]),
|
|
{ allowUiBusy: false });
|
|
await pressKey("Space", "select focused cut with Space");
|
|
current = await waitFor("Space cut selection", snapshot =>
|
|
focusedCutIndex(snapshot) === 1 &&
|
|
JSON.stringify(selectedPhysicalIndices(snapshot)) === JSON.stringify([1]),
|
|
{ allowUiBusy: false });
|
|
await pressKey("ArrowDown", "extend cut selection with Shift ArrowDown", shiftModifier);
|
|
current = await waitFor("Shift ArrowDown cut range", snapshot =>
|
|
focusedCutIndex(snapshot) === 2 &&
|
|
JSON.stringify(selectedPhysicalIndices(snapshot)) === JSON.stringify([1, 2]),
|
|
{ allowUiBusy: false });
|
|
await checkpoint("cut-keyboard", {
|
|
selectedPhysicalIndices: selectedPhysicalIndices(current),
|
|
focusedPhysicalIndex: focusedCutIndex(current)
|
|
});
|
|
|
|
current = await dragCutsToPlaylist(
|
|
2,
|
|
ctrlModifier | shiftModifier,
|
|
2,
|
|
"multi-cut captured-pointer drag");
|
|
assertExactArray(
|
|
current.state.playlist.map(row => row.stockName),
|
|
[evidence.fixture.stockDisplayName, evidence.fixture.stockDisplayName],
|
|
"two-cut playlist stock order");
|
|
assertExactArray(
|
|
current.state.playlist.map(row => `${row.graphicType}|${row.subtype}`),
|
|
["1열판기본|현재가", "1열판기본|시간외단일가"],
|
|
"two-cut playlist mapping");
|
|
await checkpoint("multi-cut-drag", {
|
|
playlistRowIds: current.state.playlist.map(row => row.rowId)
|
|
});
|
|
|
|
current = await dragCutsToPlaylist(
|
|
0,
|
|
0,
|
|
1,
|
|
"single-cut captured-pointer drag");
|
|
if (current.state.playlist.length !== 3) {
|
|
failKnown("The two cut drags did not create exactly three in-memory rows.");
|
|
}
|
|
assertExactArray(
|
|
current.state.playlist.map(row => `${row.graphicType}|${row.subtype}`),
|
|
[
|
|
"1열판기본|현재가",
|
|
"1열판기본|시간외단일가",
|
|
"1열판기본|예상체결가"
|
|
],
|
|
"three-cut playlist mapping");
|
|
assertExactArray(
|
|
current.dom.playlist.map(row => row.rowHeaderText),
|
|
["1", "2", "3"],
|
|
"playlist row-header ordinals");
|
|
if (current.dom.playlist.some(row =>
|
|
!Number.isFinite(row.rowHeaderWidth) ||
|
|
Math.abs(row.rowHeaderWidth - 30) > 0.5 ||
|
|
row.rowHeaderDragEnabled !== true)) {
|
|
failKnown("Playlist row-header geometry or drag availability differs from FarPoint.");
|
|
}
|
|
await checkpoint("single-cut-drag", {
|
|
playlistRowIds: current.state.playlist.map(row => row.rowId)
|
|
});
|
|
|
|
const [rowA, rowB, rowC] = current.state.playlist.map(row => row.rowId);
|
|
await pointerClickPlaylistRow(rowA, "select playlist row A");
|
|
current = await waitFor("playlist row A selection", snapshot =>
|
|
activePlaylistId(snapshot) === rowA &&
|
|
JSON.stringify(selectedPlaylistIds(snapshot)) === JSON.stringify([rowA]),
|
|
{ allowUiBusy: false });
|
|
await pointerClickPlaylistRow(rowB, "Ctrl-select playlist row B", ctrlModifier);
|
|
current = await waitFor("playlist A+B selection", snapshot =>
|
|
activePlaylistId(snapshot) === rowB &&
|
|
JSON.stringify(selectedPlaylistIds(snapshot)) === JSON.stringify([rowA, rowB]),
|
|
{ allowUiBusy: false });
|
|
|
|
await dragPlaylistRow(rowA, rowC, "single-source playlist row drag");
|
|
current = await waitFor("single-source playlist reorder", snapshot =>
|
|
JSON.stringify(snapshot.state.playlist.map(row => row.rowId)) ===
|
|
JSON.stringify([rowB, rowC, rowA]),
|
|
{ allowUiBusy: false });
|
|
assertExactArray(selectedPlaylistIds(current), [rowA], "post-drag deletion selection");
|
|
if (activePlaylistId(current) !== rowA ||
|
|
current.dom.activeElement?.playlistRowId !== rowA) {
|
|
failKnown("Playlist drag did not retain the source as active and focused.");
|
|
}
|
|
await checkpoint("playlist-single-source-drag", {
|
|
beforeOrder: [rowA, rowB, rowC],
|
|
afterOrder: [rowB, rowC, rowA]
|
|
});
|
|
assertExactArray(
|
|
current.dom.playlist.map(row => row.rowHeaderText),
|
|
["1", "2", "3"],
|
|
"reordered playlist row-header ordinals");
|
|
|
|
const orderBeforeCheckboxAttempt = current.state.playlist.map(row => row.rowId);
|
|
const enabledBeforeCheckboxAttempt = current.state.playlist.map(row => ({
|
|
rowId: row.rowId,
|
|
enabled: row.isEnabled
|
|
}));
|
|
await attemptCheckboxOriginDrag(rowB, rowA, "checkbox-origin drag rejection");
|
|
current = await waitFor("checkbox-origin drag rejection", snapshot =>
|
|
snapshot.state.isBusy === false && activePlaylistId(snapshot) === rowB,
|
|
{ allowUiBusy: false });
|
|
assertExactArray(
|
|
current.state.playlist.map(row => row.rowId),
|
|
orderBeforeCheckboxAttempt,
|
|
"checkbox-origin playlist order");
|
|
if (JSON.stringify(current.state.playlist.map(row => ({
|
|
rowId: row.rowId,
|
|
enabled: row.isEnabled
|
|
}))) !== JSON.stringify(enabledBeforeCheckboxAttempt)) {
|
|
failKnown("Checkbox-origin drag changed a row's enabled state.");
|
|
}
|
|
assertExactArray(selectedPlaylistIds(current), [rowA], "checkbox-origin deletion selection");
|
|
if (current.dom.playlist.some(row => row.dragging || row.dragBefore || row.dragAfter)) {
|
|
failKnown("Checkbox-origin drag left playlist drag visuals active.");
|
|
}
|
|
await checkpoint("playlist-checkbox-drag-rejected");
|
|
|
|
const focusBeforeBoundary = current.dom.activeElement;
|
|
if (focusBeforeBoundary?.playlistRowId !== rowB) {
|
|
failKnown("Checkbox-origin pointer did not leave focus inside source row B.");
|
|
}
|
|
await pressKey("End", "select last playlist boundary without moving focus");
|
|
current = await waitFor("playlist End boundary", snapshot =>
|
|
activePlaylistId(snapshot) === rowA,
|
|
{ allowUiBusy: false });
|
|
assertExactArray(selectedPlaylistIds(current), [rowA], "End deletion selection");
|
|
if (JSON.stringify(current.dom.activeElement) !== JSON.stringify(focusBeforeBoundary)) {
|
|
failKnown("End moved DOM focus instead of only changing the F8 candidate.");
|
|
}
|
|
await pressKey("Home", "select first playlist boundary without moving focus");
|
|
current = await waitFor("playlist Home boundary", snapshot =>
|
|
activePlaylistId(snapshot) === rowB,
|
|
{ allowUiBusy: false });
|
|
assertExactArray(selectedPlaylistIds(current), [rowA], "Home deletion selection");
|
|
if (JSON.stringify(current.dom.activeElement) !== JSON.stringify(focusBeforeBoundary)) {
|
|
failKnown("Home moved DOM focus instead of only changing the F8 candidate.");
|
|
}
|
|
await checkpoint("playlist-keyboard-focus-split");
|
|
|
|
current = await requestWindowsInput(
|
|
current,
|
|
rowB,
|
|
rowA,
|
|
rowC,
|
|
"Windows SendInput row-header drag and Home key");
|
|
|
|
await pointerClick(
|
|
'document.getElementById("db-load-button")',
|
|
"open DB playlist load modal");
|
|
current = await waitFor("read-only named playlist load modal", snapshot =>
|
|
snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === false &&
|
|
snapshot.dom.namedConfirmationHidden === true &&
|
|
snapshot.dom.activeInsideVisibleModal === true,
|
|
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
|
|
if (current.dom.namedTitle !== "DB 재생목록 불러오기") {
|
|
failKnown(`Unexpected load-modal title ${current.dom.namedTitle}.`);
|
|
}
|
|
await pressKey("Escape", "child load modal Escape no-op");
|
|
await sleep(100);
|
|
current = await readSnapshot();
|
|
assertSafety(current, "load modal Escape", false);
|
|
if (current.dom.namedModalHidden || !current.dom.namedConfirmationHidden ||
|
|
!current.dom.activeInsideVisibleModal) {
|
|
failKnown("Escape closed the read-only named-playlist child modal.");
|
|
}
|
|
|
|
const focusableCount = current.dom.modalFocusableIds.length;
|
|
if (focusableCount < 2) failKnown("The named-playlist modal has fewer than two focusable controls.");
|
|
for (let index = 0; index <= focusableCount; index += 1) {
|
|
await pressKey("Tab", `load modal Shift+Tab cycle ${index + 1}`, shiftModifier);
|
|
const tabSnapshot = await readSnapshot();
|
|
assertSafety(tabSnapshot, `load modal Shift+Tab cycle ${index + 1}`, false);
|
|
if (!tabSnapshot.dom.activeInsideVisibleModal) {
|
|
failKnown("Shift+Tab escaped the named-playlist modal focus trap.");
|
|
}
|
|
}
|
|
await pointerClick(
|
|
'document.getElementById("named-playlist-cancel-button")',
|
|
"close load modal with Cancel");
|
|
current = await waitFor("load modal close and focus restore", snapshot =>
|
|
snapshot.dom.namedModalHidden === true &&
|
|
snapshot.dom.activeElement?.id === "db-load-button",
|
|
{ allowUiBusy: false });
|
|
await checkpoint("named-playlist-load-modal-focus", {
|
|
definitionCount: current.state.namedPlaylist?.definitions?.length ?? 0,
|
|
focusableCount
|
|
});
|
|
|
|
await pointerClick(
|
|
'document.getElementById("db-save-button")',
|
|
"open DB playlist save modal");
|
|
current = await waitFor("read-only named playlist save modal", snapshot =>
|
|
snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === false &&
|
|
snapshot.dom.namedConfirmationHidden === true &&
|
|
snapshot.dom.activeInsideVisibleModal === true,
|
|
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
|
|
if (current.dom.namedTitle !== "DB 재생목록 저장") {
|
|
failKnown(`Unexpected save-modal title ${current.dom.namedTitle}.`);
|
|
}
|
|
|
|
if (current.dom.namedConfirmDisabled) {
|
|
evidence.warnings.push(
|
|
"Nested save confirmation was skipped because no existing definition was safely selectable.");
|
|
await pointerClick(
|
|
'document.getElementById("named-playlist-cancel-button")',
|
|
"close save modal without an existing definition");
|
|
current = await waitFor("save modal fixture skip close", snapshot =>
|
|
snapshot.dom.namedModalHidden === true &&
|
|
snapshot.dom.activeElement?.id === "db-save-button",
|
|
{ allowUiBusy: false });
|
|
} else {
|
|
const namedBeforeConfirmation = {
|
|
revision: current.state.namedPlaylist?.revision ?? null,
|
|
lastMutationOutcome: current.state.namedPlaylist?.lastMutationOutcome ?? null,
|
|
isWriteInProgress: current.state.namedPlaylist?.isWriteInProgress ?? null
|
|
};
|
|
await pointerClick(
|
|
'document.getElementById("named-playlist-confirm-button")',
|
|
"open save confirmation without confirming write");
|
|
current = await waitFor("nested save confirmation", snapshot =>
|
|
snapshot.dom.namedModalHidden === false &&
|
|
snapshot.dom.namedConfirmationHidden === false &&
|
|
snapshot.dom.namedConfirmationMessage === "저장하겠습니까?" &&
|
|
snapshot.dom.activeElement?.id === "named-playlist-confirmation-yes",
|
|
{ allowUiBusy: false });
|
|
await pressKey("Escape", "nested save confirmation Escape no-op");
|
|
await sleep(100);
|
|
current = await readSnapshot();
|
|
assertSafety(current, "nested confirmation Escape", false);
|
|
if (current.dom.namedConfirmationHidden ||
|
|
current.dom.activeElement?.id !== "named-playlist-confirmation-yes") {
|
|
failKnown("Escape changed the nested save confirmation.");
|
|
}
|
|
await pressKey("Tab", "move nested confirmation focus to No");
|
|
current = await readSnapshot();
|
|
if (current.dom.activeElement?.id !== "named-playlist-confirmation-no") {
|
|
failKnown("Tab did not move nested confirmation focus from Yes to No.");
|
|
}
|
|
await pressKey("Tab", "wrap nested confirmation focus to Yes");
|
|
current = await readSnapshot();
|
|
if (current.dom.activeElement?.id !== "named-playlist-confirmation-yes") {
|
|
failKnown("Tab did not wrap nested confirmation focus back to Yes.");
|
|
}
|
|
await pointerClick(
|
|
'document.getElementById("named-playlist-confirmation-no")',
|
|
"decline save confirmation");
|
|
current = await waitFor("declined save confirmation close", snapshot =>
|
|
snapshot.dom.namedModalHidden === true &&
|
|
snapshot.dom.namedConfirmationHidden === true &&
|
|
snapshot.dom.activeElement?.id === "db-save-button",
|
|
{ allowUiBusy: false });
|
|
const namedAfterConfirmation = {
|
|
revision: current.state.namedPlaylist?.revision ?? null,
|
|
lastMutationOutcome: current.state.namedPlaylist?.lastMutationOutcome ?? null,
|
|
isWriteInProgress: current.state.namedPlaylist?.isWriteInProgress ?? null
|
|
};
|
|
if (JSON.stringify(namedAfterConfirmation) !==
|
|
JSON.stringify(namedBeforeConfirmation)) {
|
|
failKnown("Declining the save confirmation changed named-playlist mutation state.");
|
|
}
|
|
}
|
|
await checkpoint("named-playlist-save-confirmation-no-write");
|
|
|
|
current = await checkpoint("final", {
|
|
expectedPlaylistOrder: [rowC, rowA, rowB]
|
|
});
|
|
assertExactArray(
|
|
current.state.playlist.map(row => row.rowId),
|
|
[rowC, rowA, rowB],
|
|
"final playlist order");
|
|
assertExactArray(selectedPlaylistIds(current), [rowB], "final deletion selection");
|
|
if (!current.dom.namedModalHidden || !current.dom.namedConfirmationHidden) {
|
|
failKnown("A named-playlist modal remained open at final state.");
|
|
}
|
|
evidence.finalSnapshot = current;
|
|
}
|
|
|
|
try {
|
|
const target = await discoverExactTarget();
|
|
const endpoint = validateWebSocketTarget(target);
|
|
evidence.target.id = target.id;
|
|
evidence.target.url = target.url;
|
|
await connect(endpoint);
|
|
await executeSafeSequence();
|
|
await captureScreenshot();
|
|
const sealedSnapshot = await readSnapshot();
|
|
assertSafety(sealedSnapshot, "post-screenshot safety seal", false);
|
|
evidence.finalSnapshot = sealedSnapshot;
|
|
evidence.result = evidence.warnings.length === 0 ? "PASS" : "PASS_WITH_WARNING";
|
|
} catch (error) {
|
|
evidence.result = "FAIL";
|
|
evidence.error = errorProjection(error);
|
|
process.exitCode = 1;
|
|
|
|
if (socket?.readyState === WebSocket.OPEN) {
|
|
try {
|
|
evidence.finalSnapshot = await readSnapshot();
|
|
} catch (snapshotError) {
|
|
evidence.warnings.push(`Failure snapshot unavailable: ${snapshotError.message}`);
|
|
}
|
|
try {
|
|
await captureScreenshot();
|
|
} catch (screenshotError) {
|
|
evidence.warnings.push(`Failure screenshot unavailable: ${screenshotError.message}`);
|
|
}
|
|
}
|
|
} finally {
|
|
cleanupMode = true;
|
|
cleanupDeadlineEpochMilliseconds = Date.now() + 5_000;
|
|
const cleanupFailures = [];
|
|
if (pointerIsPressed && socket?.readyState === WebSocket.OPEN) {
|
|
try {
|
|
await releaseMouse(pointerReleasePoint || { x: 1, y: 1 }, pointerReleaseModifiers);
|
|
} catch (releaseError) {
|
|
evidence.warnings.push(`Pointer release cleanup failed: ${releaseError.message}`);
|
|
cleanupFailures.push(`pointer release: ${releaseError.message}`);
|
|
}
|
|
}
|
|
if (socket?.readyState === WebSocket.OPEN) {
|
|
try {
|
|
await removeProbe();
|
|
} catch (probeError) {
|
|
evidence.warnings.push(`Probe cleanup failed: ${probeError.message}`);
|
|
cleanupFailures.push(`probe restore: ${probeError.message}`);
|
|
}
|
|
socket.close();
|
|
} else if (probeInstalled) {
|
|
evidence.warnings.push(
|
|
"Probe cleanup failed: the CDP socket was closed before bridge restoration.");
|
|
cleanupFailures.push("probe restore: CDP socket closed");
|
|
}
|
|
if (cleanupFailures.length > 0 && evidence.result !== "FAIL") {
|
|
evidence.result = "FAIL";
|
|
evidence.error = {
|
|
category: "OUTCOME_UNKNOWN",
|
|
name: "CleanupFailure",
|
|
message: "The one-shot input cleanup did not complete."
|
|
};
|
|
process.exitCode = 1;
|
|
}
|
|
evidence.completedAt = new Date().toISOString();
|
|
fs.writeFileSync(
|
|
configuration.outputPath,
|
|
`${JSON.stringify(evidence, null, 2)}\n`,
|
|
{ flag: "wx" });
|
|
}
|
|
|
|
if (evidence.result === "FAIL") {
|
|
console.error(JSON.stringify({
|
|
result: evidence.result,
|
|
output: configuration.outputPath,
|
|
screenshot: evidence.screenshot?.path || null,
|
|
error: evidence.error
|
|
}));
|
|
} else {
|
|
console.log(JSON.stringify({
|
|
result: evidence.result,
|
|
output: configuration.outputPath,
|
|
screenshot: evidence.screenshot
|
|
}));
|
|
}
|