From a8ecd7fc33f34249daae5951be99140fb239105b Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:19:17 +0800 Subject: [PATCH] test: measure live page readiness in page --- docs/binance-orderbook-trade-ui-automation.md | 29 +- .../helpers/live-performance-probe.js | 260 ++++++++++++++++-- ...-orders-2026-08-27-page-ready.capture.json | 80 ++++++ .../live-baselines/no-orders.baseline.json | 22 +- .../specs/live-performance-probe.pw.js | 75 ++++- .../binance-live-performance-probe.test.js | 28 ++ 6 files changed, 452 insertions(+), 42 deletions(-) create mode 100644 e2e/binance-orderbook/live-baselines/no-orders-2026-08-27-page-ready.capture.json diff --git a/docs/binance-orderbook-trade-ui-automation.md b/docs/binance-orderbook-trade-ui-automation.md index 9045812..2ec7da8 100644 --- a/docs/binance-orderbook-trade-ui-automation.md +++ b/docs/binance-orderbook-trade-ui-automation.md @@ -245,6 +245,29 @@ exposes overflow counts for every bounded stream. A capture is invalid when the browser does not support Long Task or Long Animation Frame evidence, any stream overflows, or any uncaught error is observed. +Before clicking, use `createLivePerformanceCompletionPreparationExpression()` with +raw CDP or `prepareLivePerformanceProbeCompletion()` in Playwright and wait for its +immediate `{ prepared: true }` result. After the business action, collect the stored +promise with `createLivePerformanceCompletionResultExpression()` or +`finishLivePerformanceProbeWhenReady()`. This explicit prepare/action/collect order +prevents observer-installation latency from entering the sample. The completion +observer runs inside the target page, verifies the exact +no-order/cancel/confirm terminal contract, marks semantic completion immediately, and +keeps only the performance observers alive through the next page task. This preserves +the true completion timestamp while still collecting the tail of the click task, +without adding browser-control or animation-frame scheduling jitter. Do not poll the +page from the automation process and call +`finish()` afterward: browser-control round trips would be counted as application +latency and would corrupt `clickToFinalReady` or `decisionToFinalReady`. Probe finish +also drains `PerformanceObserver.takeRecords()` before disconnecting, so a final long +task or long animation frame cannot disappear merely because its callback has not run. +The completion observer rechecks in a microtask after relevant DOM mutations so it +sees the probe's semantic sample from the same mutation batch; it must not wait for an +unrelated later mutation to discover that the action already finished. +Each isolated sample must begin from the normal enabled `撤单` state with no native +dialog visible. `arm()` rejects a sample while the prior `无挂单` feedback window is +still active, rather than recording that carry-over as the next run's first feedback. + Pass the validated probe snapshots to `e2e/binance-orderbook/helpers/live-capture-builder.js`. The builder derives every wall-clock segment from the recorded semantic events and verifies that the dialog @@ -265,9 +288,11 @@ The assembler rejects unknown bundle fields and refuses to overwrite its input f or an existing capture. This command is the standard boundary between CDP collection and capture summary; do not replace it with an ad hoc Node snippet. -The current checked-in L4 reference is the isolated zero-order HYPEUSDT run: +The current checked-in L4 reference is the isolated zero-order HYPEUSDT run. The +older captures remain immutable evidence; the active baseline comes from the latest +page-owned completion sample: -- `e2e/binance-orderbook/live-baselines/no-orders-2026-08-27.capture.json` +- `e2e/binance-orderbook/live-baselines/no-orders-2026-08-27-page-ready.capture.json` - `e2e/binance-orderbook/live-baselines/no-orders.baseline.json` Each scenario kind owns one exact wall-clock segment contract. A scenario cannot add, diff --git a/e2e/binance-orderbook/helpers/live-performance-probe.js b/e2e/binance-orderbook/helpers/live-performance-probe.js index fc641f6..110e181 100644 --- a/e2e/binance-orderbook/helpers/live-performance-probe.js +++ b/e2e/binance-orderbook/helpers/live-performance-probe.js @@ -1,4 +1,5 @@ const DEFAULT_GLOBAL_NAME = '__BINANCE_LIVE_PERFORMANCE_PROBE__'; +const DEFAULT_COMPLETION_GLOBAL_NAME = '__BINANCE_LIVE_PERFORMANCE_COMPLETION__'; function assertRecord(value, path) { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -66,11 +67,122 @@ export function validateLivePerformanceProbeSnapshot(snapshot) { return snapshot; } +export function waitForBinanceLivePerformanceCompletion(options = {}) { + const globalName = options.globalName || '__BINANCE_LIVE_PERFORMANCE_PROBE__'; + const kind = options.kind; + const timeoutMs = options.timeoutMs || 5_000; + const panelSelector = options.panelSelector || '#jh-binance-close-qty-multiplier-panel'; + const cancelButtonSelector = options.cancelButtonSelector + || '[data-ladder-cancel-symbol="true"]'; + const statusSelector = options.statusSelector || '#jh-binance-ladder-status'; + const dialogSelector = options.dialogSelector + || '[role="dialog"], [class*="modal"], [class*="Modal"]'; + const supportedKinds = ['no-orders', 'dialog-cancel', 'dialog-confirm']; + if (!supportedKinds.includes(kind)) { + throw new Error(`Unsupported live completion kind: ${kind}`); + } + if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) { + throw new Error('timeoutMs must be a positive integer'); + } + if (!document.body) throw new Error('Live completion wait requires document.body'); + const probe = window[globalName]; + if (!probe) throw new Error(`Live performance probe is not installed: ${globalName}`); + + const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim(); + const isVisible = (element) => Boolean(element && element.getClientRects().length); + const matchesDialogText = (value) => /取消全部订单|Cancel all orders/i.test(value); + const readVisibleDialog = () => Array.from(document.querySelectorAll(dialogSelector)) + .filter(isVisible) + .find((dialog) => matchesDialogText(normalizeText(dialog.textContent))) || null; + const readState = () => { + const panel = document.querySelector(panelSelector); + const cancelButton = panel?.querySelector(cancelButtonSelector) || null; + return { + cancelButtonPresent: Boolean(cancelButton), + cancelButtonDisabled: Boolean(cancelButton?.disabled), + cancelButtonText: normalizeText(cancelButton?.textContent), + statusText: normalizeText(panel?.querySelector(statusSelector)?.textContent), + dialogVisible: Boolean(readVisibleDialog()), + }; + }; + const hasEvent = (snapshot, eventKind) => ( + snapshot.events.some((event) => event.kind === eventKind) + ); + const isReady = () => { + const snapshot = probe.snapshot(); + if (snapshot.startedAtMonotonicMs === null || snapshot.finishedAtMonotonicMs !== null) { + return false; + } + if (!snapshot.firstFeedbackCaptured || !hasEvent(snapshot, 'first-feedback')) return false; + const state = readState(); + if (!state.cancelButtonPresent || state.cancelButtonDisabled || state.dialogVisible) return false; + + if (kind === 'no-orders') { + if (hasEvent(snapshot, 'dialog-visible') || hasEvent(snapshot, 'dialog-action')) return false; + return state.cancelButtonText === '无挂单' || /当前币无挂单/.test(state.statusText); + } + + const dialogActions = snapshot.events.filter((event) => event.kind === 'dialog-action'); + if (dialogActions.length !== 1 || !hasEvent(snapshot, 'dialog-hidden')) return false; + const expectsPrimary = kind === 'dialog-confirm'; + if (dialogActions[0].detail?.primary !== expectsPrimary) return false; + return kind === 'dialog-cancel' + ? /已取消撤单.*已恢复页面状态/.test(state.statusText) + : /撤单流程结束.*已恢复筛选状态/.test(state.statusText); + }; + + return new Promise((resolve, reject) => { + let settled = false; + let checkQueued = false; + const queueAttempt = () => { + if (settled || checkQueued) return; + checkQueued = true; + queueMicrotask(() => { + checkQueued = false; + attemptFinish(); + }); + }; + const observer = new MutationObserver(queueAttempt); + const cleanup = () => { + observer.disconnect(); + window.clearTimeout(timeoutId); + }; + const fail = (error) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + function attemptFinish() { + if (settled || !isReady()) return; + settled = true; + cleanup(); + try { + resolve(probe.finishAfterPerformanceTail()); + } catch (error) { + reject(error); + } + } + const timeoutId = window.setTimeout(() => { + fail(new Error(`Live completion timed out for ${kind}`)); + }, timeoutMs); + observer.observe(document.body, { + subtree: true, + childList: true, + characterData: true, + attributes: true, + attributeFilter: ['disabled', 'aria-disabled', 'class', 'style'], + }); + attemptFinish(); + }); +} + export function installBinanceLivePerformanceProbe(options = {}) { const globalName = options.globalName || '__BINANCE_LIVE_PERFORMANCE_PROBE__'; const panelSelector = options.panelSelector || '#jh-binance-close-qty-multiplier-panel'; const cancelButtonSelector = options.cancelButtonSelector || '[data-ladder-cancel-symbol="true"]'; + const readyCancelButtonText = options.readyCancelButtonText || '撤单'; const statusSelector = options.statusSelector || '#jh-binance-ladder-status'; const dialogSelector = options.dialogSelector || '[role="dialog"], [class*="modal"], [class*="Modal"]'; @@ -139,6 +251,8 @@ export function installBinanceLivePerformanceProbe(options = {}) { let observedDialogRoot = null; let longTaskObserver = null; let longAnimationFrameObserver = null; + let performanceCollectionActive = false; + let finalizationPromise = null; let sampleQueued = false; const hasStarted = () => Boolean(run && run.startedAtMonotonicMs !== null); const isCollecting = () => Boolean(hasStarted() && run.finishedAtMonotonicMs === null); @@ -159,40 +273,57 @@ export function installBinanceLivePerformanceProbe(options = {}) { if (!isCollecting()) return; appendBounded('events', { kind, atMs: relativeNow(), detail }, eventLimit); }; + const recordLongTasks = (entries) => { + if (!performanceCollectionActive) return; + const cutoff = run.finishedAtMonotonicMs ?? Number.POSITIVE_INFINITY; + for (const entry of entries) { + if (entry.startTime > cutoff) continue; + appendBounded('longTasks', { + startTime: entry.startTime, + duration: entry.duration, + }, performanceLimit); + } + }; + const recordLongAnimationFrames = (entries) => { + if (!performanceCollectionActive) return; + const cutoff = run.finishedAtMonotonicMs ?? Number.POSITIVE_INFINITY; + for (const entry of entries) { + if (entry.startTime > cutoff) continue; + appendBounded('longAnimationFrames', { + startTime: entry.startTime, + duration: entry.duration, + blockingDuration: entry.blockingDuration, + forcedStyleAndLayoutDuration: entry.scripts?.reduce( + (total, script) => total + (script.forcedStyleAndLayoutDuration || 0), + 0, + ) || 0, + }, performanceLimit); + } + }; + const flushPerformanceObservers = () => { + recordLongTasks(longTaskObserver?.takeRecords?.() || []); + recordLongAnimationFrames(longAnimationFrameObserver?.takeRecords?.() || []); + }; const stopPerformanceObservers = () => { + flushPerformanceObservers(); longTaskObserver?.disconnect(); longAnimationFrameObserver?.disconnect(); longTaskObserver = null; longAnimationFrameObserver = null; + performanceCollectionActive = false; }; const startPerformanceObservers = () => { stopPerformanceObservers(); + performanceCollectionActive = true; if (run.performanceSupport.longTask) { longTaskObserver = new PerformanceObserver((list) => { - if (!isCollecting()) return; - for (const entry of list.getEntries()) { - appendBounded('longTasks', { - startTime: entry.startTime, - duration: entry.duration, - }, performanceLimit); - } + recordLongTasks(list.getEntries()); }); longTaskObserver.observe({ type: 'longtask' }); } if (run.performanceSupport.longAnimationFrame) { longAnimationFrameObserver = new PerformanceObserver((list) => { - if (!isCollecting()) return; - for (const entry of list.getEntries()) { - appendBounded('longAnimationFrames', { - startTime: entry.startTime, - duration: entry.duration, - blockingDuration: entry.blockingDuration, - forcedStyleAndLayoutDuration: entry.scripts?.reduce( - (total, script) => total + (script.forcedStyleAndLayoutDuration || 0), - 0, - ) || 0, - }, performanceLimit); - } + recordLongAnimationFrames(list.getEntries()); }); longAnimationFrameObserver.observe({ type: 'long-animation-frame' }); } @@ -346,9 +477,21 @@ export function installBinanceLivePerformanceProbe(options = {}) { window.addEventListener('unhandledrejection', handleUnhandledRejection); const cloneRun = () => structuredClone(run); + const markRunFinished = () => { + if (!hasStarted()) throw new Error('Live performance probe has not started'); + if (run.finishedAtMonotonicMs === null) { + recordSemanticState('finish'); + appendEvent('finished'); + run.finishedAtMonotonicMs = performance.now(); + stopMutationObservers(); + } + }; const api = { arm(scenarioName) { if (destroyed) throw new Error('Live performance probe is destroyed'); + if (finalizationPromise) { + throw new Error('Cannot arm while live performance evidence is finalizing'); + } if (typeof scenarioName !== 'string' || scenarioName.length === 0) { throw new Error('scenarioName must be a non-empty string'); } @@ -356,6 +499,16 @@ export function installBinanceLivePerformanceProbe(options = {}) { if (run.startedAtMonotonicMs === null && run.scenarioName === scenarioName) return cloneRun(); throw new Error('Cannot arm while a live performance run is active'); } + const initialState = readSemanticState(); + if ( + !initialState.panelPresent + || !initialState.cancelButtonPresent + || initialState.cancelButtonDisabled + || initialState.cancelButtonText !== readyCancelButtonText + || initialState.dialogVisible + ) { + throw new Error('Live performance probe cannot arm before the cancel UI is fully ready'); + } stopPerformanceObservers(); const armedAtMonotonicMs = performance.now(); run = { @@ -391,16 +544,26 @@ export function installBinanceLivePerformanceProbe(options = {}) { return cloneRun(); }, finish() { - if (!hasStarted()) throw new Error('Live performance probe has not started'); - if (run.finishedAtMonotonicMs === null) { - recordSemanticState('finish'); - appendEvent('finished'); - run.finishedAtMonotonicMs = performance.now(); - stopPerformanceObservers(); - stopMutationObservers(); + if (finalizationPromise) { + throw new Error('Live performance evidence is still finalizing'); } + markRunFinished(); + stopPerformanceObservers(); return cloneRun(); }, + finishAfterPerformanceTail() { + if (finalizationPromise) return finalizationPromise; + markRunFinished(); + finalizationPromise = new Promise((resolve) => { + window.setTimeout(() => { + stopPerformanceObservers(); + const snapshot = cloneRun(); + finalizationPromise = null; + resolve(snapshot); + }, 0); + }); + return finalizationPromise; + }, destroy() { if (destroyed) return run ? cloneRun() : null; destroyed = true; @@ -421,6 +584,39 @@ export function createLivePerformanceProbeExpression(options = {}) { return `(${installBinanceLivePerformanceProbe.toString()})(${JSON.stringify(options)})`; } +export function createLivePerformanceCompletionExpression(options = {}) { + return `(${waitForBinanceLivePerformanceCompletion.toString()})(${JSON.stringify(options)})`; +} + +export function createLivePerformanceCompletionPreparationExpression(options = {}) { + const completionGlobalName = options.completionGlobalName || DEFAULT_COMPLETION_GLOBAL_NAME; + const waitOptions = { ...options }; + delete waitOptions.completionGlobalName; + const waitExpression = createLivePerformanceCompletionExpression(waitOptions); + return `(() => { + const completionGlobalName = ${JSON.stringify(completionGlobalName)}; + if (window[completionGlobalName]) { + throw new Error('Live performance completion is already prepared'); + } + window[completionGlobalName] = ${waitExpression}; + return { completionGlobalName, prepared: true }; + })()`; +} + +export function createLivePerformanceCompletionResultExpression(options = {}) { + const completionGlobalName = options.completionGlobalName || DEFAULT_COMPLETION_GLOBAL_NAME; + return `(async () => { + const completionGlobalName = ${JSON.stringify(completionGlobalName)}; + const completion = window[completionGlobalName]; + if (!completion) throw new Error('Live performance completion is not prepared'); + try { + return await completion; + } finally { + delete window[completionGlobalName]; + } + })()`; +} + export async function installLivePerformanceProbe(page, options = {}) { return page.evaluate(installBinanceLivePerformanceProbe, options); } @@ -436,6 +632,18 @@ export async function finishLivePerformanceProbe(page) { return page.evaluate((globalName) => window[globalName].finish(), DEFAULT_GLOBAL_NAME); } +export async function prepareLivePerformanceProbeCompletion(page, kind, options = {}) { + return page.evaluate(createLivePerformanceCompletionPreparationExpression({ + ...options, + globalName: DEFAULT_GLOBAL_NAME, + kind, + })); +} + +export async function finishLivePerformanceProbeWhenReady(page, options = {}) { + return page.evaluate(createLivePerformanceCompletionResultExpression(options)); +} + export async function destroyLivePerformanceProbe(page) { return page.evaluate((globalName) => window[globalName]?.destroy?.() || null, DEFAULT_GLOBAL_NAME); } diff --git a/e2e/binance-orderbook/live-baselines/no-orders-2026-08-27-page-ready.capture.json b/e2e/binance-orderbook/live-baselines/no-orders-2026-08-27-page-ready.capture.json new file mode 100644 index 0000000..ca02c09 --- /dev/null +++ b/e2e/binance-orderbook/live-baselines/no-orders-2026-08-27-page-ready.capture.json @@ -0,0 +1,80 @@ +{ + "schemaVersion": 1, + "capturedAt": "2026-08-26T20:14:42.922Z", + "environment": { + "browser": "Chrome 151.0.0.0", + "os": "macOS (MacIntel)", + "route": "https://www.binance.com/zh-CN/futures/HYPEUSDT", + "symbol": "HYPEUSDT", + "userscriptSha256": "f4bb60af07044e21f684d1978535795363ade1c240dde0de6b904767cfa87a18", + "userscriptVersion": "2.7.126" + }, + "scenarios": [ + { + "name": "cancel-current-symbol-no-orders", + "parameters": { + "kind": "no-orders" + }, + "applicableSegments": [ + "clickToFirstFeedback", + "clickToFinalReady" + ], + "samples": [ + { + "capacityEvidence": null, + "segmentsMs": { + "clickToFirstFeedback": 7.5, + "clickToFinalReady": 7.8999998569488525 + }, + "testOrderLedger": { + "created": [], + "fills": [], + "residual": [] + }, + "stateRestored": true, + "noFills": true, + "residualTestOrders": 0, + "uncaughtErrors": 0, + "maxLongTaskMs": 0, + "maxLongAnimationFrameMs": 0 + }, + { + "capacityEvidence": null, + "segmentsMs": { + "clickToFirstFeedback": 5.599999904632568, + "clickToFinalReady": 6 + }, + "testOrderLedger": { + "created": [], + "fills": [], + "residual": [] + }, + "stateRestored": true, + "noFills": true, + "residualTestOrders": 0, + "uncaughtErrors": 0, + "maxLongTaskMs": 0, + "maxLongAnimationFrameMs": 0 + }, + { + "capacityEvidence": null, + "segmentsMs": { + "clickToFirstFeedback": 6.6000001430511475, + "clickToFinalReady": 7 + }, + "testOrderLedger": { + "created": [], + "fills": [], + "residual": [] + }, + "stateRestored": true, + "noFills": true, + "residualTestOrders": 0, + "uncaughtErrors": 0, + "maxLongTaskMs": 0, + "maxLongAnimationFrameMs": 0 + } + ] + } + ] +} diff --git a/e2e/binance-orderbook/live-baselines/no-orders.baseline.json b/e2e/binance-orderbook/live-baselines/no-orders.baseline.json index 6067ab2..b5483bf 100644 --- a/e2e/binance-orderbook/live-baselines/no-orders.baseline.json +++ b/e2e/binance-orderbook/live-baselines/no-orders.baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "capturedAt": "2026-08-26T18:34:58.000Z", + "capturedAt": "2026-08-26T20:14:42.922Z", "environment": { "browser": "Chrome 151.0.0.0", "os": "macOS (MacIntel)", @@ -18,16 +18,16 @@ "sampleCount": 3, "segmentsMs": { "clickToFirstFeedback": { - "min": 3.4, - "median": 3.6, - "p95": 11.7, - "max": 11.7 + "min": 5.599999904632568, + "median": 6.6000001430511475, + "p95": 7.5, + "max": 7.5 }, "clickToFinalReady": { - "min": 91.8, - "median": 95.3, - "p95": 106.9, - "max": 106.9 + "min": 6, + "median": 7, + "p95": 7.8999998569488525, + "max": 7.8999998569488525 } }, "maxLongTaskMs": { @@ -39,8 +39,8 @@ "maxLongAnimationFrameMs": { "min": 0, "median": 0, - "p95": 59.8, - "max": 59.8 + "p95": 0, + "max": 0 } } ], diff --git a/e2e/binance-orderbook/specs/live-performance-probe.pw.js b/e2e/binance-orderbook/specs/live-performance-probe.pw.js index a46d9fe..a2f61c8 100644 --- a/e2e/binance-orderbook/specs/live-performance-probe.pw.js +++ b/e2e/binance-orderbook/specs/live-performance-probe.pw.js @@ -11,7 +11,9 @@ import { createLivePerformanceProbeExpression, destroyLivePerformanceProbe, finishLivePerformanceProbe, + finishLivePerformanceProbeWhenReady, installLivePerformanceProbe, + prepareLivePerformanceProbeCompletion, validateLivePerformanceProbeSnapshot, } from '../helpers/live-performance-probe.js'; @@ -22,11 +24,15 @@ test('live probe captures a no-order run and destroys every listener', async ({ const secondArm = await armLivePerformanceProbe(page, 'cancel-current-symbol-no-orders'); expect(secondArm.sessionId).toBe(firstArm.sessionId); + await prepareLivePerformanceProbeCompletion(page, 'no-orders'); await page.getByRole('button', { name: '撤单' }).click(); - await expect(page.getByRole('button', { name: '撤单' })).toBeEnabled(); - const snapshot = await finishLivePerformanceProbe(page); + const snapshot = await finishLivePerformanceProbeWhenReady(page); + await expect(page.getByRole('button', { name: '无挂单' })).toBeEnabled(); expect(() => validateLivePerformanceProbeSnapshot(snapshot)).not.toThrow(); expect(snapshot.events.map((event) => event.kind)).toContain('first-feedback'); + const firstFeedback = snapshot.events.find((event) => event.kind === 'first-feedback'); + expect(snapshot.finishedAtMonotonicMs - snapshot.startedAtMonotonicMs - firstFeedback.atMs) + .toBeLessThan(50); expect(snapshot.lastSemanticState.statusText).toBe('HYPEUSDT 当前币无挂单'); const finishedEventCount = snapshot.events.length; @@ -55,6 +61,7 @@ test('live probe has no user-decision deadline and follows a replaced portal dia await installLivePerformanceProbe(page); await armLivePerformanceProbe(page, 'cancel-dialog-cancel'); + await prepareLivePerformanceProbeCompletion(page, 'dialog-cancel'); await page.getByRole('button', { name: '撤单' }).click(); await expect(page.getByRole('dialog')).toBeVisible(); await page.waitForTimeout(1_000); @@ -63,8 +70,8 @@ test('live probe has no user-decision deadline and follows a replaced portal dia expect(waiting.events.map((event) => event.kind)).toContain('dialog-visible'); await page.getByRole('button', { name: '取消' }).click(); + const snapshot = await finishLivePerformanceProbeWhenReady(page); await expect(page.getByText('HYPEUSDT 已取消撤单,已恢复页面状态')).toBeVisible(); - const snapshot = await finishLivePerformanceProbe(page); expect(() => validateLivePerformanceProbeSnapshot(snapshot)).not.toThrow(); expect(snapshot.events.map((event) => event.kind)).toEqual(expect.arrayContaining([ 'dialog-visible', @@ -74,6 +81,67 @@ test('live probe has no user-decision deadline and follows a replaced portal dia await destroyLivePerformanceProbe(page); }); +test('live completion waits for confirmed cancellation cleanup', async ({ page }) => { + const scenario = createCancelScenario({ + positions: POSITION_SETS.current, + orders: ORDER_SETS.current, + }); + await openUserscriptScenario(page, scenario); + await installLivePerformanceProbe(page); + await armLivePerformanceProbe(page, 'cancel-dialog-confirm'); + + await prepareLivePerformanceProbeCompletion(page, 'dialog-confirm'); + await page.getByRole('button', { name: '撤单' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.getByRole('button', { name: '确认' }).click(); + const snapshot = await finishLivePerformanceProbeWhenReady(page); + + expect(() => validateLivePerformanceProbeSnapshot(snapshot)).not.toThrow(); + expect(snapshot.events.find((event) => event.kind === 'dialog-action')?.detail?.primary).toBe(true); + expect(snapshot.lastSemanticState.statusText).toBe('HYPEUSDT 撤单流程结束,已恢复筛选状态'); + await destroyLivePerformanceProbe(page); +}); + +test('live completion flushes the final long task before disconnecting observers', async ({ page }) => { + await openUserscriptScenario(page, createCancelScenario()); + await installLivePerformanceProbe(page); + await armLivePerformanceProbe(page, 'cancel-current-symbol-no-orders-long-task'); + await page.locator('[data-ladder-cancel-symbol="true"]').evaluate((button) => { + button.addEventListener('click', () => { + const deadline = performance.now() + 80; + while (performance.now() < deadline) { + // Intentional deterministic host stall for performance-observer coverage. + } + }, { once: true }); + }); + + await prepareLivePerformanceProbeCompletion(page, 'no-orders'); + await page.getByRole('button', { name: '撤单' }).click(); + const snapshot = await finishLivePerformanceProbeWhenReady(page); + + expect(snapshot.longTasks.some((entry) => entry.duration >= 75)).toBe(true); + await destroyLivePerformanceProbe(page); +}); + +test('live probe rejects a sample while prior no-order feedback is still visible', async ({ page }) => { + await openUserscriptScenario(page, createCancelScenario()); + await installLivePerformanceProbe(page); + await armLivePerformanceProbe(page, 'cancel-current-symbol-no-orders-first'); + + await prepareLivePerformanceProbeCompletion(page, 'no-orders'); + await page.getByRole('button', { name: '撤单' }).click(); + await finishLivePerformanceProbeWhenReady(page); + await expect(page.getByRole('button', { name: '无挂单' })).toBeEnabled(); + await expect(page.evaluate(() => ( + window.__BINANCE_LIVE_PERFORMANCE_PROBE__.arm('cancel-current-symbol-no-orders-too-soon') + ))).rejects.toThrow(/cannot arm before the cancel UI is fully ready/); + + await expect(page.getByRole('button', { name: '撤单' })).toBeEnabled(); + const rearmed = await armLivePerformanceProbe(page, 'cancel-current-symbol-no-orders-second'); + expect(rearmed.startedAtMonotonicMs).toBeNull(); + await destroyLivePerformanceProbe(page); +}); + test('live probe serializes uncaught errors and unhandled rejections', async ({ page }) => { await openUserscriptScenario(page, createCancelScenario()); await installLivePerformanceProbe(page); @@ -106,6 +174,7 @@ test('live probe follows a userscript panel replaced after arm and before click' await installLivePerformanceProbe(page, { panelSelector: '#probe-panel', cancelButtonSelector: '[data-probe-cancel="true"]', + readyCancelButtonText: 'Probe cancel', statusSelector: '#probe-status', }); await armLivePerformanceProbe(page, 'replaced-panel-before-click'); diff --git a/test/unit/binance-live-performance-probe.test.js b/test/unit/binance-live-performance-probe.test.js index 9f5fc48..5364c1e 100644 --- a/test/unit/binance-live-performance-probe.test.js +++ b/test/unit/binance-live-performance-probe.test.js @@ -2,6 +2,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + createLivePerformanceCompletionExpression, + createLivePerformanceCompletionPreparationExpression, + createLivePerformanceCompletionResultExpression, createLivePerformanceProbeExpression, validateLivePerformanceProbeSnapshot, } from '../../e2e/binance-orderbook/helpers/live-performance-probe.js'; @@ -69,3 +72,28 @@ test('live probe Runtime.evaluate expression is self-contained', () => { assert.match(expression, /"eventLimit":25/); assert.doesNotMatch(expression, /DEFAULT_GLOBAL_NAME/); }); + +test('live completion preparation and result expressions remove the action race', () => { + const preparation = createLivePerformanceCompletionPreparationExpression({ + kind: 'no-orders', + }); + const result = createLivePerformanceCompletionResultExpression(); + + assert.match(preparation, /window\[completionGlobalName\] = \(function waitForBinanceLivePerformanceCompletion/); + assert.match(preparation, /prepared: true/); + assert.match(result, /return await completion/); + assert.match(result, /delete window\[completionGlobalName\]/); +}); + +test('live completion Runtime.evaluate expression owns the page-ready contract', () => { + const expression = createLivePerformanceCompletionExpression({ + kind: 'dialog-confirm', + }); + + assert.match(expression, /^\(function waitForBinanceLivePerformanceCompletion/); + assert.match(expression, /"kind":"dialog-confirm"/); + assert.match(expression, /finishAfterPerformanceTail/); + assert.match(expression, /dialog-action/); + assert.match(expression, /已恢复筛选状态/); + assert.doesNotMatch(expression, /DEFAULT_GLOBAL_NAME/); +});