feat: expose graphic screens in sidebar
This commit is contained in:
@@ -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') {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
Reference in New Issue
Block a user