diff --git a/docs/binance-orderbook-trade-ui-automation.md b/docs/binance-orderbook-trade-ui-automation.md index cd18639..b92eaa9 100644 --- a/docs/binance-orderbook-trade-ui-automation.md +++ b/docs/binance-orderbook-trade-ui-automation.md @@ -237,16 +237,24 @@ before driving a live path. The probe only observes; it never clicks, submits, o cancels an order. Arm it before the userscript action, finish it only after the final stable state, validate the returned snapshot, then destroy it. It has no user-decision deadline, dynamically reacquires the panel and portal dialog after React replacement, -and exposes overflow counts for every bounded stream. A capture is invalid when the +reads feedback from the dedicated `#jh-binance-ladder-status` semantic node, and +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. +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 +action matches the declared scenario kind. Do not manually transcribe timing values +from console output, screenshots, or chat into a capture. + The current checked-in L4 reference is the isolated zero-order HYPEUSDT run: - `e2e/binance-orderbook/live-baselines/no-orders-2026-08-27.capture.json` - `e2e/binance-orderbook/live-baselines/no-orders.baseline.json` -Each scenario declares its applicable wall-clock segments and contains at least three +Each scenario kind owns one exact wall-clock segment contract. A scenario cannot add, +omit, or reorder segments independently of its kind and must contain at least three isolated samples. Every sample must prove restored UI state, no fills, zero residual test-owned orders, zero uncaught errors, and bounded long-task observations. Missing segments are invalid data, not zero-duration work. @@ -274,8 +282,8 @@ read-only evidence for this calculation and the live runner must not change it. `fills`, and `residual` collections use the same exact order identity: symbol, side, position side, price, quantity, and creation timestamp. A fill or residual record that does not match a created test order invalidates the capture. An empty ledger is valid -for no-order and dialog-cancel scenarios; account-wide count changes never create test -ownership. +only for a no-order scenario; dialog scenarios must identify every created test order. +Account-wide count changes never create test ownership. Compare a new capture with a checked-in summary without making network timing a PR gate by default: diff --git a/e2e/binance-orderbook/helpers/live-capture-builder.js b/e2e/binance-orderbook/helpers/live-capture-builder.js new file mode 100644 index 0000000..4dffd53 --- /dev/null +++ b/e2e/binance-orderbook/helpers/live-capture-builder.js @@ -0,0 +1,141 @@ +import { validateLivePerformanceProbeSnapshot } from './live-performance-probe.js'; +import { + getLiveScenarioSegments, + validateLivePerformanceCapture, +} from '../../../scripts/binance-live-performance.mjs'; + +function assertRecord(value, path) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${path} must be an object`); + } +} + +function readUniqueEvent(probe, kind) { + const matches = probe.events.filter((event) => event.kind === kind); + if (matches.length !== 1) { + throw new Error(`probe must contain exactly one ${kind} event`); + } + const [event] = matches; + if (!Number.isFinite(event.atMs) || event.atMs < 0) { + throw new Error(`probe ${kind} event must have a finite non-negative atMs`); + } + return event; +} + +function maxDuration(entries, path) { + let maximum = 0; + for (const [index, entry] of entries.entries()) { + if (!Number.isFinite(entry.duration) || entry.duration < 0) { + throw new Error(`${path}[${index}].duration must be a finite non-negative number`); + } + maximum = Math.max(maximum, entry.duration); + } + return maximum; +} + +function readProbeDuration(probe) { + const duration = probe.finishedAtMonotonicMs - probe.startedAtMonotonicMs; + if (!Number.isFinite(duration) || duration < 0) { + throw new Error('probe duration must be a finite non-negative number'); + } + return duration; +} + +function buildSegments(parameters, probe) { + const firstFeedback = readUniqueEvent(probe, 'first-feedback'); + const duration = readProbeDuration(probe); + if (firstFeedback.atMs > duration) throw new Error('probe first feedback occurs after finish'); + if (parameters.kind === 'no-orders') { + if (probe.events.some((event) => ['dialog-visible', 'dialog-action'].includes(event.kind))) { + throw new Error('no-orders probe must not contain dialog events'); + } + return { + clickToFirstFeedback: firstFeedback.atMs, + clickToFinalReady: duration, + }; + } + + const dialogVisible = readUniqueEvent(probe, 'dialog-visible'); + const dialogAction = readUniqueEvent(probe, 'dialog-action'); + const dialogHidden = readUniqueEvent(probe, 'dialog-hidden'); + if (!( + firstFeedback.atMs <= dialogVisible.atMs + && dialogVisible.atMs <= dialogAction.atMs + && dialogAction.atMs <= dialogHidden.atMs + && dialogHidden.atMs <= duration + )) { + throw new Error('probe dialog events are out of order'); + } + const expectsPrimary = parameters.kind !== 'dialog-cancel'; + if (dialogAction.detail?.primary !== expectsPrimary) { + throw new Error( + expectsPrimary + ? `${parameters.kind} requires the primary dialog action` + : 'dialog-cancel requires the non-primary dialog action', + ); + } + return { + clickToFirstFeedback: firstFeedback.atMs, + clickToDialog: dialogVisible.atMs, + decisionToFinalReady: duration - dialogAction.atMs, + }; +} + +export function buildLivePerformanceSample({ + parameters, + probe, + capacityEvidence, + testOrderLedger, + stateRestored, +}) { + assertRecord(parameters, 'parameters'); + validateLivePerformanceProbeSnapshot(probe); + assertRecord(testOrderLedger, 'testOrderLedger'); + if (stateRestored !== true) throw new Error('stateRestored must equal true'); + const segmentsMs = buildSegments(parameters, probe); + if (JSON.stringify(Object.keys(segmentsMs)) !== JSON.stringify(getLiveScenarioSegments(parameters.kind))) { + throw new Error(`derived segments do not match the ${parameters.kind} contract`); + } + return Object.freeze({ + capacityEvidence: structuredClone(capacityEvidence), + segmentsMs, + testOrderLedger: structuredClone(testOrderLedger), + stateRestored: true, + noFills: testOrderLedger.fills.length === 0, + residualTestOrders: testOrderLedger.residual.length, + uncaughtErrors: probe.errors.length, + maxLongTaskMs: maxDuration(probe.longTasks, 'probe.longTasks'), + maxLongAnimationFrameMs: maxDuration( + probe.longAnimationFrames, + 'probe.longAnimationFrames', + ), + }); +} + +export function buildLivePerformanceCapture({ capturedAt, environment, scenarios }) { + if (!Array.isArray(scenarios) || scenarios.length === 0) { + throw new Error('scenarios must contain at least one scenario'); + } + const capture = { + schemaVersion: 1, + capturedAt, + environment: structuredClone(environment), + scenarios: scenarios.map((scenario, scenarioIndex) => { + assertRecord(scenario, `scenarios[${scenarioIndex}]`); + if (!Array.isArray(scenario.samples)) { + throw new Error(`scenarios[${scenarioIndex}].samples must be an array`); + } + return { + name: scenario.name, + parameters: structuredClone(scenario.parameters), + applicableSegments: getLiveScenarioSegments(scenario.parameters?.kind), + samples: scenario.samples.map((sample) => buildLivePerformanceSample({ + parameters: scenario.parameters, + ...sample, + })), + }; + }), + }; + validateLivePerformanceCapture(capture); + return capture; +} diff --git a/e2e/binance-orderbook/helpers/live-performance-probe.js b/e2e/binance-orderbook/helpers/live-performance-probe.js index ae80659..fc641f6 100644 --- a/e2e/binance-orderbook/helpers/live-performance-probe.js +++ b/e2e/binance-orderbook/helpers/live-performance-probe.js @@ -71,6 +71,7 @@ export function installBinanceLivePerformanceProbe(options = {}) { 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 eventLimit = options.eventLimit || 200; @@ -108,6 +109,7 @@ export function installBinanceLivePerformanceProbe(options = {}) { }; const readPanel = () => document.querySelector(panelSelector); const readCancelButton = (panel = readPanel()) => panel?.querySelector(cancelButtonSelector) || null; + const readStatus = (panel = readPanel()) => panel?.querySelector(statusSelector) || null; const readDialog = () => Array.from(document.querySelectorAll(dialogSelector)) .filter(isVisible) .filter((dialog) => matchesDialogText(normalizeText(dialog.textContent))) @@ -123,7 +125,7 @@ export function installBinanceLivePerformanceProbe(options = {}) { cancelButtonPresent: Boolean(cancelButton), cancelButtonText: normalizeText(cancelButton?.textContent), cancelButtonDisabled: Boolean(cancelButton?.disabled), - statusText: normalizeText(panel?.lastElementChild?.textContent), + statusText: normalizeText(readStatus(panel)?.textContent), dialogVisible: Boolean(dialog), dialogText: normalizeText(dialog?.textContent), }; diff --git a/e2e/binance-orderbook/specs/live-performance-probe.pw.js b/e2e/binance-orderbook/specs/live-performance-probe.pw.js index b814eb0..a46d9fe 100644 --- a/e2e/binance-orderbook/specs/live-performance-probe.pw.js +++ b/e2e/binance-orderbook/specs/live-performance-probe.pw.js @@ -27,6 +27,7 @@ test('live probe captures a no-order run and destroys every listener', async ({ const snapshot = await finishLivePerformanceProbe(page); expect(() => validateLivePerformanceProbeSnapshot(snapshot)).not.toThrow(); expect(snapshot.events.map((event) => event.kind)).toContain('first-feedback'); + expect(snapshot.lastSemanticState.statusText).toBe('HYPEUSDT 当前币无挂单'); const finishedEventCount = snapshot.events.length; await page.evaluate(() => { @@ -99,12 +100,13 @@ test('live probe follows a userscript panel replaced after arm and before click' await page.evaluate(() => { const panel = document.createElement('section'); panel.id = 'probe-panel'; - panel.innerHTML = '
Idle
'; + panel.innerHTML = 'Idle
'; document.body.append(panel); }); await installLivePerformanceProbe(page, { panelSelector: '#probe-panel', cancelButtonSelector: '[data-probe-cancel="true"]', + statusSelector: '#probe-status', }); await armLivePerformanceProbe(page, 'replaced-panel-before-click'); await page.evaluate(() => { diff --git a/scripts/binance-live-performance.mjs b/scripts/binance-live-performance.mjs index 7a1a887..43f2b2f 100644 --- a/scripts/binance-live-performance.mjs +++ b/scripts/binance-live-performance.mjs @@ -27,6 +27,24 @@ const ENVIRONMENT_FIELDS = [ const ORDER_LEDGER_FIELDS = ['created', 'fills', 'residual']; const ORDER_RECORD_FIELDS = ['createdAt', 'positionSide', 'price', 'quantity', 'side', 'symbol']; const SUMMARY_STAT_FIELDS = ['min', 'median', 'p95', 'max']; +const SCENARIO_SEGMENTS = Object.freeze({ + 'no-orders': Object.freeze(['clickToFirstFeedback', 'clickToFinalReady']), + 'dialog-cancel': Object.freeze([ + 'clickToFirstFeedback', + 'clickToDialog', + 'decisionToFinalReady', + ]), + 'dialog-confirm': Object.freeze([ + 'clickToFirstFeedback', + 'clickToDialog', + 'decisionToFinalReady', + ]), + 'order-scale': Object.freeze([ + 'clickToFirstFeedback', + 'clickToDialog', + 'decisionToFinalReady', + ]), +}); function assertRecord(value, path) { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -153,6 +171,14 @@ function validateScenarioParameters(parameters, path) { return parameters; } +export function getLiveScenarioSegments(kind) { + const segments = SCENARIO_SEGMENTS[kind]; + if (!segments) { + throw new Error(`Unsupported live scenario kind: ${kind}`); + } + return [...segments]; +} + function validateCapacityEvidenceForScenario(evidence, parameters, ledger, path) { if (parameters.kind === 'no-orders') { if (evidence !== null) throw new Error(`${path} must be null for a no-orders scenario`); @@ -209,6 +235,12 @@ export function validateLivePerformanceCapture(capture) { throw new Error(`${path}.applicableSegments[${segmentIndex}] must be a non-empty string`); } } + if ( + JSON.stringify(scenario.applicableSegments) + !== JSON.stringify(getLiveScenarioSegments(scenario.parameters.kind)) + ) { + throw new Error(`${path}.applicableSegments must match the ${scenario.parameters.kind} contract`); + } if (!Array.isArray(scenario.samples) || scenario.samples.length < 3) { throw new Error(`${path}.samples must contain at least three isolated samples`); } diff --git a/test/unit/binance-live-capture-builder.test.js b/test/unit/binance-live-capture-builder.test.js new file mode 100644 index 0000000..2afe154 --- /dev/null +++ b/test/unit/binance-live-capture-builder.test.js @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildLivePerformanceCapture, + buildLivePerformanceSample, +} from '../../e2e/binance-orderbook/helpers/live-capture-builder.js'; + +const EMPTY_LEDGER = Object.freeze({ created: [], fills: [], residual: [] }); + +function probeSnapshot(events) { + return { + schemaVersion: 1, + sessionId: 'probe-session', + scenarioName: 'scenario', + capturedAt: '2026-08-27T01:00:00.000Z', + timeOriginMs: 1000, + armedAtMonotonicMs: 10, + startedAtMonotonicMs: 20, + startedAtWallClock: '2026-08-27T01:00:00.010Z', + finishedAtMonotonicMs: 220, + performanceSupport: { longTask: true, longAnimationFrame: true }, + dropped: { events: 0, errors: 0, longTasks: 0, longAnimationFrames: 0 }, + events: [ + { kind: 'cancel-click', atMs: 0, detail: null }, + ...events, + { kind: 'finished', atMs: 200, detail: null }, + ], + errors: [], + longTasks: [{ startTime: 100, duration: 55 }], + longAnimationFrames: [{ startTime: 100, duration: 60 }], + clickSemanticSignature: '{}', + lastSemanticSignature: '{}', + lastSemanticState: {}, + firstFeedbackCaptured: true, + }; +} + +function dialogEvents(primary) { + return [ + { kind: 'first-feedback', atMs: 4, detail: null }, + { kind: 'dialog-visible', atMs: 80, detail: null }, + { kind: 'dialog-action', atMs: 150, detail: { primary, text: primary ? '确认' : '取消' } }, + { kind: 'dialog-hidden', atMs: 160, detail: null }, + ]; +} + +test('builds a no-orders sample directly from one probe snapshot', () => { + const sample = buildLivePerformanceSample({ + parameters: { kind: 'no-orders' }, + probe: probeSnapshot([{ kind: 'first-feedback', atMs: 3, detail: null }]), + capacityEvidence: null, + testOrderLedger: EMPTY_LEDGER, + stateRestored: true, + }); + + assert.deepEqual(sample.segmentsMs, { + clickToFirstFeedback: 3, + clickToFinalReady: 200, + }); + assert.equal(sample.maxLongTaskMs, 55); + assert.equal(sample.maxLongAnimationFrameMs, 60); + assert.equal(sample.uncaughtErrors, 0); +}); + +test('derives cancel and confirm decision timing from semantic probe events', () => { + const cancel = buildLivePerformanceSample({ + parameters: { kind: 'dialog-cancel', testOrderCount: 1 }, + probe: probeSnapshot(dialogEvents(false)), + capacityEvidence: {}, + testOrderLedger: { created: [{}], fills: [], residual: [] }, + stateRestored: true, + }); + assert.deepEqual(cancel.segmentsMs, { + clickToFirstFeedback: 4, + clickToDialog: 80, + decisionToFinalReady: 50, + }); + + const confirm = buildLivePerformanceSample({ + parameters: { kind: 'dialog-confirm', testOrderCount: 1 }, + probe: probeSnapshot(dialogEvents(true)), + capacityEvidence: {}, + testOrderLedger: { created: [{}], fills: [], residual: [] }, + stateRestored: true, + }); + assert.deepEqual(confirm.segmentsMs, cancel.segmentsMs); +}); + +test('rejects a dialog outcome that contradicts the declared scenario kind', () => { + assert.throws( + () => buildLivePerformanceSample({ + parameters: { kind: 'dialog-confirm', testOrderCount: 1 }, + probe: probeSnapshot(dialogEvents(false)), + capacityEvidence: {}, + testOrderLedger: { created: [{}], fills: [], residual: [] }, + stateRestored: true, + }), + /requires the primary dialog action/, + ); +}); + +test('rejects a dialog that closes before the recorded user decision', () => { + const events = dialogEvents(true); + events.find((event) => event.kind === 'dialog-hidden').atMs = 140; + assert.throws( + () => buildLivePerformanceSample({ + parameters: { kind: 'dialog-confirm', testOrderCount: 1 }, + probe: probeSnapshot(events), + capacityEvidence: {}, + testOrderLedger: { created: [{}], fills: [], residual: [] }, + stateRestored: true, + }), + /dialog events are out of order/, + ); +}); + +test('builds and validates a complete three-sample capture without manual timing transcription', () => { + const capture = buildLivePerformanceCapture({ + capturedAt: '2026-08-27T01:00:00.000Z', + environment: { + browser: 'Chrome 151.0.0.0', + os: 'macOS (MacIntel)', + route: 'https://www.binance.com/zh-CN/futures/HYPEUSDT', + symbol: 'HYPEUSDT', + userscriptSha256: 'a'.repeat(64), + userscriptVersion: '2.7.126', + }, + scenarios: [{ + name: 'cancel-current-symbol-no-orders', + parameters: { kind: 'no-orders' }, + samples: [1, 2, 3].map((atMs) => ({ + probe: probeSnapshot([{ kind: 'first-feedback', atMs, detail: null }]), + capacityEvidence: null, + testOrderLedger: EMPTY_LEDGER, + stateRestored: true, + })), + }], + }); + + assert.equal(capture.scenarios[0].samples.length, 3); + assert.deepEqual(capture.scenarios[0].applicableSegments, [ + 'clickToFirstFeedback', + 'clickToFinalReady', + ]); +}); diff --git a/test/unit/binance-live-performance.test.js b/test/unit/binance-live-performance.test.js index f630747..6b0fa0b 100644 --- a/test/unit/binance-live-performance.test.js +++ b/test/unit/binance-live-performance.test.js @@ -124,6 +124,18 @@ test('live performance capture requires three complete isolated samples', () => ); }); +test('scenario kind fixes the exact wall-clock segment contract', () => { + const invalid = capture(); + invalid.scenarios[0].applicableSegments = ['clickToFirstFeedback', 'decisionToFinalReady']; + for (const entry of invalid.scenarios[0].samples) { + delete entry.segmentsMs.clickToDialog; + } + assert.throws( + () => validateLivePerformanceCapture(invalid), + /applicableSegments must match the dialog-cancel contract/, + ); +}); + test('live performance capture models no-orders, dialog-cancel, and dialog-confirm explicitly', () => { const dialogCancel = capture(); assert.equal(validateLivePerformanceCapture(dialogCancel).scenarios[0].parameters.kind, 'dialog-cancel'); @@ -136,9 +148,17 @@ test('live performance capture models no-orders, dialog-cancel, and dialog-confi const noOrders = structuredClone(dialogCancel); noOrders.scenarios[0].name = 'cancel-current-symbol-no-orders'; noOrders.scenarios[0].parameters = { kind: 'no-orders' }; + noOrders.scenarios[0].applicableSegments = [ + 'clickToFirstFeedback', + 'clickToFinalReady', + ]; for (const entry of noOrders.scenarios[0].samples) { entry.capacityEvidence = null; entry.testOrderLedger = structuredClone(EMPTY_ORDER_LEDGER); + entry.segmentsMs = { + clickToFirstFeedback: entry.segmentsMs.clickToFirstFeedback, + clickToFinalReady: entry.segmentsMs.decisionToFinalReady, + }; } assert.equal(validateLivePerformanceCapture(noOrders).scenarios[0].parameters.kind, 'no-orders'); });