feat: expose graphic screens in sidebar

This commit is contained in:
2026-07-21 09:45:12 +09:00
parent 44a7d90413
commit 15ad9934a9
11 changed files with 1044 additions and 117 deletions

View File

@@ -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') {

View File

@@ -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 {

View File

@@ -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 ||