diff --git a/scripts/Invoke-LegacyPackageInputSmoke.ps1 b/scripts/Invoke-LegacyPackageInputSmoke.ps1 index adfc42e..241ff1c 100644 --- a/scripts/Invoke-LegacyPackageInputSmoke.ps1 +++ b/scripts/Invoke-LegacyPackageInputSmoke.ps1 @@ -2304,7 +2304,9 @@ function Read-AndAssertHarnessEvidence( $HarnessProfile -ceq 'DryRunPlayout') { $allowedOutbound = @( 'ready', 'search-stocks', 'select-stock', 'cut-pointer-down', - 'cut-key-down', 'drop-selected-cuts', 'activate-playlist-row', + 'cut-key-down', 'drop-selected-cuts', 'select-tab', + 'hover-swap-tabs', + 'activate-playlist-row', 'select-playlist-row', 'reorder-playlist-rows', 'select-playlist-boundary', 'refresh-named-playlists') if ($HarnessProfile -ceq 'DryRunPlayout') { diff --git a/scripts/Test-LegacyPackageInput.mjs b/scripts/Test-LegacyPackageInput.mjs index 7a41546..c2054e0 100644 --- a/scripts/Test-LegacyPackageInput.mjs +++ b/scripts/Test-LegacyPackageInput.mjs @@ -36,6 +36,8 @@ const readOnlyAllowedOutboundMessageTypes = Object.freeze([ "cut-pointer-down", "cut-key-down", "drop-selected-cuts", + "select-tab", + "hover-swap-tabs", "activate-playlist-row", "select-playlist-row", "reorder-playlist-rows", @@ -44,7 +46,6 @@ const readOnlyAllowedOutboundMessageTypes = Object.freeze([ ]); const fullUiDbAllowedOutboundMessageTypes = Object.freeze([ ...readOnlyAllowedOutboundMessageTypes, - "select-tab", "activate-fixed-action", "activate-fixed-section", "select-industry", @@ -466,10 +467,13 @@ let cancellationInputBuffer = ""; let pointerIsPressed = false; let pointerReleasePoint = null; let pointerReleaseModifiers = 0; +let dragInterceptionEnabled = false; +let activeInterceptedDragData = null; let probeInstalled = false; let cleanupMode = false; let cleanupDeadlineEpochMilliseconds = 0; let expectedJavaScriptDialog = null; +let pendingDragIntercept = null; const unexpectedJavaScriptDialogs = []; process.stdin.setEncoding("utf8"); @@ -488,6 +492,13 @@ process.stdin.on("data", chunk => { `Wrapper cancellation interrupted ${pending.method}; the command is not retried.`)); } pendingRequests.clear(); + if (pendingDragIntercept) { + clearTimeout(pendingDragIntercept.timer); + pendingDragIntercept.reject(new HarnessFailure( + "OUTCOME_UNKNOWN", + "Wrapper cancellation interrupted the intercepted drag; it is not retried.")); + pendingDragIntercept = null; + } }); if (typeof process.stdin.unref === "function") process.stdin.unref(); @@ -568,6 +579,15 @@ async function connect(endpoint) { socket.addEventListener("message", event => { const message = JSON.parse(String(event.data)); + if (message.method === "Input.dragIntercepted") { + const pending = pendingDragIntercept; + if (pending) { + pendingDragIntercept = null; + clearTimeout(pending.timer); + pending.resolve(message.params?.data || null); + } + return; + } if (message.method === "Page.javascriptDialogOpening") { const dialog = message.params || {}; const expected = expectedJavaScriptDialog; @@ -608,13 +628,49 @@ async function connect(endpoint) { `CDP socket closed while waiting for ${pending.method}.`)); } pendingRequests.clear(); + if (pendingDragIntercept) { + clearTimeout(pendingDragIntercept.timer); + pendingDragIntercept.reject(new HarnessFailure( + "OUTCOME_UNKNOWN", + "CDP socket closed while waiting for the intercepted drag.")); + pendingDragIntercept = null; + } }); } +function waitForDragIntercept(label) { + if (pendingDragIntercept) { + failKnown("A second intercepted drag was requested before the first completed."); + } + const promise = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingDragIntercept = null; + reject(new HarnessFailure( + "KNOWN_FAILURE", + `${label} did not expose Chromium's intercepted drag data.`)); + }, 5_000); + pendingDragIntercept = { resolve, reject, timer }; + }); + // A pointer or transport failure can happen before the caller reaches its + // await. Attach a handler immediately; the caller still observes the same + // rejection when it awaits this promise during structured cleanup. + void promise.catch(() => {}); + return promise; +} + +function cancelPendingDragIntercept(message) { + const pending = pendingDragIntercept; + if (!pending) return; + pendingDragIntercept = null; + clearTimeout(pending.timer); + pending.reject(new HarnessFailure("KNOWN_FAILURE", message)); +} + function rpc(method, params = {}, timeoutMilliseconds = 10_000) { assertWithinHardDeadline(`before ${method}`); if (cleanupMode && method !== "Input.dispatchMouseEvent" && - method !== "Runtime.evaluate") { + method !== "Input.dispatchDragEvent" && + method !== "Input.setInterceptDrags" && method !== "Runtime.evaluate") { failKnown(`Cleanup refused unexpected CDP method ${method}.`); } if (forbiddenCdpMethods.has(method)) { @@ -701,6 +757,7 @@ function probeSnapshotExpression() { capturedAt: new Date().toISOString(), devicePixelRatio: window.devicePixelRatio, stateCount: probe?.stateCount ?? null, + busyStateCount: probe?.busyStateCount ?? null, safety: { postMessageWrapped: probe?.postMessageWrapper != null && window.chrome?.webview?.postMessage === probe.postMessageWrapper, @@ -1061,9 +1118,12 @@ async function readWorkspaceLayout() { const surface = document.querySelector(".workspace-surface"); const toggle = document.getElementById("workspace-nav-toggle"); const stockTab = document.getElementById("workspace-stock-tab"); - const catalogTab = document.getElementById("workspace-catalog-tab"); + const settingsTab = document.getElementById("workspace-settings-tab"); + const marketTabs = Array.from( + document.querySelectorAll("#market-tabs > button[data-tab-id]")); const stock = document.getElementById("stock-workspace"); const catalog = document.getElementById("catalog-workspace"); + const settings = document.getElementById("settings-workspace"); const schedule = document.querySelector(".playlist-panel"); const scheduleBounds = schedule?.getBoundingClientRect() || null; const scheduleHit = scheduleBounds && scheduleBounds.width > 0 && scheduleBounds.height > 0 @@ -1086,6 +1146,16 @@ async function readWorkspaceLayout() { navigationExpanded: toggle?.getAttribute("aria-expanded") === "true", navigationCollapsedClass: region?.classList.contains("nav-collapsed") === true, title: (document.getElementById("workspace-title")?.textContent || "").trim(), + activeMarketTab: region?.dataset?.activeMarketTab || null, + currentMenuItemCount: document.querySelectorAll( + "#workspace-navigation .workspace-nav-item[aria-current='page']").length, + marketTabs: marketTabs.map(button => ({ + tabId: button.dataset.tabId, + label: (button.querySelector(".workspace-nav-label")?.textContent || "").trim(), + current: button.getAttribute("aria-current") || null, + focused: document.activeElement === button, + hasIcon: Boolean(button.querySelector("svg")) + })), stock: { hidden: stock?.hidden === true, inert: stock?.hasAttribute("inert") === true, @@ -1098,9 +1168,16 @@ async function readWorkspaceLayout() { hidden: catalog?.hidden === true, inert: catalog?.hasAttribute("inert") === true, ariaHidden: catalog?.getAttribute("aria-hidden") || null, - tabCurrent: catalogTab?.getAttribute("aria-current") || null, width: catalog?.getBoundingClientRect().width ?? null, height: catalog?.getBoundingClientRect().height ?? null + }, + settings: { + hidden: settings?.hidden === true, + inert: settings?.hasAttribute("inert") === true, + ariaHidden: settings?.getAttribute("aria-hidden") || null, + tabCurrent: settingsTab?.getAttribute("aria-current") || null, + width: settings?.getBoundingClientRect().width ?? null, + height: settings?.getBoundingClientRect().height ?? null } }; })()`); @@ -1109,13 +1186,31 @@ async function readWorkspaceLayout() { function workspaceLayoutMatches(layout, workspace, expanded = null) { if (!layout || layout.activeWorkspace !== workspace) return false; if (expanded !== null && layout.navigationExpanded !== expanded) return false; - const selected = workspace === "stock" ? layout.stock : layout.catalog; - const other = workspace === "stock" ? layout.catalog : layout.stock; - return selected.hidden === false && selected.inert === false && - selected.ariaHidden === "false" && selected.tabCurrent === "page" && - selected.width > 0 && selected.height > 0 && - other.hidden === true && other.inert === true && - other.ariaHidden === "true" && other.tabCurrent === "false"; + if (layout.scheduleHitOwned !== true || layout.currentMenuItemCount !== 1 || + layout.marketTabs?.length !== 10 || + layout.marketTabs.some(tab => tab.hasIcon !== true)) return false; + const visible = view => view?.hidden === false && view.inert === false && + view.ariaHidden === "false" && view.width > 0 && view.height > 0; + const hidden = view => view?.hidden === true && view.inert === true && + view.ariaHidden === "true"; + const currentMarkets = layout.marketTabs.filter(tab => tab.current === "page"); + + if (workspace === "stock") { + return visible(layout.stock) && hidden(layout.catalog) && hidden(layout.settings) && + layout.stock.tabCurrent === "page" && layout.settings.tabCurrent === "false" && + currentMarkets.length === 0; + } + if (workspace === "settings") { + return hidden(layout.stock) && hidden(layout.catalog) && visible(layout.settings) && + layout.stock.tabCurrent === "false" && layout.settings.tabCurrent === "page" && + currentMarkets.length === 0; + } + if (workspace === "catalog") { + return hidden(layout.stock) && visible(layout.catalog) && hidden(layout.settings) && + layout.stock.tabCurrent === "false" && layout.settings.tabCurrent === "false" && + currentMarkets.length === 1 && currentMarkets[0].tabId === layout.activeMarketTab; + } + return false; } async function waitForWorkspaceLayout(label, predicate) { @@ -1133,7 +1228,8 @@ function assertWorkspaceAndScheduleGeometry(layout, label) { if (!layout?.shell || !layout.region || !layout.navigation || !layout.surface || !layout.schedule || layout.schedule.width <= 0 || layout.schedule.height <= 0 || layout.scheduleHitOwned !== true) { - failKnown(`${label} does not expose the complete workspace and fixed schedule geometry.`); + failKnown(`${label} does not expose the complete workspace and fixed schedule geometry: ` + + JSON.stringify(layout)); } const widthRatio = layout.region.width / layout.schedule.width; const workspaceShare = layout.region.width / layout.shell.width; @@ -1157,16 +1253,21 @@ function assertFixedSchedule(baseline, current, label) { } async function ensureWorkspace(workspace, label) { - const tabId = workspace === "stock" - ? "workspace-stock-tab" - : workspace === "catalog" - ? "workspace-catalog-tab" - : null; - if (!tabId) failKnown(`${label} requested an unknown workspace.`); - let layout = await readWorkspaceLayout(); if (!workspaceLayoutMatches(layout, workspace)) { - await pointerClick(`document.getElementById("${tabId}")`, label); + let expression = null; + if (workspace === "stock") { + expression = 'document.getElementById("workspace-stock-tab")'; + } else if (workspace === "settings") { + expression = 'document.getElementById("workspace-settings-tab")'; + } else if (workspace === "catalog") { + const activeTabId = layout.activeMarketTab || layout.marketTabs?.[0]?.tabId; + if (activeTabId) { + expression = `document.querySelector('#market-tabs > button[data-tab-id="${activeTabId}"]')`; + } + } + if (!expression) failKnown(`${label} requested an unknown workspace.`); + await pointerClick(expression, label); layout = await waitForWorkspaceLayout(label, candidate => workspaceLayoutMatches(candidate, workspace)); } @@ -1179,6 +1280,10 @@ async function verifyWorkspaceNavigationAndLayout(preflight) { if (baseline.navigationExpanded !== true || baseline.navigationCollapsedClass === true) { failKnown("The workspace menu was not initially expanded."); } + const firstMarket = baseline.marketTabs[0]; + if (!firstMarket?.tabId || !firstMarket.label) { + failKnown("The first graphic menu item is missing its native identity or label."); + } const baselineRatio = assertWorkspaceAndScheduleGeometry( baseline, "expanded stock workspace preflight"); @@ -1195,13 +1300,21 @@ async function verifyWorkspaceNavigationAndLayout(preflight) { } assertFixedSchedule(baseline, collapsed, "collapsed workspace menu"); + const beforeGraphicSelection = await readSnapshot(); + assertSafety(beforeGraphicSelection, "first graphic native selection preflight", false); await pointerClick( - 'document.getElementById("workspace-catalog-tab")', - "select catalog workspace from collapsed menu"); - const catalog = await waitForWorkspaceLayout("catalog workspace", layout => + `document.querySelector('#market-tabs > button[data-tab-id="${firstMarket.tabId}"]')`, + "select first graphic screen from collapsed menu"); + const catalog = await waitForWorkspaceLayout("first graphic workspace", layout => workspaceLayoutMatches(layout, "catalog", false) && - layout.title === "기타 그래픽"); - assertFixedSchedule(baseline, catalog, "catalog workspace transition"); + layout.title === firstMarket.label && layout.activeMarketTab === firstMarket.tabId); + assertFixedSchedule(baseline, catalog, "first graphic workspace transition"); + let nativeState = await waitFor("first graphic native selection", snapshot => + snapshot.stateCount >= beforeGraphicSelection.stateCount + 2 && + snapshot.busyStateCount > beforeGraphicSelection.busyStateCount && + snapshot.state.isBusy === false && + snapshot.state.tabs?.some(tab => tab.id === firstMarket.tabId && tab.isActive) === true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick( 'document.getElementById("workspace-nav-toggle")', @@ -1215,6 +1328,41 @@ async function verifyWorkspaceNavigationAndLayout(preflight) { } assertFixedSchedule(baseline, expandedCatalog, "expanded catalog workspace"); + const secondMarket = baseline.marketTabs[1]; + if (!secondMarket?.tabId || secondMarket.tabId === firstMarket.tabId) { + failKnown("The second graphic menu item is unavailable for vertical drag verification."); + } + const swappedMarketTabs = await dragMarketTab( + firstMarket.tabId, + secondMarket.tabId, + "vertically reorder graphic menu items"); + assertFixedSchedule(baseline, swappedMarketTabs.layout, + "vertical graphic menu reorder"); + const restoredMarketTabs = await dragMarketTab( + firstMarket.tabId, + secondMarket.tabId, + "restore graphic menu item order"); + assertFixedSchedule(baseline, restoredMarketTabs.layout, + "restored graphic menu order"); + if (!restoredMarketTabs.layout.marketTabs.every( + (tab, index) => tab.tabId === baseline.marketTabs[index].tabId)) { + failKnown("The vertical graphic menu drag did not restore the original native order."); + } + const beforeSettingsMessageCount = + restoredMarketTabs.completed.safety.outboundMessages.length; + + await pointerClick( + 'document.getElementById("workspace-settings-tab")', + "open settings workspace"); + const settings = await waitForWorkspaceLayout("settings workspace", layout => + workspaceLayoutMatches(layout, "settings", true) && layout.title === "설정"); + assertFixedSchedule(baseline, settings, "settings workspace transition"); + nativeState = await readSnapshot(); + assertSafety(nativeState, "settings workspace local transition", false); + if (nativeState.safety.outboundMessages.length !== beforeSettingsMessageCount) { + failKnown("The local Settings menu emitted a native WebView message."); + } + await pointerClick( 'document.getElementById("workspace-stock-tab")', "return to stock workspace"); @@ -1225,20 +1373,31 @@ async function verifyWorkspaceNavigationAndLayout(preflight) { if (await evaluate('document.activeElement?.id === "workspace-stock-tab"') !== true) { failKnown("The pointer-selected stock menu item did not retain keyboard focus."); } - await pressKey("ArrowDown", "focus catalog workspace menu item"); - if (await evaluate('document.activeElement?.id === "workspace-catalog-tab"') !== true) { - failKnown("ArrowDown did not move focus to the catalog workspace menu item."); + await pressKey("ArrowDown", "focus first graphic menu item"); + if (await evaluate( + `document.activeElement?.dataset?.tabId === ${JSON.stringify(firstMarket.tabId)}`) !== true) { + failKnown("ArrowDown did not move focus to the first graphic menu item."); } const focusOnlyStock = await readWorkspaceLayout(); if (!workspaceLayoutMatches(focusOnlyStock, "stock", true)) { failKnown("ArrowDown changed the workspace before explicit keyboard activation."); } - assertFixedSchedule(baseline, focusOnlyStock, "keyboard catalog focus"); + assertFixedSchedule(baseline, focusOnlyStock, "keyboard graphic focus"); - await pressKey("Enter", "activate catalog workspace from keyboard"); - const keyboardCatalog = await waitForWorkspaceLayout("keyboard catalog workspace", layout => - workspaceLayoutMatches(layout, "catalog", true) && layout.title === "기타 그래픽"); - assertFixedSchedule(baseline, keyboardCatalog, "keyboard catalog activation"); + const beforeKeyboardGraphicSelection = await readSnapshot(); + assertSafety(beforeKeyboardGraphicSelection, + "keyboard graphic native selection preflight", false); + await pressKey("Enter", "activate first graphic screen from keyboard"); + const keyboardCatalog = await waitForWorkspaceLayout("keyboard graphic workspace", layout => + workspaceLayoutMatches(layout, "catalog", true) && + layout.title === firstMarket.label && layout.activeMarketTab === firstMarket.tabId); + assertFixedSchedule(baseline, keyboardCatalog, "keyboard graphic activation"); + await waitFor("keyboard graphic native selection", snapshot => + snapshot.stateCount >= beforeKeyboardGraphicSelection.stateCount + 2 && + snapshot.busyStateCount > beforeKeyboardGraphicSelection.busyStateCount && + snapshot.state.isBusy === false && + snapshot.state.tabs?.some(tab => tab.id === firstMarket.tabId && tab.isActive) === true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pressKey("ArrowUp", "focus stock workspace menu item"); if (await evaluate('document.activeElement?.id === "workspace-stock-tab"') !== true) { @@ -1249,12 +1408,41 @@ async function verifyWorkspaceNavigationAndLayout(preflight) { workspaceLayoutMatches(layout, "stock", true) && layout.title === "종목·컷"); assertFixedSchedule(baseline, keyboardStock, "keyboard stock activation"); + await pressKey("ArrowUp", "wrap focus to settings menu item"); + if (await evaluate('document.activeElement?.id === "workspace-settings-tab"') !== true) { + failKnown("ArrowUp did not wrap focus from Stock to Settings."); + } + const focusOnlyStockBeforeSettings = await readWorkspaceLayout(); + if (!workspaceLayoutMatches(focusOnlyStockBeforeSettings, "stock", true)) { + failKnown("Focusing Settings changed the workspace before keyboard activation."); + } + await pressKey("Enter", "activate settings workspace from keyboard"); + const keyboardSettings = await waitForWorkspaceLayout("keyboard settings workspace", layout => + workspaceLayoutMatches(layout, "settings", true) && layout.title === "설정"); + assertFixedSchedule(baseline, keyboardSettings, "keyboard settings activation"); + + await pointerClick( + 'document.getElementById("workspace-stock-tab")', + "restore stock workspace after keyboard navigation"); + const finalStock = await waitForWorkspaceLayout("final stock workspace", layout => + workspaceLayoutMatches(layout, "stock", true) && layout.title === "종목·컷"); + assertFixedSchedule(baseline, finalStock, "final stock workspace"); + const after = await readSnapshot(); assertSafety(after, "workspace navigation/layout input", false); - if (after.safety.outboundMessages.length !== preflight.safety.outboundMessages.length || + const navigationMessages = after.safety.outboundMessages.slice( + preflight.safety.outboundMessages.length); + const selectTabMessages = navigationMessages.filter( + message => message.type === "select-tab"); + const hoverSwapMessages = navigationMessages.filter( + message => message.type === "hover-swap-tabs"); + if (navigationMessages.length !== 4 || selectTabMessages.length !== 2 || + hoverSwapMessages.length !== 2 || + navigationMessages.some(message => + message.type !== "select-tab" && message.type !== "hover-swap-tabs") || after.safety.blockedOutboundMessages.length !== preflight.safety.blockedOutboundMessages.length) { - failKnown("Workspace-only pointer input crossed the native WebView message boundary."); + failKnown("Sidebar navigation emitted duplicate or unexpected native messages."); } await checkpoint("workspace-navigation-layout", { @@ -1263,8 +1451,17 @@ async function verifyWorkspaceNavigationAndLayout(preflight) { expandedNavigationWidth: baseline.navigation.width, collapsedNavigationWidth: collapsed.navigation.width, schedule: baseline.schedule, - pointerTransitions: ["collapse", "catalog", "expand", "stock"], - keyboardTransitions: ["ArrowDown", "Enter catalog", "ArrowUp", "Enter stock"] + graphicMenuCount: baseline.marketTabs.length, + pointerTransitions: ["collapse", firstMarket.tabId, "expand", "settings", "stock"], + verticalMenuDrag: [ + `${firstMarket.tabId} -> ${secondMarket.tabId}`, + `${firstMarket.tabId} -> ${secondMarket.tabId} (restore)` + ], + keyboardTransitions: [ + "ArrowDown", `Enter ${firstMarket.tabId}`, "ArrowUp", "Enter stock", + "ArrowUp wrap", "Enter settings" + ], + nativeNavigationMessages: navigationMessages.map(message => message.type) }); } @@ -1375,6 +1572,42 @@ async function releaseMouse(point, modifiers = 0) { pointerReleaseModifiers = 0; } +async function cleanupDragInputSession(cancelActiveDrag = true) { + const failures = []; + const point = pointerReleasePoint || { x: 1, y: 1 }; + if (cancelActiveDrag && activeInterceptedDragData) { + try { + await rpc("Input.dispatchDragEvent", { + type: "dragCancel", + x: point.x, + y: point.y, + data: activeInterceptedDragData + }); + activeInterceptedDragData = null; + } catch (error) { + failures.push(`drag cancel: ${error.message}`); + } + } + if (pointerIsPressed) { + try { + await releaseMouse(point, pointerReleaseModifiers); + } catch (error) { + failures.push(`pointer release: ${error.message}`); + } + } + if (dragInterceptionEnabled) { + try { + await rpc("Input.setInterceptDrags", { enabled: false }); + dragInterceptionEnabled = false; + } catch (error) { + failures.push(`drag interception disable: ${error.message}`); + } + } + if (failures.length > 0) { + failUnknown(`Input cleanup did not complete (${failures.join("; ")}).`); + } +} + async function waitForPointerReady( label, allowLegacyDialog = false, @@ -1411,6 +1644,113 @@ async function pointerClick(expression, label, modifiers = 0, options = {}) { return point; } +async function dragMarketTab(sourceTabId, targetTabId, label) { + await waitForPointerReady(label); + const beforeLayout = await readWorkspaceLayout(); + const beforeOrder = beforeLayout.marketTabs.map(tab => tab.tabId); + const sourceIndex = beforeOrder.indexOf(sourceTabId); + const targetIndex = beforeOrder.indexOf(targetTabId); + if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) { + failKnown(`${label} does not have two distinct visible market menu items.`); + } + const expectedOrder = beforeOrder.slice(); + expectedOrder[sourceIndex] = targetTabId; + expectedOrder[targetIndex] = sourceTabId; + const source = await elementGeometry( + `document.querySelector('#market-tabs > button[data-tab-id="${sourceTabId}"]')`, + `${label} source`); + const target = await elementGeometry( + `document.querySelector('#market-tabs > button[data-tab-id="${targetTabId}"]')`, + `${label} target`); + const before = await readSnapshot(); + assertSafety(before, `${label} preflight`, false); + evidence.inputs.push({ + type: "market-tab-drag", + label, + sourceTabId, + targetTabId, + source, + target, + beforeOrder, + expectedOrder + }); + + let dragData = null; + let dragCompleted = false; + let operationError = null; + let intercepted = null; + // Record the attempt before awaiting the acknowledgement. If Chromium + // applies the command but its response is lost, bounded outer cleanup must + // still send and confirm the idempotent disable command once. + dragInterceptionEnabled = true; + try { + await rpc("Input.setInterceptDrags", { enabled: true }); + intercepted = waitForDragIntercept(label); + await pressMouse(source, 0); + pointerReleasePoint = source; + const direction = target.y >= source.y ? 1 : -1; + await moveMouse({ x: source.x, y: source.y + direction * 10 }, 0, 1); + dragData = await intercepted; + activeInterceptedDragData = dragData; + const marketItem = dragData?.items?.find(item => item.mimeType === "text/x-mbn-tab"); + if (!marketItem || marketItem.data !== sourceTabId) { + failKnown(`${label} did not preserve the closed market identity in drag data.`); + } + await rpc("Input.dispatchDragEvent", { + type: "dragEnter", + x: target.x, + y: target.y, + data: dragData + }); + await rpc("Input.dispatchDragEvent", { + type: "dragOver", + x: target.x, + y: target.y, + data: dragData + }); + await sleep(100); + await rpc("Input.dispatchDragEvent", { + type: "drop", + x: target.x, + y: target.y, + data: dragData + }); + activeInterceptedDragData = null; + dragCompleted = true; + await releaseMouse(target, 0); + } catch (error) { + operationError = error; + throw error; + } finally { + if (pendingDragIntercept) { + cancelPendingDragIntercept( + `${label} ended before Chromium supplied intercepted drag data.`); + } + if (intercepted) await intercepted.catch(() => null); + try { + await cleanupDragInputSession(!dragCompleted); + } catch (cleanupError) { + if (!operationError) throw cleanupError; + // Keep the original operation failure. The outer cleanup switches to a + // bounded cleanup mode and makes one final release/disable attempt. + } + } + + const completed = await waitFor(`${label} native swap`, snapshot => { + const order = snapshot.state.tabs?.map(tab => tab.id) || []; + return snapshot.safety.outboundMessages.length === + before.safety.outboundMessages.length + 1 && + snapshot.safety.outboundMessages.at(-1)?.type === "hover-swap-tabs" && + order.length === expectedOrder.length && + order.every((tabId, index) => tabId === expectedOrder[index]); + }, { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const completedLayout = await waitForWorkspaceLayout(`${label} menu order`, layout => + layout.marketTabs.length === expectedOrder.length && + layout.marketTabs.every((tab, index) => tab.tabId === expectedOrder[index])); + assertWorkspaceAndScheduleGeometry(completedLayout, `${label} menu order`); + return { completed, layout: completedLayout, beforeOrder, expectedOrder }; +} + const keyDefinitions = Object.freeze({ ArrowDown: { key: "ArrowDown", code: "ArrowDown", virtualKey: 40 }, ArrowUp: { key: "ArrowUp", code: "ArrowUp", virtualKey: 38 }, @@ -2066,6 +2406,7 @@ async function installProbe() { const probe = { token: ${JSON.stringify(token)}, stateCount: 0, + busyStateCount: 0, states: [], listener: null, pointerEventTypes: [ @@ -2090,6 +2431,7 @@ async function installProbe() { if (event.data?.type !== "state" || !event.data.payload) return; const payload = event.data.payload; probe.stateCount += 1; + if (payload.isBusy === true) probe.busyStateCount += 1; probe.states.push(structuredClone(payload)); if (probe.states.length > 750) probe.states.shift(); const playout = payload.playout; @@ -3162,24 +3504,34 @@ async function executeManualFinancialCrud() { } async function selectMainTab(tabId, label) { - await ensureWorkspace("catalog", `${label} catalog workspace`); 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 layout = await readWorkspaceLayout(); const before = await readSnapshot(); assertSafety(before, `${label} preflight`); const alreadyActive = before.state.tabs?.some( tab => tab.id === tabId && tab.isActive) === true; - if (!alreadyActive) { + const selectionRequired = !alreadyActive || layout.activeWorkspace !== "catalog" || + layout.activeMarketTab !== tabId; + if (selectionRequired) { await pointerClick( - `document.querySelector('button[data-tab-id="${tabId}"]')`, + `document.querySelector('#market-tabs > button[data-tab-id="${tabId}"]')`, label); } - return await waitFor(`${label} load`, snapshot => + const loaded = await waitFor(`${label} load`, snapshot => + (!selectionRequired || + (snapshot.stateCount >= before.stateCount + 2 && + snapshot.busyStateCount > before.busyStateCount)) && isLoaded(snapshot), { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const loadedLayout = await waitForWorkspaceLayout(`${label} workspace`, candidate => + workspaceLayoutMatches(candidate, "catalog") && + candidate.activeMarketTab === tabId); + assertWorkspaceAndScheduleGeometry(loadedLayout, `${label} workspace`); + return loaded; } async function openThemeCatalogList(label) { @@ -4394,13 +4746,19 @@ try { cleanupMode = true; cleanupDeadlineEpochMilliseconds = Date.now() + 5_000; const cleanupFailures = []; - if (pointerIsPressed && socket?.readyState === WebSocket.OPEN) { + if ((pointerIsPressed || dragInterceptionEnabled || activeInterceptedDragData) && + 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}`); + await cleanupDragInputSession(true); + } catch (inputCleanupError) { + evidence.warnings.push(`Input cleanup failed: ${inputCleanupError.message}`); + cleanupFailures.push(`input cleanup: ${inputCleanupError.message}`); } + } else if ((pointerIsPressed || dragInterceptionEnabled || activeInterceptedDragData) && + socket?.readyState !== WebSocket.OPEN) { + evidence.warnings.push( + "Input cleanup failed: the CDP socket closed before drag/pointer restoration."); + cleanupFailures.push("input cleanup: CDP socket closed"); } if (socket?.readyState === WebSocket.OPEN) { try { diff --git a/scripts/Test-LegacyPackageUiOnlySmoke.mjs b/scripts/Test-LegacyPackageUiOnlySmoke.mjs index 935ad12..e8c140e 100644 --- a/scripts/Test-LegacyPackageUiOnlySmoke.mjs +++ b/scripts/Test-LegacyPackageUiOnlySmoke.mjs @@ -215,9 +215,11 @@ async function readShell() { const workspaceRegion = document.getElementById("workspace-region"); const navigationToggle = document.getElementById("workspace-nav-toggle"); const stockTab = document.getElementById("workspace-stock-tab"); - const catalogTab = document.getElementById("workspace-catalog-tab"); + const marketTabs = Array.from(document.querySelectorAll("#market-tabs > button[data-tab-id]")); + const settingsTab = document.getElementById("workspace-settings-tab"); const stockWorkspace = document.getElementById("stock-workspace"); const catalogWorkspace = document.getElementById("catalog-workspace"); + const settingsWorkspace = document.getElementById("settings-workspace"); const schedule = document.querySelector(".playlist-panel"); const workspaceBounds = rectangle(workspaceRegion); const scheduleBounds = rectangle(schedule); @@ -250,13 +252,25 @@ async function readShell() { navigationExpanded: navigationToggle?.getAttribute("aria-expanded") === "true" && workspaceRegion?.classList.contains("nav-collapsed") !== true, stockTabCurrent: stockTab?.getAttribute("aria-current") || null, - catalogTabCurrent: catalogTab?.getAttribute("aria-current") || null, + marketTabCount: marketTabs.length, + marketTabsCurrent: marketTabs.map(button => ({ + tabId: button.dataset.tabId, + current: button.getAttribute("aria-current") || null, + hasIcon: Boolean(button.querySelector("svg")), + hasLabel: Boolean(button.querySelector(".workspace-nav-label")) + })), + settingsTabCurrent: settingsTab?.getAttribute("aria-current") || null, + currentMenuItemCount: document.querySelectorAll( + "#workspace-navigation .workspace-nav-item[aria-current='page']").length, stockWorkspaceVisible: isVisible(stockWorkspace, rectangle(stockWorkspace)) && stockWorkspace?.getAttribute("aria-hidden") === "false" && stockWorkspace?.hasAttribute("inert") !== true, catalogWorkspaceHidden: catalogWorkspace?.hidden === true && catalogWorkspace?.getAttribute("aria-hidden") === "true" && catalogWorkspace?.hasAttribute("inert") === true, + settingsWorkspaceHidden: settingsWorkspace?.hidden === true && + settingsWorkspace?.getAttribute("aria-hidden") === "true" && + settingsWorkspace?.hasAttribute("inert") === true, workspace: workspaceBounds, schedule: scheduleBounds, workspaceToScheduleWidthRatio: workspaceBounds && scheduleBounds && @@ -286,8 +300,13 @@ function assertShell(shell) { const layout = shell.layout; if (!layout || layout.activeWorkspace !== "stock" || layout.navigationExpanded !== true || - layout.stockTabCurrent !== "page" || layout.catalogTabCurrent !== "false" || - layout.stockWorkspaceVisible !== true || layout.catalogWorkspaceHidden !== true) { + layout.stockTabCurrent !== "page" || layout.settingsTabCurrent !== "false" || + layout.marketTabCount !== 10 || + layout.marketTabsCurrent.some(tab => tab.current !== "false" || + tab.hasIcon !== true || tab.hasLabel !== true) || + layout.currentMenuItemCount !== 1 || + layout.stockWorkspaceVisible !== true || layout.catalogWorkspaceHidden !== true || + layout.settingsWorkspaceHidden !== true) { fail("KNOWN_FAILURE", "The package did not render the expanded navigation with stock as the sole initial workspace."); } if (layout.scheduleVisible !== true || layout.scheduleHitTestVisible !== true || diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js index c6fea98..2191936 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js @@ -1374,12 +1374,23 @@ if (!button) return; const buttonAtPosition = marketTabs.children.item(position); if (buttonAtPosition !== button) marketTabs.insertBefore(button, buttonAtPosition); - button.textContent = tab.label; + const label = button.querySelector(".workspace-nav-label"); + if (label) label.textContent = tab.label; + else button.textContent = tab.label; + button.setAttribute("aria-label", tab.label); + button.title = tab.label; button.classList.toggle("active", tab.isActive === true); - button.setAttribute("aria-current", tab.isActive === true ? "page" : "false"); button.disabled = state.isBusy === true || state.fixedSectionBatch != null; button.draggable = state.isBusy !== true && state.fixedSectionBatch == null; }); + const activeTab = state.tabs.find(function (tab) { return tab.isActive === true; }); + // Keep the clicked menu highlighted while native tab loading is in flight. + // The busy snapshot still carries the previous active tab; only reconcile + // once the authoritative operation has completed. + if (activeTab && state.isBusy !== true && + window.LegacyWorkspaceNavigation?.syncMarket) { + window.LegacyWorkspaceNavigation.syncMarket(activeTab.id); + } acknowledgePendingTabHoverSwap( state.tabs.map(function (tab) { return tab.id; })); } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html index b8bbd09..9e03bfd 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html @@ -9,17 +9,20 @@