Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions docs/binance-orderbook-trade-ui-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
141 changes: 141 additions & 0 deletions e2e/binance-orderbook/helpers/live-capture-builder.js
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 3 additions & 1 deletion e2e/binance-orderbook/helpers/live-performance-probe.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)))
Expand All @@ -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),
};
Expand Down
4 changes: 3 additions & 1 deletion e2e/binance-orderbook/specs/live-performance-probe.pw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 = '<button data-probe-cancel="true">Probe cancel</button><p>Idle</p>';
panel.innerHTML = '<button data-probe-cancel="true">Probe cancel</button><p id="probe-status">Idle</p>';
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(() => {
Expand Down
32 changes: 32 additions & 0 deletions scripts/binance-live-performance.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -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`);
}
Expand Down
Loading
Loading