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 = 900_000; const acceptedArgumentNames = new Set([ "profile", "scope", "port", "output", "screenshot", "windows-input-request", "windows-input-ack", "timeout-ms" ]); const readOnlyForbiddenControlIds = 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 readOnlyAllowedOutboundMessageTypes = 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 fullUiDbAllowedOutboundMessageTypes = Object.freeze([ ...readOnlyAllowedOutboundMessageTypes, "select-tab", "activate-fixed-action", "activate-fixed-section", "select-industry", "double-click-industry", "swap-industries", "set-industry-comparison-text", "activate-industry-action", "activate-industry-section", "search-overseas-industries", "search-overseas-stocks", "select-overseas-fixed-index", "select-overseas-industry", "select-overseas-stock", "activate-overseas-action", "search-comparison-domestic", "search-comparison-world", "select-comparison-domestic", "select-comparison-world", "select-comparison-fixed", "choose-comparison-domestic", "choose-comparison-world", "choose-comparison-fixed", "swap-comparison-targets", "clear-comparison-targets", "add-comparison-pair", "select-comparison-pair", "move-comparison-pair", "delete-selected-comparison-pair", "delete-all-comparison-pairs", "activate-comparison-action", "activate-comparison-section", "search-themes", "select-theme", "activate-theme-result", "set-theme-sort", "activate-theme-action", "begin-uc4-theme-catalog", "edit-uc4-selected-theme", "delete-uc4-selected-theme", "search-experts", "select-expert", "activate-expert-action", "begin-uc6-expert-catalog", "edit-uc6-selected-expert", "delete-uc6-selected-expert", "search-trading-halts", "select-trading-halt", "toggle-trading-halt-sort", "activate-trading-halt-action", "open-manual-financial", "close-manual-financial", "search-manual-financial", "find-manual-financial", "toggle-manual-financial-name-sort", "load-manual-financial", "activate-manual-financial-result", "begin-manual-financial-create", "search-manual-financial-stocks", "select-manual-financial-stock", "save-manual-financial", "delete-manual-financial", "activate-manual-financial-action", "open-manual-list", "close-manual-list", "refresh-manual-list", "set-manual-net-sell-cell", "search-manual-vi", "select-manual-vi-result", "add-manual-vi-result", "move-manual-vi-item", "delete-manual-vi-item", "delete-all-manual-vi-items", "confirm-manual-list", "open-operator-catalog", "close-operator-catalog", "search-operator-catalog", "select-operator-catalog-result", "begin-create-theme-catalog", "begin-create-expert-catalog", "search-operator-catalog-stocks", "select-operator-catalog-stock", "add-operator-catalog-stock", "activate-operator-catalog-stock", "move-operator-catalog-draft-row", "reorder-operator-catalog-draft-row", "remove-operator-catalog-draft-row", "select-operator-catalog-draft-row", "remove-active-operator-catalog-draft-row", "clear-operator-catalog-draft-rows", "set-operator-catalog-draft-buy-amount", "check-operator-catalog-theme-name", "save-operator-catalog", "confirm-native-dialog", "cancel-native-dialog", "dismiss-dialog", "select-named-playlist", "load-named-playlist-by-id", "create-named-playlist", "save-current-named-playlist-to", "delete-selected-named-playlist", "move-selected-playlist-rows", "delete-selected-playlist-rows", "delete-all-playlist-rows", "set-all-playlist-enabled", "set-moving-averages" ]); 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", "add-comparison-pair", "move-comparison-pair", "delete-selected-comparison-pair", "delete-all-comparison-pairs", "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 " + "--output --screenshot " + "--windows-input-request " + "--windows-input-ack " + "[--profile read-only|dry-run-playout|full-ui-db] [--timeout-ms ]"); } 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 }); const profile = values.get("profile") || "read-only"; if (!new Set(["read-only", "dry-run-playout", "full-ui-db"]).has(profile)) { failKnown("--profile must be read-only, dry-run-playout, or full-ui-db."); } const scope = values.get("scope") || "all"; if (!new Set(["all", "plist", "graphe", "catalog", "screens"]).has(scope)) { failKnown("--scope must be all, plist, graphe, catalog, or screens."); } if (profile !== "full-ui-db" && scope !== "all") { failKnown("A non-DB-write run requires --scope all."); } return { profile, scope, 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 fullUiDbProfile = configuration.profile === "full-ui-db"; const dryRunPlayoutProfile = configuration.profile === "dry-run-playout"; const forbiddenControlIds = fullUiDbProfile ? Object.freeze([ "playout-background-file", "playout-prepare", "playout-take-in", "playout-next", "playout-take-out" ]) : dryRunPlayoutProfile ? Object.freeze(readOnlyForbiddenControlIds.filter(id => !new Set(["playout-take-in", "playout-next", "playout-take-out"]).has(id))) : readOnlyForbiddenControlIds; const allowedOutboundMessageTypes = fullUiDbProfile ? fullUiDbAllowedOutboundMessageTypes : dryRunPlayoutProfile ? Object.freeze([ ...readOnlyAllowedOutboundMessageTypes, "take-in", "next-playout", "take-out" ]) : readOnlyAllowedOutboundMessageTypes; const hardDeadlineEpochMilliseconds = Date.now() + configuration.timeoutMilliseconds; const startedAt = new Date().toISOString(); const evidence = { schemaVersion: 1, profile: configuration.profile, scope: configuration.scope, 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, approvedDatabaseWriteMessages: [], observedOutboundMessageTypes: [], blockedOutboundMessages: [], stateViolations: [], externalCancellationRequested: false }, fixture: { query: "삼성", stockDisplayName: "삼성출판사", databaseStockDisplayName: "1Q 삼성전자선물단일종목레버리지", namedPlaylistTitle: fullUiDbProfile ? `CDX_P_${Date.now()}_${randomBytes(3).toString("hex")}` : null, krxThemeTitle: fullUiDbProfile ? `CDX_K_${Date.now()}_${randomBytes(3).toString("hex")}` : null, nxtThemeTitle: fullUiDbProfile ? `CDX_N_${Date.now()}_${randomBytes(3).toString("hex")}` : null, expertTitle: fullUiDbProfile ? `CDX_E_${Date.now()}_${randomBytes(3).toString("hex")}` : null }, fullUiDb: fullUiDbProfile ? { result: "PENDING", screenChecks: [], databaseChecks: [], screenInventory: [], cleanupVerified: false } : null, dryRunPlayout: dryRunPlayoutProfile ? { result: "PENDING", stagedEntryId: null, selectedEntryId: null, nextEntryId: null, programCutSelection: null, playlistCountBeforeSelection: null, playlistCountAfterDoubleClick: null } : null, 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; let expectedJavaScriptDialog = null; const unexpectedJavaScriptDialogs = []; 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 (message.method === "Page.javascriptDialogOpening") { const dialog = message.params || {}; const expected = expectedJavaScriptDialog; if (!expected || dialog.type !== expected.type || (expected.message && dialog.message !== expected.message)) { unexpectedJavaScriptDialogs.push({ type: dialog.type || null, message: dialog.message || null, at: new Date().toISOString() }); return; } expectedJavaScriptDialog = null; void rpc("Page.handleJavaScriptDialog", { accept: expected.accept === true }).then(expected.resolve, expected.reject); return; } 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 cutDragFeedback = document.getElementById("cut-drag-feedback"); const cutDragFeedbackBounds = cutDragFeedback?.getBoundingClientRect() || 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 })), approvedDatabaseWriteMessages: (probe?.approvedDatabaseWriteMessages || []).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, tabs: (state.tabs || []).map(tab => ({ ...tab })), movingAverages: state.movingAverages ? { ...state.movingAverages } : null, fixedCatalog: state.fixedCatalog ? structuredClone(state.fixedCatalog) : null, industry: state.industry ? structuredClone(state.industry) : null, overseas: state.overseas ? structuredClone(state.overseas) : null, comparison: state.comparison ? structuredClone(state.comparison) : null, theme: state.theme ? structuredClone(state.theme) : null, expert: state.expert ? structuredClone(state.expert) : null, tradingHalt: state.tradingHalt ? structuredClone(state.tradingHalt) : null, manualFinancial: state.manualFinancial ? structuredClone(state.manualFinancial) : null, manualLists: state.manualLists ? structuredClone(state.manualLists) : null, operatorCatalog: state.operatorCatalog ? structuredClone(state.operatorCatalog) : null, fixedSectionBatch: state.fixedSectionBatch ? structuredClone(state.fixedSectionBatch) : null, 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, currentCueIndexZeroBased: state.playout.currentCueIndexZeroBased, pageIndexZeroBased: state.playout.pageIndexZeroBased, pageCount: state.playout.pageCount, nextKind: state.playout.nextKind, 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, cutDragFeedback: cutDragFeedback ? { hidden: cutDragFeedback.hidden === true, dropAllowed: cutDragFeedback.dataset.dropAllowed || null, left: cutDragFeedbackBounds?.left ?? null, top: cutDragFeedbackBounds?.top ?? null, width: cutDragFeedbackBounds?.width ?? null, height: cutDragFeedbackBounds?.height ?? null } : null, 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, manualFinancialModalHidden: document.getElementById("manual-financial-modal")?.hidden === true, manualListModalHidden: document.getElementById("manual-list-modal")?.hidden === true, operatorCatalogModalHidden: document.getElementById("operator-catalog-modal")?.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, allowLegacyDialog = false, allowActiveDryRun = false) { 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}.`); } if (unexpectedJavaScriptDialogs.length > 0 || expectedJavaScriptDialog) { failUnknown(`An unexpected or unresolved JavaScript dialog exists at ${label}.`); } const outboundMessages = snapshot.safety.outboundMessages || []; const approvedDatabaseWriteMessages = snapshot.safety.approvedDatabaseWriteMessages || []; const blockedOutboundMessages = snapshot.safety.blockedOutboundMessages || []; const stateViolations = snapshot.safety.stateViolations || []; evidence.safety.observedOutboundMessageTypes = outboundMessages .map(message => message.type); evidence.safety.approvedDatabaseWriteMessages = approvedDatabaseWriteMessages .map(message => ({ ...message })); evidence.safety.blockedOutboundMessages = blockedOutboundMessages .map(message => ({ ...message })); evidence.safety.stateViolations = stateViolations.map(violation => ({ ...violation, reasons: [...violation.reasons] })); evidence.safety.playoutIntentIssued = outboundMessages.some(message => playoutIntentMessageTypes.includes(message.type)) || blockedOutboundMessages.some(message => message.category === "playout"); evidence.safety.databaseWriteIntentIssued = approvedDatabaseWriteMessages.length > 0 || 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 (!allowActiveDryRun && (playout.phase !== "idle" || playout.preparedCode != null || playout.onAirCode != null)) { failKnown(`Playout left IDLE at ${label}.`); } if (playout.outcomeUnknown === true || (!allowActiveDryRun && playout.isPlayCompletionPending === true) || (!allowActiveDryRun && playout.isTakeOutCompletionPending === true) || (!allowActiveDryRun && playout.isBusy === true) || (!allowActiveDryRun && 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 && !allowLegacyDialog) { failKnown(`A legacy alert is visible at ${label}: ${snapshot.dom.legacyDialogText}`); } } async function waitFor(label, predicate, options = {}) { const timeoutMilliseconds = options.timeoutMilliseconds || Math.min(configuration.timeoutMilliseconds, 60_000); 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, options.allowLegacyDialog === true, options.allowActiveDryRun === true); } 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; element.scrollIntoView({ block: "nearest", inline: "nearest" }); 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 waitForPointerReady( label, allowLegacyDialog = false, allowActiveDryRun = false) { await waitFor(`${label} pointer readiness`, snapshot => snapshot.state?.isBusy === false && snapshot.dom.bodyBusy === "false", { allowUiBusy: true, allowLegacyDialog, allowActiveDryRun }); } async function pointerClick(expression, label, modifiers = 0, options = {}) { if (options.skipPointerReady !== true) { await waitForPointerReady( label, options.allowLegacyDialog === true, options.allowActiveDryRun === true); } 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 }, Enter: { key: "Enter", code: "Enter", virtualKey: 13 }, 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 }); } async function replaceText(expression, text, label) { await pointerClick(expression, `focus ${label}`); const focused = await evaluate(`document.activeElement === (${expression})`); if (focused !== true) failKnown(`${label} did not receive pointer focus.`); await pressKey("SelectAll", `select existing ${label}`, ctrlModifier); await pressKey("Backspace", `clear existing ${label}`); evidence.inputs.push({ type: "text", label, text }); await rpc("Input.insertText", { text }); const actual = await evaluate(`(${expression})?.value ?? null`); if (actual !== text) { failKnown(`${label} did not retain the exact typed value.`); } } async function selectOptionValue(expression, value, label) { await pointerClick(expression, `focus ${label}`); const selection = await evaluate(`(() => { const select = ${expression}; if (!(select instanceof HTMLSelectElement)) return null; const targetIndex = [...select.options].findIndex(option => option.value === ${JSON.stringify(value)}); return { targetIndex, currentValue: select.value }; })()`); if (!selection || selection.targetIndex < 0) { failKnown(`${label} does not contain the requested option.`); } await pressKey("Home", `${label} first option`); for (let index = 0; index < selection.targetIndex; index++) { await pressKey("ArrowDown", `${label} next option ${index + 1}`); } await pressKey("Enter", `${label} commit option`); const actual = await evaluate(`(${expression})?.value ?? null`); if (actual !== value) failKnown(`${label} did not select the requested value.`); } async function respondToNativeDialog(label, confirmation, accept) { const opened = await waitFor(`${label} dialog`, snapshot => snapshot.state?.dialog?.isConfirmation === confirmation && snapshot.dom.legacyDialogHidden === false, { timeoutMilliseconds: 30_000, allowUiBusy: true, allowLegacyDialog: true }); if (confirmation && accept !== true && accept !== false) { failKnown(`${label} confirmation response is not explicit.`); } await pointerClick( confirmation && accept === false ? 'document.getElementById("legacy-dialog-no")' : 'document.getElementById("legacy-dialog-ok")', `${label} ${confirmation ? (accept ? "yes" : "no") : "ok"}`, 0, { allowLegacyDialog: true }); await waitFor(`${label} dialog close`, snapshot => snapshot.state?.dialog == null && snapshot.dom.legacyDialogHidden === true, { timeoutMilliseconds: 60_000, allowUiBusy: true, allowLegacyDialog: true }); return opened; } async function pointerDoubleClick(expression, label, modifiers = 0) { await waitForPointerReady(label); const point = await elementGeometry(expression, label); evidence.inputs.push({ type: "pointer-double-click", label, modifiers, point }); await moveMouse(point, modifiers, 0); for (const clickCount of [1, 2]) { await rpc("Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", buttons: 1, clickCount, modifiers, pointerType: "mouse" }); await rpc("Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount, modifiers, pointerType: "mouse" }); if (clickCount === 1) await sleep(50); } } async function pointerClickWithJavaScriptDialog( expression, label, type, message, accept) { if (expectedJavaScriptDialog) { failUnknown(`Another JavaScript dialog was pending before ${label}.`); } await waitForPointerReady(label); let resolveDialog; let rejectDialog; const handled = new Promise((resolve, reject) => { resolveDialog = resolve; rejectDialog = reject; }); expectedJavaScriptDialog = { type, message, accept: accept === true, resolve: resolveDialog, reject: rejectDialog }; try { await pointerClick(expression, label, 0, { skipPointerReady: true }); await Promise.race([ handled, new Promise((_, reject) => setTimeout( () => reject(new HarnessFailure( "OUTCOME_UNKNOWN", `Timed out waiting for the JavaScript dialog at ${label}.`)), 10_000)) ]); } catch (error) { if (expectedJavaScriptDialog) expectedJavaScriptDialog = null; throw error; } } 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 || beforeThreshold.dom.cutDragFeedback?.hidden !== true) { 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, feedback: thresholdSnapshot.dom.cutDragFeedback, 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.`); } const thresholdFeedback = thresholdSnapshot.dom.cutDragFeedback; if (!thresholdFeedback || thresholdFeedback.hidden || thresholdFeedback.dropAllowed !== "false" || !Number.isFinite(thresholdFeedback.left) || !Number.isFinite(thresholdFeedback.top) || Math.abs(thresholdFeedback.left - threshold.x) > 280 || Math.abs(thresholdFeedback.top - threshold.y) > 80) { failKnown(`${label} did not show blocked drag feedback beside the real pointer.`); } 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, feedback: sample.dom.cutDragFeedback, captured: sample.dom.cutPointerCapture?.physicalIndices ?.includes(physicalIndex) === true, pointerEvents: sample.pointerEvents.slice(-12) }; dragInput.targetSamples.push(sampleEvidence); if (sampleEvidence.dragging && sampleEvidence.highlighted && sampleEvidence.captured && sampleEvidence.feedback?.hidden === false && sampleEvidence.feedback?.dropAllowed === "true") { 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 || completed.dom.cutDragFeedback?.hidden !== true) { 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: [], approvedDatabaseWriteMessages: [], blockedOutboundMessages: [], stateViolations: [] }; const allowActiveDryRun = ${JSON.stringify(dryRunPlayoutProfile)}; 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 (!allowActiveDryRun && playout.phase !== "idle") reasons.push("phase"); if (!allowActiveDryRun && playout.preparedCode != null) reasons.push("prepared-code"); if (!allowActiveDryRun && playout.onAirCode != null) reasons.push("on-air-code"); if (playout.isConnected === true) reasons.push("connected"); if (playout.outcomeUnknown === true) reasons.push("outcome-unknown"); if (!allowActiveDryRun && playout.isPlayCompletionPending === true) { reasons.push("play-pending"); } if (!allowActiveDryRun && playout.isTakeOutCompletionPending === true) { reasons.push("take-out-pending"); } if (!allowActiveDryRun && playout.isBusy === true) reasons.push("playout-busy"); if (!allowActiveDryRun && 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(), allowAppend: type === "cut-pointer-down" ? message?.payload?.allowAppend ?? null : null }; 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; } if (databaseWriteTypes.has(type)) { probe.approvedDatabaseWriteMessages.push(record); } 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( 1, 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; } async function executeDryRunPlayoutSequence() { if (!dryRunPlayoutProfile) return; let current = await readSnapshot(); assertSafety(current, "DryRun playout sequence preflight", false); if (current.state.playlist.length < 3 || current.state.playlist.some(row => row.isEnabled !== true)) { failKnown("DryRun NEXT validation requires three enabled in-memory rows."); } const originalSelectedIds = selectedPlaylistIds(current); const originalActiveId = activePlaylistId(current); if (originalSelectedIds.length !== 1 || !originalActiveId) { failKnown("DryRun playout preflight lacks the exact legacy selection split."); } // The preceding interaction sequence deliberately leaves two distinct current- // quote rows adjacent. After-hours/expected quote tables are session-dependent; // the original application also rejects those rows when no exact quote exists. // Use the two current rows so this check isolates NEXT row semantics from market // session data availability. const stagedEntryId = current.state.playlist[2].rowId; const selectedEntryId = current.state.playlist[0].rowId; const nextEntryId = current.state.playlist[1].rowId; const playlistCount = current.state.playlist.length; evidence.dryRunPlayout.stagedEntryId = stagedEntryId; evidence.dryRunPlayout.selectedEntryId = selectedEntryId; evidence.dryRunPlayout.nextEntryId = nextEntryId; evidence.dryRunPlayout.playlistCountBeforeSelection = playlistCount; // Move the F8 candidate away from the target first. If the following rapid row // click were dropped, TAKE IN would remain on this distinct staged row and the // selected-row assertion below would fail deterministically. await pointerClickPlaylistRow( stagedEntryId, "stage alternate playlist row before rapid DryRun TAKE IN"); current = await waitFor("alternate DryRun row staging", snapshot => activePlaylistId(snapshot) === stagedEntryId && JSON.stringify(selectedPlaylistIds(snapshot)) === JSON.stringify([stagedEntryId]), { allowUiBusy: false }); // Keep the real user timing: release the row click and immediately press TAKE IN, // without waiting for the row-selection state receipt. The native ordered intent // gate must preserve this input order instead of silently dropping F8/TAKE IN. await pointerClickPlaylistRow( selectedEntryId, "rapid select playlist row before DryRun TAKE IN"); await pointerClick( 'document.getElementById("playout-take-in")', "rapid DryRun TAKE IN after row selection", 0, { skipPointerReady: true }); current = await waitFor("rapid selected-row DryRun TAKE IN", snapshot => snapshot.state.playout.phase === "program" && snapshot.state.playout.currentEntryId === selectedEntryId && snapshot.state.playout.isBusy !== true && snapshot.state.isBusy !== true, { allowUiBusy: true, allowActiveDryRun: true }); if (current.state.playout.nextKind !== "playlistNext") { failKnown("DryRun TAKE IN did not expose playlist NEXT from the selected row."); } const selectableCut = current.state.cutRows.find(row => row.isSeparator !== true && row.isSelected !== true); if (!selectableCut) { failKnown("No alternate cut exists for PROGRAM selection validation."); } const cutExpression = `document.querySelector('#cut-list .cut-row[data-physical-index="${selectableCut.physicalIndex}"]')`; await pointerClick( cutExpression, "select alternate left cut during PROGRAM", 0, { allowActiveDryRun: true }); current = await waitFor("PROGRAM left cut selection", snapshot => snapshot.state.playout.phase === "program" && snapshot.state.playlist.length === playlistCount && snapshot.state.cutRows.some(row => row.physicalIndex === selectableCut.physicalIndex && row.isSelected === true && row.isFocused === true), { allowUiBusy: false, allowActiveDryRun: true }); evidence.dryRunPlayout.programCutSelection = selectableCut.physicalIndex; await pointerClick( cutExpression, "PROGRAM protected double-click first press", 0, { allowActiveDryRun: true }); await pointerClick( cutExpression, "PROGRAM protected double-click second press", 0, { allowActiveDryRun: true }); await sleep(150); current = await readSnapshot(); assertSafety(current, "PROGRAM protected double-click", false, false, true); const recentProgramCutMessages = current.safety.outboundMessages .filter(message => message.type === "cut-pointer-down") .slice(-3); if (current.state.playlist.length !== playlistCount || recentProgramCutMessages.length !== 3 || recentProgramCutMessages.some(message => message.allowAppend !== false)) { failKnown("PROGRAM cut selection changed the playlist or retained append authority."); } evidence.dryRunPlayout.playlistCountAfterDoubleClick = current.state.playlist.length; await pointerClick( 'document.getElementById("playout-next")', "DryRun NEXT from selected TAKE IN row", 0, { allowActiveDryRun: true }); const revisionBeforeNext = current.state.revision; current = await waitFor("DryRun NEXT selected-row successor", snapshot => { if (snapshot.state.revision > revisionBeforeNext && snapshot.state.playout.isBusy !== true && snapshot.state.isBusy !== true && snapshot.state.statusKind === "error") { failKnown( `DryRun NEXT was safely rejected before playout: ${snapshot.state.statusMessage}`); } return snapshot.state.playout.phase === "program" && snapshot.state.playout.currentEntryId === nextEntryId && snapshot.state.playout.isBusy !== true && snapshot.state.isBusy !== true; }, { allowUiBusy: true, allowActiveDryRun: true }); await pointerClick( 'document.getElementById("playout-take-out")', "DryRun TAKE OUT after NEXT", 0, { allowActiveDryRun: true }); current = await waitFor("DryRun TAKE OUT IDLE", snapshot => snapshot.state.playout.phase === "idle" && snapshot.state.playout.currentEntryId == null && snapshot.state.playout.onAirCode == null && snapshot.state.playout.isBusy !== true && snapshot.state.isBusy !== true, { allowUiBusy: true, allowActiveDryRun: true }); await pointerClickPlaylistRow( originalSelectedIds[0], "restore deletion selection after DryRun playout"); await pointerClick(`(() => { const row = Array.from(document.querySelectorAll( "#playlist-rows .playlist-data-row")) .find(candidate => candidate.dataset.rowId === ${JSON.stringify(originalActiveId)}); return row?.querySelector(".playlist-row-header") || null; })()`, "restore active playlist row after DryRun playout"); current = await waitFor("restore playlist state after DryRun playout", snapshot => snapshot.state.playout.phase === "idle" && JSON.stringify(selectedPlaylistIds(snapshot)) === JSON.stringify(originalSelectedIds) && activePlaylistId(snapshot) === originalActiveId, { allowUiBusy: false }); evidence.dryRunPlayout.result = "PASS"; await checkpoint("dry-run-playout-selection-next", { stagedEntryId, selectedEntryId, nextEntryId, programCutPhysicalIndex: selectableCut.physicalIndex, playlistCount }); } function recordFullUiDbCheck(collection, name, details = null) { if (!evidence.fullUiDb) return; evidence.fullUiDb[collection].push({ name, at: new Date().toISOString(), details }); } function namedDefinitionExpression(title) { return `Array.from(document.querySelectorAll( "#named-playlist-definitions button[data-named-playlist-definition-id]")) .find(button => button.textContent === ${JSON.stringify(title)})`; } async function executeNamedPlaylistCrud() { const title = evidence.fixture.namedPlaylistTitle; let current = await readSnapshot(); if (current.state.namedPlaylist.definitions.some(row => row.title === title)) { failKnown("The generated PList fixture already exists before mutation."); } await pointerClick('document.getElementById("db-save-button")', "PList open save dialog"); current = await waitFor("PList fresh definition read", snapshot => snapshot.dom.namedModalHidden === false && snapshot.state.isBusy === false, { timeoutMilliseconds: 60_000, allowUiBusy: true }); if (current.state.namedPlaylist.definitions.some(row => row.title === title)) { failKnown("The generated PList fixture appeared during the preflight read."); } await replaceText('document.getElementById("named-playlist-new-title")', title, "PList unique title"); await pointerClick('document.getElementById("named-playlist-create-button")', "PList create definition"); current = await waitFor("PList definition create commit", snapshot => snapshot.state.isBusy === false && snapshot.state.namedPlaylist.definitions.some(row => row.title === title) && snapshot.state.namedPlaylist.lastMutationOutcome && snapshot.state.namedPlaylist.lastMutationOutcome.startsWith("committed"), { timeoutMilliseconds: 60_000, allowUiBusy: true }); const created = current.state.namedPlaylist.definitions.find(row => row.title === title); if (!created || current.state.namedPlaylist.selectedDefinitionId !== created.definitionId) { await pointerClick(namedDefinitionExpression(title), "PList select created definition"); current = await waitFor("PList created definition selection", snapshot => snapshot.state.isBusy === false && snapshot.state.namedPlaylist.selectedTitle === title, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } await pointerClick('document.getElementById("named-playlist-confirm-button")', "PList request row save"); await waitFor("PList save confirmation", snapshot => snapshot.dom.namedConfirmationHidden === false && snapshot.state.isBusy === false, { allowUiBusy: false }); await pointerClickWithJavaScriptDialog( 'document.getElementById("named-playlist-confirmation-yes")', "PList confirm row save", "alert", "저장되었습니다", true); current = await waitFor("PList row save commit", snapshot => snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === true && snapshot.state.namedPlaylist.selectedTitle === title && snapshot.state.namedPlaylist.rowCount === 3 && snapshot.state.namedPlaylist.lastMutationOutcome && snapshot.state.namedPlaylist.lastMutationOutcome.startsWith("committed"), { timeoutMilliseconds: 60_000, allowUiBusy: true }); recordFullUiDbCheck("databaseChecks", "PList create/save", { rowCount: current.state.namedPlaylist.rowCount }); await pointerClick('document.getElementById("db-load-button")', "PList reopen for fresh readback"); current = await waitFor("PList fresh readback", snapshot => snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === false && snapshot.state.namedPlaylist.definitions.some(row => row.title === title), { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick(namedDefinitionExpression(title), "PList select fresh readback"); current = await waitFor("PList fresh selected readback", snapshot => snapshot.state.isBusy === false && snapshot.state.namedPlaylist.selectedTitle === title, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick('document.getElementById("named-playlist-confirm-button")', "PList load fresh rows"); current = await waitFor("PList load close", snapshot => snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === true && snapshot.state.playlist.length === 3, { timeoutMilliseconds: 60_000, allowUiBusy: true }); recordFullUiDbCheck("databaseChecks", "PList fresh readback", { rowCount: current.state.playlist.length }); await pointerClick('document.getElementById("db-load-button")', "PList reopen for delete"); await waitFor("PList delete list read", snapshot => snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === false && snapshot.state.namedPlaylist.definitions.some(row => row.title === title), { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick(namedDefinitionExpression(title), "PList select delete target"); await waitFor("PList delete target selection", snapshot => snapshot.state.isBusy === false && snapshot.state.namedPlaylist.selectedTitle === title, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick('document.getElementById("named-playlist-delete-button")', "PList open delete confirmation"); await waitFor("PList delete confirmation", snapshot => snapshot.dom.namedConfirmationHidden === false && snapshot.state.isBusy === false, { allowUiBusy: false }); await pointerClick('document.getElementById("named-playlist-confirmation-yes")', "PList confirm delete"); current = await waitFor("PList delete and absence", snapshot => snapshot.state.isBusy === false && !snapshot.state.namedPlaylist.definitions.some(row => row.title === title) && snapshot.state.namedPlaylist.lastMutationOutcome && snapshot.state.namedPlaylist.lastMutationOutcome.startsWith("committed"), { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick('document.getElementById("named-playlist-cancel-button")', "PList close after delete"); await waitFor("PList final close", snapshot => snapshot.dom.namedModalHidden === true && snapshot.state.isBusy === false, { allowUiBusy: false }); recordFullUiDbCheck("databaseChecks", "PList delete/absence"); } const manualFinancialScreens = Object.freeze([ "revenue-composition", "growth-metrics", "sales", "operating-profit" ]); function manualScreenButtonExpression(screen) { return `document.querySelector('button[data-manual-screen="${screen}"]')`; } async function openManualFinancial(screen, label) { await pointerClick(manualScreenButtonExpression(screen), label); return await waitFor(`${label} open`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.screen === screen && snapshot.dom.manualFinancialModalHidden === false, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } async function closeManualFinancial(label) { await pointerClick('document.getElementById("manual-financial-close")', label); await waitFor(`${label} complete`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial == null && snapshot.dom.manualFinancialModalHidden === true, { allowUiBusy: false }); } async function searchManualFinancialIdentity(screen, stockName, label) { await replaceText('document.getElementById("manual-financial-list-query")', stockName, `${label} list query`); await pointerClick('document.querySelector("button[data-manual-list-search]")', `${label} list search`); return await waitFor(`${label} list result`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.screen === screen && snapshot.state.manualFinancial.query === stockName, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } async function preflightManualFinancialIdentities() { const stockName = evidence.fixture.databaseStockDisplayName; for (const screen of manualFinancialScreens) { let current = await openManualFinancial(screen, `GraphE ${screen} preflight open`); current = await searchManualFinancialIdentity( screen, stockName, `GraphE ${screen} preflight`); if (current.state.manualFinancial.rows.some(row => row.stockName === stockName)) { failKnown(`GraphE ${screen} fixture stock already exists before any GraphE write.`); } await closeManualFinancial(`GraphE ${screen} preflight close`); } recordFullUiDbCheck("screenChecks", "GraphE four-screen identity preflight"); } async function fillManualFinancialRecord(screen) { if (screen === "revenue-composition") { await replaceText('document.querySelector("[data-manual-base-date]")', "2026Q3", "GraphE revenue base date"); const percentages = ["10", "20", "30", "20", "20"]; for (let index = 0; index < 5; index += 1) { await replaceText( `document.querySelector('[data-manual-revenue-label="${index}"]')`, `항목${index + 1}`, `GraphE revenue label ${index + 1}`); await replaceText( `document.querySelector('[data-manual-revenue-percentage="${index}"]')`, percentages[index], `GraphE revenue percentage ${index + 1}`); } return; } if (screen === "growth-metrics") { for (let index = 0; index < 4; index += 1) { await replaceText( `document.querySelector('[data-manual-growth-period="${index}"]')`, `2${index + 3}Q${index + 1}`, `GraphE growth period ${index + 1}`); } const series = [ "salesGrowth", "operatingProfitGrowth", "netAssetGrowth", "netIncomeGrowth" ]; for (let seriesIndex = 0; seriesIndex < series.length; seriesIndex += 1) { for (let index = 0; index < 4; index += 1) { await replaceText( `document.querySelector('[data-manual-growth-series="${series[seriesIndex]}"]` + `[data-manual-growth-value="${index}"]')`, String(seriesIndex * 4 + index + 1), `GraphE growth value ${seriesIndex + 1}-${index + 1}`); } } return; } for (let index = 0; index < 6; index += 1) { await replaceText( `document.querySelector('[data-manual-quarter-label="${index}"]')`, `${index + 1}Q`, `GraphE ${screen} quarter ${index + 1}`); await replaceText( `document.querySelector('[data-manual-quarter-value="${index}"]')`, String((index + 1) * 10), `GraphE ${screen} value ${index + 1}`); } } async function executeManualFinancialCrud() { const stockName = evidence.fixture.databaseStockDisplayName; await preflightManualFinancialIdentities(); for (const screen of manualFinancialScreens) { let current = await openManualFinancial(screen, `GraphE ${screen} create open`); await pointerClick('document.querySelector("button[data-manual-create]")', `GraphE ${screen} new input`); await waitFor(`GraphE ${screen} create form`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.screen === screen && snapshot.state.manualFinancial.selectedRecord == null, { allowUiBusy: false }); await replaceText('document.getElementById("manual-financial-stock-name")', stockName, `GraphE ${screen} stock name`); await pointerClick('document.querySelector("button[data-manual-stock-search]")', `GraphE ${screen} verify stock`); current = await waitFor(`GraphE ${screen} stock candidates`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.stockCandidates?.some(row => row.name === stockName), { timeoutMilliseconds: 60_000, allowUiBusy: true }); const candidates = current.state.manualFinancial.stockCandidates .filter(row => row.name === stockName); if (candidates.length !== 1) { failKnown(`GraphE ${screen} fixture stock is not unique in the master.`); } await pointerClick( `document.querySelector('[data-manual-stock-result-id="${candidates[0].resultId}"]')`, `GraphE ${screen} select verified stock`); await waitFor(`GraphE ${screen} verified stock`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.verifiedStock?.name === stockName, { allowUiBusy: false }); await fillManualFinancialRecord(screen); await pointerClick('document.querySelector("button[data-manual-save]")', `GraphE ${screen} save`); current = await waitFor(`GraphE ${screen} create commit and readback`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.status === "writeCommitted" && snapshot.state.manualFinancial?.selectedRecord?.stockName === stockName && snapshot.state.manualFinancial?.canDelete === true && snapshot.state.manualFinancial?.writesQuarantined !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); recordFullUiDbCheck("databaseChecks", `GraphE ${screen} create/save`, { status: current.state.manualFinancial.status }); await closeManualFinancial(`GraphE ${screen} close after create`); await openManualFinancial(screen, `GraphE ${screen} fresh reopen`); current = await searchManualFinancialIdentity( screen, stockName, `GraphE ${screen} fresh readback`); const rows = current.state.manualFinancial.rows .filter(row => row.stockName === stockName); if (rows.length !== 1) { failKnown(`GraphE ${screen} fresh readback is not unique.`); } await replaceText('document.getElementById("manual-financial-find-query")', stockName, `GraphE ${screen} find query`); await pointerClick('document.querySelector("button[data-manual-find-direction=down]")', `GraphE ${screen} find down`); await waitFor(`GraphE ${screen} find down result`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.selectedRecord?.stockName === stockName, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick('document.querySelector("button[data-manual-find-direction=up]")', `GraphE ${screen} find up`); await waitFor(`GraphE ${screen} find up result`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.selectedRecord?.stockName === stockName, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick('document.querySelector("button[data-manual-name-sort]")', `GraphE ${screen} toggle name sort`); await waitFor(`GraphE ${screen} sort complete`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.nameSortDirection === "descending", { allowUiBusy: false }); recordFullUiDbCheck("databaseChecks", `GraphE ${screen} fresh readback`, { rows: rows.length }); const actionIds = await clickAllEnabledPlaylistActions( "button[data-manual-action-id]", "manualActionId", `GraphE ${screen} action`); const beforeDoubleClick = await readSnapshot(); await pointerDoubleClick(dataButtonExpression("manualResultId", rows[0].resultId), `GraphE ${screen} row double-click`); await waitFor(`GraphE ${screen} row double-click add`, snapshot => snapshot.state.playlist.length === beforeDoubleClick.state.playlist.length + 1 && snapshot.state.manualFinancial == null && snapshot.dom.manualFinancialModalHidden === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); recordFullUiDbCheck("screenChecks", `GraphE ${screen} actions and row double-click`, { actionButtons: actionIds.length }); await openManualFinancial(screen, `GraphE ${screen} delete reopen`); current = await searchManualFinancialIdentity( screen, stockName, `GraphE ${screen} delete fresh search`); const deleteRows = current.state.manualFinancial.rows .filter(row => row.stockName === stockName); if (deleteRows.length !== 1) { failKnown(`GraphE ${screen} delete target is not unique after fresh reopen.`); } await pointerClick( dataButtonExpression("manualResultId", deleteRows[0].resultId), `GraphE ${screen} select delete target`); await waitFor(`GraphE ${screen} delete target loaded`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.selectedRecord?.stockName === stockName && snapshot.state.manualFinancial?.canDelete === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClickWithJavaScriptDialog( 'document.querySelector("button[data-manual-delete]")', `GraphE ${screen} delete`, "confirm", "선택한 수동 재무 데이터를 삭제하시겠습니까?", true); current = await waitFor(`GraphE ${screen} delete commit`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.status === "writeCommitted" && snapshot.state.manualFinancial?.writesQuarantined !== true && !snapshot.state.manualFinancial.rows.some(row => row.stockName === stockName), { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClickWithJavaScriptDialog( 'document.querySelector("button[data-manual-delete-all]")', `GraphE ${screen} delete-all cancel branch`, "confirm", "현재 화면의 수동 재무 데이터를 모두 삭제하시겠습니까?", false); recordFullUiDbCheck("databaseChecks", `GraphE ${screen} delete/absence`); await closeManualFinancial(`GraphE ${screen} close after delete`); } if (configuration.scope === "graphe") { await clearScreenValidationPlaylist(); } } async function selectMainTab(tabId, label) { const isLoaded = snapshot => snapshot.state.isBusy === false && snapshot.state.tabs?.some(tab => tab.id === tabId && tab.isActive) === true && (tabId !== "theme" || snapshot.state.theme != null) && (tabId !== "expert" || snapshot.state.expert != null); const before = await readSnapshot(); assertSafety(before, `${label} preflight`); const alreadyActive = before.state.tabs?.some( tab => tab.id === tabId && tab.isActive) === true; if (!alreadyActive) { await pointerClick( `document.querySelector('button[data-tab-id="${tabId}"]')`, label); } return await waitFor(`${label} load`, snapshot => isLoaded(snapshot), { timeoutMilliseconds: 60_000, allowUiBusy: true }); } async function openThemeCatalogList(label) { await selectMainTab("theme", `${label} select ThemeA tab`); await pointerClick('document.getElementById("uc4-theme-add")', `${label} open ThemeA catalog`); return await waitFor(`${label} ThemeA catalog list`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.isOpen === true && snapshot.state.operatorCatalog?.entity === "theme" && snapshot.state.operatorCatalog?.mode === "list" && snapshot.state.operatorCatalog?.schemaValidated === true && snapshot.state.operatorCatalog?.writesQuarantined !== true && snapshot.dom.operatorCatalogModalHidden === false, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } async function closeOperatorCatalog(label, useEditorCancel = false) { await pointerClick( useEditorCancel ? 'document.getElementById("operator-catalog-cancel")' : 'document.getElementById("operator-catalog-close")', label); return await waitFor(`${label} complete`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog == null && snapshot.dom.operatorCatalogModalHidden === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } async function searchOperatorCatalog(query, label) { await replaceText('document.getElementById("operator-catalog-query")', query, `${label} query`); const before = await readSnapshot(); await pointerClick('document.getElementById("operator-catalog-search")', `${label} submit`); return await waitFor(`${label} results`, snapshot => snapshot.state.isBusy === false && snapshot.state.revision > before.state.revision && snapshot.state.operatorCatalog?.isOpen === true && snapshot.state.operatorCatalog?.mode === "list" && snapshot.state.operatorCatalog?.query === query && snapshot.state.operatorCatalog?.writesQuarantined !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } async function requireExactCatalogResult(query, label, expectedCount) { const current = await searchOperatorCatalog(query, label); const matches = current.state.operatorCatalog.results .filter(row => row.displayName === query); if (matches.length !== expectedCount) { failKnown(`${label} expected ${expectedCount} exact catalog result(s), ` + `received ${matches.length}.`); } return { current, matches }; } async function addOperatorCatalogStock( query, expectedDisplayName, label) { const before = await readSnapshot(); const beforeRows = before.state.operatorCatalog?.draftRows || []; await replaceText('document.getElementById("operator-catalog-stock-query")', query, `${label} query`); const beforeSearch = await readSnapshot(); await pointerClick('document.getElementById("operator-catalog-stock-search")', `${label} search`); const searched = await waitFor(`${label} stock results`, snapshot => snapshot.state.isBusy === false && snapshot.state.revision > beforeSearch.state.revision && snapshot.state.operatorCatalog?.stockQuery === query && snapshot.state.operatorCatalog?.writesQuarantined !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const matches = searched.state.operatorCatalog.stockResults.filter(row => row.displayName === expectedDisplayName && row.isCompatible === true); if (matches.length !== 1) { failKnown(`${label} requires one exact compatible stock result, received ` + `${matches.length}.`); } const resultId = matches[0].resultId; await pointerClick( `document.querySelector('button[data-operator-catalog-stock-result-id="${resultId}"]')`, `${label} select`); await waitFor(`${label} selected`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.selectedStockResultId === resultId && snapshot.state.operatorCatalog?.canAddStock === true, { timeoutMilliseconds: 30_000, allowUiBusy: true }); await pointerClick('document.getElementById("operator-catalog-add-stock")', `${label} add`); const added = await waitFor(`${label} added`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.draftRows?.length === beforeRows.length + 1 && snapshot.state.operatorCatalog?.selectedStockResultId == null, { allowUiBusy: false }); const addedRows = added.state.operatorCatalog.draftRows.filter(row => !beforeRows.some(existing => existing.rowId === row.rowId)); if (addedRows.length !== 1) { failKnown(`${label} did not add exactly one new draft row.`); } return { current: added, row: addedRows[0] }; } async function setOperatorCatalogBuyAmount(rowId, amount, label) { const expression = `document.querySelector('input[data-operator-catalog-buy-amount-row-id="${rowId}"]')`; await replaceText(expression, String(amount), label); await pointerClick('document.getElementById("operator-catalog-stock-query")', `${label} commit`); return await waitFor(`${label} state`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.draftRows?.some(row => row.rowId === rowId && row.buyAmount === amount) === true, { allowUiBusy: false }); } async function searchAndSelectMainTheme(title, expectedDisplayTitle, label) { await selectMainTab("theme", `${label} select ThemeA tab`); await replaceText('document.getElementById("theme-search")', title, `${label} query`); const beforeSearch = await readSnapshot(); await pointerClick('document.querySelector("button[data-theme-search]")', `${label} search`); const searched = await waitFor(`${label} results`, snapshot => snapshot.state.isBusy === false && snapshot.state.revision > beforeSearch.state.revision && snapshot.state.theme?.query === title, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const matches = searched.state.theme.searchResults.filter(row => row.displayTitle === expectedDisplayTitle); if (matches.length !== 1) { failKnown(`${label} expected one exact ThemeA result, received ${matches.length}.`); } const resultId = matches[0].resultId; await pointerClick( `document.querySelector('button[data-theme-result-id="${resultId}"]')`, `${label} select`); const selected = await waitFor(`${label} preview`, snapshot => snapshot.state.isBusy === false && snapshot.state.theme?.selectedResultId === resultId && snapshot.state.theme?.selectedTheme?.displayTitle === expectedDisplayTitle, { timeoutMilliseconds: 60_000, allowUiBusy: true }); return { current: selected, resultId }; } async function assertMainThemeAbsent(title, expectedDisplayTitle, label) { await selectMainTab("theme", `${label} select ThemeA tab`); await replaceText('document.getElementById("theme-search")', title, `${label} query`); const beforeSearch = await readSnapshot(); await pointerClick('document.querySelector("button[data-theme-search]")', `${label} search`); const current = await waitFor(`${label} results`, snapshot => snapshot.state.isBusy === false && snapshot.state.revision > beforeSearch.state.revision && snapshot.state.theme?.query === title, { timeoutMilliseconds: 60_000, allowUiBusy: true }); if (current.state.theme.searchResults.some(row => row.displayTitle === expectedDisplayTitle)) { failKnown(`${label} still returned the deleted ThemeA row.`); } return current; } async function createReadEditDeleteTheme( title, market, stockQuery, stockDisplayName) { const label = `ThemeA ${market.toUpperCase()} ${title}`; await openThemeCatalogList(`${label} create`); await requireExactCatalogResult(title, `${label} preflight`, 0); await selectOptionValue( 'document.getElementById("operator-catalog-new-market")', market, `${label} market`); await pointerClick('document.getElementById("operator-catalog-new")', `${label} new`); await waitFor(`${label} create editor`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.entity === "theme" && snapshot.state.operatorCatalog?.mode === "create" && snapshot.state.operatorCatalog?.newThemeMarket === market && snapshot.state.operatorCatalog?.codeLabel?.length > 0 && snapshot.state.operatorCatalog?.writesQuarantined !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await replaceText('document.getElementById("operator-catalog-name")', title, `${label} name`); await pointerClick('document.getElementById("operator-catalog-name-check")', `${label} duplicate check`); await respondToNativeDialog(`${label} duplicate check`, false, true); await waitFor(`${label} name available`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.checkedThemeName === title && snapshot.state.operatorCatalog?.themeNameAvailability === "available" && snapshot.state.operatorCatalog?.writesQuarantined !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const added = await addOperatorCatalogStock( stockQuery, stockDisplayName, `${label} stock`); await pointerClick('document.getElementById("operator-catalog-delete-all")', `${label} Delete All cancel branch`); await respondToNativeDialog(`${label} Delete All cancel branch`, true, false); const afterCancel = await waitFor(`${label} draft preserved`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.draftRows?.length === 1 && snapshot.state.operatorCatalog?.draftRows?.[0]?.rowId === added.row.rowId, { allowUiBusy: false }); if (afterCancel.state.operatorCatalog.canSave !== true) { failKnown(`${label} is not saveable after the cancelled Delete All branch.`); } await pointerClick('document.getElementById("operator-catalog-save")', `${label} save`); await waitFor(`${label} create commit`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog == null && snapshot.dom.operatorCatalogModalHidden === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); recordFullUiDbCheck("databaseChecks", `${label} create/save`); await openThemeCatalogList(`${label} fresh read`); const fresh = await requireExactCatalogResult(title, `${label} fresh read`, 1); const catalogResultId = fresh.matches[0].resultId; await pointerClick( `document.querySelector('button[data-operator-catalog-result-id="${catalogResultId}"]')`, `${label} fresh read select`); const loaded = await waitFor(`${label} fresh read editor`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.mode === "edit" && snapshot.state.operatorCatalog?.selectedResultId === catalogResultId && snapshot.state.operatorCatalog?.editorName === title && snapshot.state.operatorCatalog?.draftRows?.length === 1 && snapshot.state.operatorCatalog?.writesQuarantined !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); recordFullUiDbCheck("databaseChecks", `${label} fresh readback`, { codeLabel: loaded.state.operatorCatalog.codeLabel, rows: loaded.state.operatorCatalog.draftRows.length }); await closeOperatorCatalog(`${label} close fresh read`, true); const expectedDisplayTitle = market === "nxt" ? `${title}(NXT)` : title; const selected = await searchAndSelectMainTheme( title, expectedDisplayTitle, `${label} UC4`); if (selected.current.state.theme.previewItems.length !== 1) { failKnown(`${label} UC4 preview did not contain the saved stock.`); } await pointerClick('document.getElementById("uc4-theme-edit")', `${label} UC4 edit`); await waitFor(`${label} UC4 edit loaded`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.entity === "theme" && snapshot.state.operatorCatalog?.mode === "edit" && snapshot.state.operatorCatalog?.editorName === title && snapshot.state.operatorCatalog?.draftRows?.length === 1, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await closeOperatorCatalog(`${label} UC4 edit cancel`, true); recordFullUiDbCheck("screenChecks", `${label} UC4 select/edit`); await pointerClick('document.getElementById("uc4-theme-delete")', `${label} UC4 delete`); await respondToNativeDialog(`${label} UC4 delete`, true, true); await waitFor(`${label} delete commit`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog == null && snapshot.state.theme?.selectedTheme == null && !snapshot.state.theme?.searchResults?.some(row => row.displayTitle === expectedDisplayTitle), { timeoutMilliseconds: 60_000, allowUiBusy: true }); await assertMainThemeAbsent(title, expectedDisplayTitle, `${label} fresh absence`); recordFullUiDbCheck("databaseChecks", `${label} delete/absence`); } async function searchAndSelectMainExpert(title, label) { await selectMainTab("expert", `${label} select EList tab`); await replaceText('document.getElementById("expert-search")', title, `${label} query`); const beforeSearch = await readSnapshot(); await pointerClick('document.querySelector("button[data-expert-search]")', `${label} search`); const searched = await waitFor(`${label} results`, snapshot => snapshot.state.isBusy === false && snapshot.state.revision > beforeSearch.state.revision && snapshot.state.expert?.search?.query === title, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const matches = searched.state.expert.search.results.filter(row => row.expertName === title); if (matches.length !== 1) { failKnown(`${label} expected one exact EList result, received ${matches.length}.`); } const selectionId = matches[0].selectionId; await pointerClick( `document.querySelector('button[data-expert-selection-id="${selectionId}"]')`, `${label} select`); const selected = await waitFor(`${label} preview`, snapshot => snapshot.state.isBusy === false && snapshot.state.expert?.selectedExpert?.selectionId === selectionId && snapshot.state.expert?.selectedExpert?.expertName === title, { timeoutMilliseconds: 60_000, allowUiBusy: true }); return { current: selected, selectionId }; } async function assertMainExpertAbsent(title, label) { await selectMainTab("expert", `${label} select EList tab`); await replaceText('document.getElementById("expert-search")', title, `${label} query`); const beforeSearch = await readSnapshot(); await pointerClick('document.querySelector("button[data-expert-search]")', `${label} search`); const current = await waitFor(`${label} results`, snapshot => snapshot.state.isBusy === false && snapshot.state.revision > beforeSearch.state.revision && snapshot.state.expert?.search?.query === title, { timeoutMilliseconds: 60_000, allowUiBusy: true }); if (current.state.expert.search.results.some(row => row.expertName === title)) { failKnown(`${label} still returned the deleted EList row.`); } return current; } async function createReadEditDeleteExpert(title) { const label = `EList ${title}`; await assertMainExpertAbsent(title, `${label} preflight`); await pointerClick('document.getElementById("uc6-expert-add")', `${label} UC6 add`); await waitFor(`${label} create editor`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.isOpen === true && snapshot.state.operatorCatalog?.entity === "expert" && snapshot.state.operatorCatalog?.mode === "create" && snapshot.state.operatorCatalog?.draftRows?.length === 0 && snapshot.state.operatorCatalog?.writesQuarantined !== true && snapshot.dom.operatorCatalogModalHidden === false, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await replaceText('document.getElementById("operator-catalog-name")', title, `${label} name`); await pointerClick('document.getElementById("operator-catalog-save")', `${label} create save`); await waitFor(`${label} create commit`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog == null && snapshot.dom.operatorCatalogModalHidden === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); recordFullUiDbCheck("databaseChecks", `${label} create/save`); const created = await searchAndSelectMainExpert(title, `${label} fresh create read`); if (created.current.state.expert.preview.items.length !== 0) { failKnown(`${label} create readback unexpectedly contained recommendations.`); } recordFullUiDbCheck("databaseChecks", `${label} fresh create readback`, { rows: 0 }); await pointerClick('document.getElementById("uc6-expert-edit")', `${label} UC6 edit`); await waitFor(`${label} edit loaded`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.entity === "expert" && snapshot.state.operatorCatalog?.mode === "edit" && snapshot.state.operatorCatalog?.editorName === title && snapshot.state.operatorCatalog?.draftRows?.length === 0 && snapshot.state.operatorCatalog?.writesQuarantined !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const first = await addOperatorCatalogStock( "삼성출판사", "삼성출판사", `${label} first stock`); const second = await addOperatorCatalogStock( "삼성전자", "삼성전자", `${label} second stock`); await setOperatorCatalogBuyAmount(first.row.rowId, 101, `${label} first buy amount`); await setOperatorCatalogBuyAmount(second.row.rowId, 202, `${label} second buy amount`); await pointerClick( `document.querySelector('button[data-operator-catalog-move-row-id="${second.row.rowId}"]` + `[data-operator-catalog-move-direction="up"]')`, `${label} second row Up`); await waitFor(`${label} second row Up state`, snapshot => snapshot.state.operatorCatalog?.draftRows?.[0]?.rowId === second.row.rowId && snapshot.state.operatorCatalog?.draftRows?.[1]?.rowId === first.row.rowId, { allowUiBusy: false }); await pointerClick( `document.querySelector('button[data-operator-catalog-move-row-id="${second.row.rowId}"]` + `[data-operator-catalog-move-direction="down"]')`, `${label} second row Down`); await waitFor(`${label} second row Down state`, snapshot => snapshot.state.operatorCatalog?.draftRows?.[0]?.rowId === first.row.rowId && snapshot.state.operatorCatalog?.draftRows?.[1]?.rowId === second.row.rowId, { allowUiBusy: false }); await pointerClick( `document.querySelector('button[data-operator-catalog-remove-row-id="${second.row.rowId}"]')`, `${label} second row Delete`); await waitFor(`${label} second row Delete state`, snapshot => snapshot.state.operatorCatalog?.draftRows?.length === 1 && snapshot.state.operatorCatalog?.draftRows?.[0]?.rowId === first.row.rowId, { allowUiBusy: false }); const readded = await addOperatorCatalogStock( "삼성전자", "삼성전자", `${label} second stock re-add`); await setOperatorCatalogBuyAmount(readded.row.rowId, 202, `${label} re-added buy amount`); const readyToSave = await readSnapshot(); if (readyToSave.state.operatorCatalog?.canSave !== true || readyToSave.state.operatorCatalog?.draftRows?.length !== 2) { failKnown(`${label} edit is not saveable with exactly two recommendations.`); } await pointerClick('document.getElementById("operator-catalog-save")', `${label} edit save`); await respondToNativeDialog(`${label} edit save`, false, true); const closed = await waitFor(`${label} edit commit`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog == null && snapshot.dom.operatorCatalogModalHidden === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); if (closed.state.expert?.selectedExpert?.expertName !== title || closed.state.expert?.preview?.items?.length !== 2) { failKnown(`${label} known-committed edit did not refresh the two-row preview.`); } recordFullUiDbCheck("screenChecks", `${label} UC6 add/edit/up/down/delete`); recordFullUiDbCheck("databaseChecks", `${label} edit/save`, { rows: 2 }); const fresh = await searchAndSelectMainExpert(title, `${label} fresh edit read`); const previewItems = fresh.current.state.expert.preview.items; if (previewItems.length !== 2 || previewItems[0].stockName !== "삼성출판사" || previewItems[0].buyAmount !== 101 || previewItems[1].stockName !== "삼성전자" || previewItems[1].buyAmount !== 202) { failKnown(`${label} fresh readback did not match the exact saved recommendations.`); } recordFullUiDbCheck("databaseChecks", `${label} fresh edit readback`, { rows: previewItems.length }); await pointerClick('document.getElementById("uc6-expert-delete")', `${label} UC6 delete`); await respondToNativeDialog(`${label} UC6 delete`, true, true); // Original UC6 removes the search row but deliberately retains the selected // expert and preview until the next search. The following fresh search is the // authoritative database-absence check. await waitFor(`${label} delete commit`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog == null && !snapshot.state.expert?.search?.results?.some(row => row.expertName === title), { timeoutMilliseconds: 60_000, allowUiBusy: true }); await assertMainExpertAbsent(title, `${label} fresh absence`); recordFullUiDbCheck("databaseChecks", `${label} delete/absence`); } async function executeOperatorCatalogCrud() { await createReadEditDeleteTheme( evidence.fixture.krxThemeTitle, "krx", "삼성출판사", "삼성출판사"); await createReadEditDeleteTheme( evidence.fixture.nxtThemeTitle, "nxt", "삼성전자", "삼성전자(NXT)"); await createReadEditDeleteExpert(evidence.fixture.expertTitle); } async function captureVisibleScreenInventory(tabId) { const buttons = await evaluate(`Array.from(document.querySelectorAll("button")) .filter(button => { const rectangle = button.getBoundingClientRect(); return !button.hidden && !button.closest("[hidden]") && rectangle.width > 0 && rectangle.height > 0 && getComputedStyle(button).display !== "none" && getComputedStyle(button).visibility !== "hidden"; }) .map(button => ({ id: button.id || null, text: (button.textContent || "").trim(), disabled: button.disabled === true, data: Object.fromEntries(Object.entries(button.dataset)) }))`); evidence.fullUiDb.screenInventory.push({ tabId, at: new Date().toISOString(), buttons }); } function dataButtonExpression(datasetKey, value) { return `Array.from(document.querySelectorAll("button")) .find(button => button.dataset[${JSON.stringify(datasetKey)}] === ${JSON.stringify(value)})`; } function modalButtonByTextExpression(text) { return `Array.from(document.querySelectorAll( "#manual-list-workspace button:not([disabled])")) .find(button => button.textContent.trim() === ${JSON.stringify(text)})`; } async function waitForTabModel(tabId, stateProperty, label) { await selectMainTab(tabId, label); return await waitFor(`${label} model`, snapshot => snapshot.state.isBusy === false && snapshot.state[stateProperty] != null, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } async function clickAndWaitForPlaylistAdd(expression, label, minimumAdded = 1) { const before = await readSnapshot(); const beforeCount = before.state.playlist.length; await pointerClick(expression, label); return await waitFor(`${label} playlist add`, snapshot => snapshot.state.isBusy === false && snapshot.state.playlist.length >= beforeCount + minimumAdded, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } async function doubleClickAndWaitForPlaylistAdd(expression, label, minimumAdded = 1) { const before = await readSnapshot(); const beforeCount = before.state.playlist.length; await pointerDoubleClick(expression, label); return await waitFor(`${label} playlist add`, snapshot => snapshot.state.isBusy === false && snapshot.state.playlist.length >= beforeCount + minimumAdded, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } async function clickAllEnabledPlaylistActions(selector, datasetKey, label) { const actionIds = await evaluate(`Array.from(document.querySelectorAll( ${JSON.stringify(selector)})) .filter(button => !button.disabled) .map(button => button.dataset[${JSON.stringify(datasetKey)}])`); if (!Array.isArray(actionIds) || actionIds.length === 0 || actionIds.some(value => typeof value !== "string" || value.length === 0)) { failKnown(`${label} exposed no enabled action buttons.`); } for (const actionId of actionIds) { await clickAndWaitForPlaylistAdd( dataButtonExpression(datasetKey, actionId), `${label} ${actionId}`); } return actionIds; } async function doubleClickAllEnabledPlaylistActions(selector, datasetKey, label) { const actionIds = await evaluate(`Array.from(document.querySelectorAll( ${JSON.stringify(selector)})) .filter(button => !button.disabled) .map(button => button.dataset[${JSON.stringify(datasetKey)}])`); if (!Array.isArray(actionIds) || actionIds.length === 0 || actionIds.some(value => typeof value !== "string" || value.length === 0)) { failKnown(`${label} exposed no enabled action buttons.`); } for (const actionId of actionIds) { await doubleClickAndWaitForPlaylistAdd( dataButtonExpression(datasetKey, actionId), `${label} ${actionId}`); } return actionIds; } async function verifyFixedAndIndustryScreens() { await waitForTabModel("overseas", "fixedCatalog", "UC1 overseas tab"); await pointerClick('document.getElementById("catalog-expand-all")', "UC1 Expand off"); if (await evaluate('document.getElementById("catalog-expand-all").checked') !== false) { failKnown("UC1 Expand did not close the fixed tree."); } await pointerClick('document.getElementById("catalog-expand-all")', "UC1 Expand on"); if (await evaluate('document.getElementById("catalog-expand-all").checked') !== true) { failKnown("UC1 Expand did not reopen the fixed tree."); } await doubleClickAndWaitForPlaylistAdd(dataButtonExpression("actionId", "fixed-001"), "UC1 overseas leaf"); const beforeSection = await readSnapshot(); await pointerDoubleClick( `document.querySelector('summary[data-section-index="1"]')`, "UC1 fixed three-row section"); await waitFor("UC1 fixed section batch", snapshot => snapshot.state.isBusy === false && snapshot.state.playlist.length === beforeSection.state.playlist.length + 3, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await waitForTabModel("exchange", "fixedCatalog", "UC1 exchange tab"); await doubleClickAndWaitForPlaylistAdd(dataButtonExpression("actionId", "fixed-079"), "UC1 exchange leaf"); await waitForTabModel("index", "fixedCatalog", "UC1 index tab"); await doubleClickAndWaitForPlaylistAdd(dataButtonExpression("actionId", "fixed-125"), "UC1 index leaf"); recordFullUiDbCheck("screenChecks", "UC1 Expand, leaf, and parent-section buttons"); for (const tabId of ["kospiIndustry", "kosdaqIndustry"]) { await waitForTabModel(tabId, "industry", `UC2 ${tabId} tab`); await pointerClick(dataButtonExpression("industryIndex", "0"), `UC2 ${tabId} first industry`); await waitFor(`UC2 ${tabId} first selection`, snapshot => snapshot.state.isBusy === false && snapshot.state.industry?.selectedIndex === 0, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerDoubleClick(dataButtonExpression("industryIndex", "0"), `UC2 ${tabId} first industry double-click`); await waitFor(`UC2 ${tabId} first comparison slot`, snapshot => snapshot.state.isBusy === false && Boolean(snapshot.state.industry?.firstName), { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerDoubleClick(dataButtonExpression("industryIndex", "1"), `UC2 ${tabId} second industry double-click`); const rotated = await waitFor(`UC2 ${tabId} comparison slots`, snapshot => snapshot.state.isBusy === false && Boolean(snapshot.state.industry?.firstName) && Boolean(snapshot.state.industry?.secondName), { timeoutMilliseconds: 60_000, allowUiBusy: true }); const firstName = rotated.state.industry.firstName; const secondName = rotated.state.industry.secondName; await pointerClick(dataButtonExpression("industrySwap", "true"), `UC2 ${tabId} swap`); await waitFor(`UC2 ${tabId} swapped slots`, snapshot => snapshot.state.industry?.firstName === secondName && snapshot.state.industry?.secondName === firstName, { allowUiBusy: false }); await doubleClickAndWaitForPlaylistAdd( dataButtonExpression("industryActionId", "one-column"), `UC2 ${tabId} one-column action`); } recordFullUiDbCheck("screenChecks", "UC2 KOSPI/KOSDAQ select, double-click, swap, action"); } function comparisonBaseline(snapshot) { return snapshot.state.comparison.savedPairs.map(row => ({ rowId: row.rowId, displayLabel: row.displayLabel, first: comparisonTargetIdentity(row.first), second: comparisonTargetIdentity(row.second) })); } function comparisonTargetIdentity(target) { return target == null ? null : { displayName: target.displayName, kind: target.kind, metadata: target.metadata }; } async function verifyComparisonScreen() { let current = await waitForTabModel("comparison", "comparison", "UC3 comparison tab"); const baseline = comparisonBaseline(current); await replaceText('document.getElementById("comparison-domestic-search")', "삼성", "UC3 domestic query"); await pointerClick(dataButtonExpression("comparisonSearch", "domestic"), "UC3 domestic search"); current = await waitFor("UC3 domestic results", snapshot => snapshot.state.isBusy === false && snapshot.state.comparison?.domesticSearch?.query === "삼성" && snapshot.state.comparison.domesticSearch.results.length > 1, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const domesticId = current.state.comparison.domesticSearch.results[0].selectionId; const secondDomesticId = current.state.comparison.domesticSearch.results[1].selectionId; await pointerClick(dataButtonExpression("comparisonSelectionId", domesticId), "UC3 domestic result single-click"); await waitFor("UC3 domestic result selected", snapshot => snapshot.state.comparison?.domesticSearch?.selectedResultId === domesticId, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await replaceText('document.getElementById("comparison-world-search")', "3M", "UC3 world query"); await pointerClick(dataButtonExpression("comparisonSearch", "world"), "UC3 world search"); current = await waitFor("UC3 world results", snapshot => snapshot.state.isBusy === false && snapshot.state.comparison?.worldSearch?.query === "3M" && snapshot.state.comparison?.worldSearch?.status === "ready" && snapshot.state.comparison.worldSearch.results.length > 0, { timeoutMilliseconds: 60_000, allowUiBusy: true }); if (current.state.comparison.firstTarget != null || current.state.comparison.secondTarget != null) { failKnown("UC3 fresh tab activation did not start with empty draft targets."); } await pointerDoubleClick(dataButtonExpression("comparisonSelectionId", domesticId), "UC3 choose first domestic target"); await waitFor("UC3 first domestic target", snapshot => snapshot.state.comparison?.firstTarget != null, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerDoubleClick(dataButtonExpression("comparisonSelectionId", secondDomesticId), "UC3 choose second domestic target"); current = await waitFor("UC3 second domestic target", snapshot => snapshot.state.comparison?.firstTarget != null && snapshot.state.comparison?.secondTarget != null, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const firstTargetIdentity = comparisonTargetIdentity( current.state.comparison.firstTarget); const secondTargetIdentity = comparisonTargetIdentity( current.state.comparison.secondTarget); await pointerClick(dataButtonExpression("comparisonSwap", "true"), "UC3 swap draft"); await waitFor("UC3 swapped draft", snapshot => JSON.stringify(comparisonTargetIdentity(snapshot.state.comparison?.firstTarget)) === JSON.stringify(secondTargetIdentity) && JSON.stringify(comparisonTargetIdentity(snapshot.state.comparison?.secondTarget)) === JSON.stringify(firstTargetIdentity), { allowUiBusy: false }); await pointerClick(dataButtonExpression("comparisonSwap", "true"), "UC3 restore draft order"); await waitFor("UC3 restored draft", snapshot => JSON.stringify(comparisonTargetIdentity(snapshot.state.comparison?.firstTarget)) === JSON.stringify(firstTargetIdentity) && JSON.stringify(comparisonTargetIdentity(snapshot.state.comparison?.secondTarget)) === JSON.stringify(secondTargetIdentity), { allowUiBusy: false }); await pointerClick(dataButtonExpression("comparisonAdd", "true"), "UC3 add first temporary comparison pair"); current = await waitFor("UC3 first temporary pair commit", snapshot => snapshot.state.isBusy === false && snapshot.state.comparison?.savedPairs?.length === baseline.length + 1 && snapshot.state.comparison?.selectedPairId != null, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const firstTemporaryRowId = current.state.comparison.selectedPairId; await pointerClick(dataButtonExpression("comparisonAdd", "true"), "UC3 add second temporary comparison pair"); current = await waitFor("UC3 second temporary pair commit", snapshot => snapshot.state.isBusy === false && snapshot.state.comparison?.savedPairs?.length === baseline.length + 2 && snapshot.state.comparison?.selectedPairId != null && snapshot.state.comparison.selectedPairId !== firstTemporaryRowId, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const secondTemporaryRowId = current.state.comparison.selectedPairId; await pointerClick(dataButtonExpression("comparisonMove", "up"), "UC3 second temporary pair UP"); await waitFor("UC3 second temporary pair UP commit", snapshot => snapshot.state.isBusy === false && snapshot.state.comparison?.savedPairs?.[baseline.length]?.rowId === secondTemporaryRowId, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick(dataButtonExpression("comparisonMove", "down"), "UC3 second temporary pair DOWN"); await waitFor("UC3 second temporary pair DOWN commit", snapshot => snapshot.state.isBusy === false && snapshot.state.comparison?.savedPairs?.at(-1)?.rowId === secondTemporaryRowId, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerDoubleClick(dataButtonExpression("comparisonTargetId", "kospi"), "UC3 fixed target double-click"); await waitFor("UC3 fixed target draft", snapshot => snapshot.state.comparison?.selectedFixedTargetId === "kospi" && snapshot.state.comparison?.firstTarget?.displayName === "코스피 지수", { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick(dataButtonExpression("comparisonDelete", "all"), "UC3 DEL ALL"); await respondToNativeDialog("UC3 DEL ALL cancel", true, false); const comparisonActions = await doubleClickAllEnabledPlaylistActions( "button[data-comparison-action-id]", "comparisonActionId", "UC3 action"); await pointerClick(dataButtonExpression("comparisonDelete", "selected"), "UC3 delete second temporary pair"); current = await waitFor("UC3 second temporary pair delete commit", snapshot => snapshot.state.isBusy === false && snapshot.state.comparison?.savedPairs?.length === baseline.length + 1 && !snapshot.state.comparison.savedPairs.some( row => row.rowId === secondTemporaryRowId), { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick(dataButtonExpression("comparisonPairId", firstTemporaryRowId), "UC3 select first temporary pair"); await waitFor("UC3 first temporary pair selected", snapshot => snapshot.state.comparison?.selectedPairId === firstTemporaryRowId, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick(dataButtonExpression("comparisonDelete", "selected"), "UC3 delete first temporary pair"); current = await waitFor("UC3 first temporary pair delete commit", snapshot => snapshot.state.isBusy === false && snapshot.state.comparison?.savedPairs?.length === baseline.length && !snapshot.state.comparison.savedPairs.some( row => row.rowId === firstTemporaryRowId), { timeoutMilliseconds: 60_000, allowUiBusy: true }); if (JSON.stringify(comparisonBaseline(current)) !== JSON.stringify(baseline)) { failKnown("UC3 baseline changed after deleting the temporary pair."); } await waitForTabModel("overseas", "fixedCatalog", "UC3 fresh-read detour"); current = await waitForTabModel("comparison", "comparison", "UC3 fresh-read return"); if (JSON.stringify(comparisonBaseline(current)) !== JSON.stringify(baseline)) { failKnown("UC3 persisted baseline did not survive fresh re-entry exactly."); } recordFullUiDbCheck("screenChecks", "UC3 all control buttons and temporary pair restore", { baselineRows: baseline.length, actionButtons: comparisonActions.length }); recordFullUiDbCheck("databaseChecks", "UC3 local pair save/fresh read/delete/restore", { rows: baseline.length }); } async function verifyThemeScreen() { let current = await waitForTabModel("theme", "theme", "UC4 theme tab"); await replaceText('document.getElementById("theme-search")', "", "UC4 theme query"); await pointerClick(dataButtonExpression("themeSearch", "true"), "UC4 theme search"); current = await waitFor("UC4 theme search results", snapshot => snapshot.state.isBusy === false && snapshot.state.theme?.searchResults?.length > 0, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const resultId = current.state.theme.searchResults[0].resultId; await pointerClick(dataButtonExpression("themeResultId", resultId), "UC4 theme row single-click"); await waitFor("UC4 theme preview", snapshot => snapshot.state.isBusy === false && snapshot.state.theme?.selectedResultId === resultId && snapshot.state.theme?.selectedTheme != null && snapshot.state.theme?.previewItems?.length > 0, { timeoutMilliseconds: 60_000, allowUiBusy: true }); for (const sortId of ["GAIN_DESC", "GAIN_ASC", "INPUT_ORDER"]) { await pointerClick( `document.querySelector('input[data-theme-sort][value="${sortId}"]')`, `UC4 theme sort ${sortId}`); await waitFor(`UC4 theme sort ${sortId} state`, snapshot => snapshot.state.theme?.sortId === sortId, { allowUiBusy: false }); } const beforeDouble = await readSnapshot(); await pointerDoubleClick(dataButtonExpression("themeResultId", resultId), "UC4 theme row double-click"); await waitFor("UC4 theme double-click add", snapshot => snapshot.state.playlist.length === beforeDouble.state.playlist.length + 1, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const actions = await clickAllEnabledPlaylistActions( "button[data-theme-action-id]", "themeActionId", "UC4 action"); recordFullUiDbCheck("screenChecks", "UC4 search, row, sort, double-click, all actions", { actionButtons: actions.length }); } async function verifyOverseasScreen() { let current = await waitForTabModel("overseasStocks", "overseas", "UC5 overseas tab"); const fixedTarget = current.state.overseas.fixedIndexTargets[1] || current.state.overseas.fixedIndexTargets[0]; await pointerClick(dataButtonExpression("overseasTargetId", fixedTarget.targetId), "UC5 fixed index row"); await waitFor("UC5 fixed index selected", snapshot => snapshot.state.overseas?.selectedFixedIndexId === fixedTarget.targetId, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const fixedActions = await clickAllEnabledPlaylistActions( "button[data-overseas-action-id]", "overseasActionId", "UC5 fixed action"); await replaceText('document.getElementById("overseas-industry-search")', "", "UC5 industry query"); await pointerClick(dataButtonExpression("overseasSearch", "industry"), "UC5 industry search"); current = await waitFor("UC5 industry results", snapshot => snapshot.state.isBusy === false && snapshot.state.overseas?.industry?.results?.length > 0, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const industryId = current.state.overseas.industry.results[0].selectionId; await pointerClick(dataButtonExpression("overseasSelectionId", industryId), "UC5 industry result"); await waitFor("UC5 industry selected", snapshot => snapshot.state.overseas?.industry?.selectedId === industryId, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const industryActionIds = current.state.overseas.actions .filter(action => action.source === "industry") .map(action => action.actionId); for (const actionId of industryActionIds) { await clickAndWaitForPlaylistAdd(dataButtonExpression("overseasActionId", actionId), `UC5 industry action ${actionId}`); } await replaceText('document.getElementById("overseas-stock-search")', "", "UC5 stock query"); await pointerClick(dataButtonExpression("overseasSearch", "stock"), "UC5 stock search"); current = await waitFor("UC5 stock results", snapshot => snapshot.state.isBusy === false && snapshot.state.overseas?.stock?.results?.length > 0, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const stockId = current.state.overseas.stock.results[0].selectionId; await pointerClick(dataButtonExpression("overseasSelectionId", stockId), "UC5 stock result"); current = await waitFor("UC5 stock selected", snapshot => snapshot.state.overseas?.stock?.selectedId === stockId, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const stockActionIds = current.state.overseas.actions .filter(action => action.source === "stock") .map(action => action.actionId); for (const actionId of stockActionIds) { await clickAndWaitForPlaylistAdd(dataButtonExpression("overseasActionId", actionId), `UC5 stock action ${actionId}`); } recordFullUiDbCheck("screenChecks", "UC5 fixed, industry, stock search/select/actions", { fixedActions: fixedActions.length, industryActions: industryActionIds.length, stockActions: stockActionIds.length }); } async function verifyExpertAndTradingHaltScreens() { let current = await waitForTabModel("expert", "expert", "UC6 expert tab"); await replaceText('document.getElementById("expert-search")', "", "UC6 expert query"); const expertSearchRevision = current.state.revision; await pointerClick(dataButtonExpression("expertSearch", "true"), "UC6 expert search"); current = await waitFor("UC6 expert results", snapshot => snapshot.state.isBusy === false && snapshot.state.revision > expertSearchRevision && snapshot.state.expert?.search?.results?.length > 0, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const expertId = current.state.expert.search.results[0].selectionId; await pointerClick(dataButtonExpression("expertSelectionId", expertId), "UC6 expert row"); await waitFor("UC6 expert preview", snapshot => snapshot.state.isBusy === false && snapshot.state.expert?.selectedExpert?.selectionId === expertId && snapshot.state.expert?.preview?.items?.length > 0, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const expertActions = await clickAllEnabledPlaylistActions( "button[data-expert-action-id]", "expertActionId", "UC6 action"); recordFullUiDbCheck("screenChecks", "UC6 search, row, action", { actionButtons: expertActions.length }); current = await waitForTabModel("tradingHalt", "tradingHalt", "UC7 trading-halt tab"); await replaceText('document.getElementById("halt-search")', "", "UC7 halt query"); const haltSearchRevision = current.state.revision; await pointerClick(dataButtonExpression("haltSearch", "true"), "UC7 halt search"); current = await waitFor("UC7 halt results", snapshot => snapshot.state.isBusy === false && snapshot.state.revision > haltSearchRevision && snapshot.state.tradingHalt?.status === "ready" && snapshot.state.tradingHalt?.results?.length > 0, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const beforeSort = current.state.tradingHalt.nameSort; await pointerClick(dataButtonExpression("haltSort", "true"), "UC7 halt sort"); current = await waitFor("UC7 halt sort state", snapshot => snapshot.state.tradingHalt?.nameSort !== beforeSort, { allowUiBusy: false }); const haltId = current.state.tradingHalt.results[0].selectionId; await pointerClick(dataButtonExpression("haltSelectionId", haltId), "UC7 halt row"); await waitFor("UC7 halt selection", snapshot => snapshot.state.tradingHalt?.selected?.selectionId === haltId, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const haltActions = await clickAllEnabledPlaylistActions( "button[data-halt-action-id]", "haltActionId", "UC7 action"); recordFullUiDbCheck("screenChecks", "UC7 search, sort, row, all actions", { actionButtons: haltActions.length }); } async function verifyGraphEActivationButtons() { await executeManualFinancialCrud(); } async function openFixedManualList(actionId, screen, audience, label) { await waitForTabModel("index", "fixedCatalog", `${label} index tab`); await pointerDoubleClick(dataButtonExpression("actionId", actionId), `${label} open`); return await waitFor(`${label} loaded`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualLists?.isOpen === true && snapshot.state.manualLists?.screen === screen && (audience == null || snapshot.state.manualLists?.audience === audience) && snapshot.dom.manualListModalHidden === false && snapshot.state.manualLists?.writesQuarantined !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } async function closeManualList(label) { await pointerClick('document.getElementById("manual-list-close")', label); await waitFor(`${label} complete`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualLists?.isOpen === false && snapshot.dom.manualListModalHidden === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } function netSellProjection(snapshot) { return snapshot.state.manualLists.netRows.map(row => ({ leftName: row.leftName, leftAmount: row.leftAmount, rightName: row.rightName, rightAmount: row.rightAmount })); } function viProjection(snapshot) { return snapshot.state.manualLists.viItems.map(row => ({ index: row.index, name: row.name })); } async function verifyManualListButtonsAndPersistence() { let current = await openFixedManualList( "fixed-245", "net-sell", "individual", "FSell individual"); const individualBaseline = netSellProjection(current); if (individualBaseline.length !== 5 || current.state.manualLists.canSave !== true) { failKnown("FSell individual baseline is not the original five-row saveable state."); } await pointerClickWithJavaScriptDialog( modalButtonByTextExpression("원본 1회 가져오기"), "FSell import cancel branch", "confirm", "원본 파일을 현재 빈 저장소로 한 번만 가져옵니다. 기존 파일은 덮어쓰지 않습니다.", false); const beforeRefresh = await readSnapshot(); await pointerClick(modalButtonByTextExpression("재조회"), "FSell refresh"); await waitFor("FSell refresh result", snapshot => snapshot.state.isBusy === false && snapshot.state.revision > beforeRefresh.state.revision && snapshot.state.manualLists?.netRowsAreFresh === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); for (const [label, audience] of [["외국인", "foreign"], ["기관", "institution"], ["개인", "individual"]]) { await pointerClick(modalButtonByTextExpression(label), `FSell ${label} tab`); await waitFor(`FSell ${label} tab state`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualLists?.audience === audience && snapshot.state.manualLists?.netRows?.length === 5, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } await closeManualList("FSell cancel button"); await openFixedManualList("fixed-246", "net-sell", "foreign", "FSell foreign entry"); await closeManualList("FSell foreign close"); await openFixedManualList("fixed-247", "net-sell", "institution", "FSell institution entry"); await closeManualList("FSell institution close"); current = await openFixedManualList( "fixed-245", "net-sell", "individual", "FSell save reopen"); const beforeFsellSaveCount = current.state.playlist.length; await pointerClick(modalButtonByTextExpression("확인"), "FSell confirm save"); await waitFor("FSell save, readback, add, close", snapshot => snapshot.state.isBusy === false && snapshot.state.manualLists?.isOpen === false && snapshot.dom.manualListModalHidden === true && snapshot.state.playlist.length === beforeFsellSaveCount + 1, { timeoutMilliseconds: 60_000, allowUiBusy: true }); current = await openFixedManualList( "fixed-245", "net-sell", "individual", "FSell fresh read"); if (JSON.stringify(netSellProjection(current)) !== JSON.stringify(individualBaseline)) { failKnown("FSell fresh read did not preserve the exact five-row baseline."); } await closeManualList("FSell fresh read close"); recordFullUiDbCheck("screenChecks", "FSell all entry/tab/import/refresh/cancel/confirm buttons"); recordFullUiDbCheck("databaseChecks", "FSell trusted file save/fresh read", { rows: 5 }); current = await openFixedManualList("fixed-262", "vi", null, "VIList entry"); const viBaseline = viProjection(current); if (viBaseline.length === 0 || current.state.manualLists.canSave !== true) { failKnown("VIList baseline is empty or not saveable."); } const beforeViRefresh = await readSnapshot(); await pointerClick(modalButtonByTextExpression("재조회"), "VIList refresh"); await waitFor("VIList refresh result", snapshot => snapshot.state.isBusy === false && snapshot.state.revision > beforeViRefresh.state.revision && snapshot.state.manualLists?.viItemsAreFresh === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await replaceText( 'document.querySelector("#manual-list-workspace .manual-list-search input")', "삼성출판사", "VIList stock query"); await pointerClick(modalButtonByTextExpression("검색"), "VIList search"); current = await waitFor("VIList search results", snapshot => snapshot.state.isBusy === false && snapshot.state.manualLists?.searchResults?.some(row => row.displayName === "삼성출판사"), { timeoutMilliseconds: 60_000, allowUiBusy: true }); const viResult = current.state.manualLists.searchResults .find(row => row.displayName === "삼성출판사"); const viResultExpression = `Array.from(document.querySelectorAll( "#manual-list-workspace .manual-list-search-results button")) .find(button => button.textContent.startsWith("삼성출판사 ·"))`; await pointerClick(viResultExpression, "VIList result single-click"); await waitFor("VIList result selected", snapshot => snapshot.state.manualLists?.selectedSearchResultId === viResult.resultId, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerDoubleClick(viResultExpression, "VIList result double-click add"); current = await waitFor("VIList temporary item add", snapshot => snapshot.state.manualLists?.viItems?.length === viBaseline.length + 1, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const temporaryItemId = current.state.manualLists.viItems.at(-1).itemId; await pointerClick( 'document.querySelector("#manual-list-workspace tbody tr:last-child button:nth-of-type(1)")', "VIList temporary item UP"); await waitFor("VIList temporary item UP state", snapshot => snapshot.state.manualLists?.viItems?.at(-2)?.itemId === temporaryItemId, { allowUiBusy: false }); await pointerClick( `Array.from(document.querySelectorAll("#manual-list-workspace tbody tr")) .find((row, index) => index === ${viBaseline.length - 1}) ?.querySelector("button:nth-of-type(2)")`, "VIList temporary item DOWN"); await waitFor("VIList temporary item DOWN state", snapshot => snapshot.state.manualLists?.viItems?.at(-1)?.itemId === temporaryItemId, { allowUiBusy: false }); await pointerClick( 'document.querySelector("#manual-list-workspace tbody tr:last-child button:nth-of-type(3)")', "VIList temporary item delete"); await waitFor("VIList temporary item delete state", snapshot => snapshot.state.manualLists?.viItems?.length === viBaseline.length && !snapshot.state.manualLists.viItems.some(row => row.itemId === temporaryItemId), { allowUiBusy: false }); await pointerClickWithJavaScriptDialog( modalButtonByTextExpression("전체 삭제"), "VIList delete-all cancel branch", "confirm", "VI 목록을 모두 지우시겠습니까?", false); const beforeViSave = await readSnapshot(); await pointerClick(modalButtonByTextExpression("확인"), "VIList confirm save"); await waitFor("VIList save, readback, add, close", snapshot => snapshot.state.isBusy === false && snapshot.state.manualLists?.isOpen === false && snapshot.dom.manualListModalHidden === true && snapshot.state.playlist.length === beforeViSave.state.playlist.length + 1, { timeoutMilliseconds: 60_000, allowUiBusy: true }); current = await openFixedManualList("fixed-262", "vi", null, "VIList fresh read"); if (JSON.stringify(viProjection(current)) !== JSON.stringify(viBaseline)) { failKnown("VIList fresh read did not preserve the exact baseline."); } await closeManualList("VIList fresh read close"); recordFullUiDbCheck("screenChecks", "VIList search/select/double-click/up/down/delete/all/refresh/confirm buttons"); recordFullUiDbCheck("databaseChecks", "VIList trusted file save/fresh read", { rows: viBaseline.length }); } async function clearScreenValidationPlaylist() { const current = await readSnapshot(); if (current.state.playlist.length === 0) return; await pointerClickWithJavaScriptDialog( 'document.getElementById("playlist-delete-all")', "screen validation playlist cleanup", "confirm", "송출 리스트를 모두 삭제하겠습니까?", true); await waitFor("screen validation playlist cleanup state", snapshot => snapshot.state.isBusy === false && snapshot.state.playlist.length === 0, { allowUiBusy: false }); } async function executeScreenInventory() { await verifyFixedAndIndustryScreens(); await verifyComparisonScreen(); await verifyThemeScreen(); await verifyOverseasScreen(); await verifyExpertAndTradingHaltScreens(); await verifyGraphEActivationButtons(); await verifyManualListButtonsAndPersistence(); for (const [tabId, stateProperty] of [ ["overseas", "fixedCatalog"], ["exchange", "fixedCatalog"], ["index", "fixedCatalog"], ["kospiIndustry", "industry"], ["kosdaqIndustry", "industry"], ["comparison", "comparison"], ["theme", "theme"], ["overseasStocks", "overseas"], ["expert", "expert"], ["tradingHalt", "tradingHalt"] ]) { await waitForTabModel(tabId, stateProperty, `final screen inventory ${tabId}`); await captureVisibleScreenInventory(tabId); } await clearScreenValidationPlaylist(); recordFullUiDbCheck("screenChecks", "UC1-UC7 final packaged button inventory"); } async function executeFullUiDbSequence() { if (!fullUiDbProfile) return; if (configuration.scope === "all" || configuration.scope === "plist") { await executeNamedPlaylistCrud(); } if (configuration.scope === "all" || configuration.scope === "graphe") { await executeManualFinancialCrud(); } if (configuration.scope === "all" || configuration.scope === "catalog") { await executeOperatorCatalogCrud(); } if (configuration.scope === "all" || configuration.scope === "screens") { await executeScreenInventory(); } evidence.fullUiDb.result = "PASS"; evidence.fullUiDb.cleanupVerified = true; const current = await readSnapshot(); if (!current.dom.namedModalHidden || !current.dom.namedConfirmationHidden || !current.dom.manualFinancialModalHidden || !current.dom.manualListModalHidden || !current.dom.operatorCatalogModalHidden) { failKnown("A child modal remained open after the full UI/DB sequence."); } 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 executeDryRunPlayoutSequence(); await executeFullUiDbSequence(); 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 })); }