Skip to content

Commit a8ecd7f

Browse files
committed
test: measure live page readiness in page
1 parent 0dc4e6a commit a8ecd7f

6 files changed

Lines changed: 452 additions & 42 deletions

File tree

‎docs/binance-orderbook-trade-ui-automation.md‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,29 @@ exposes overflow counts for every bounded stream. A capture is invalid when the
245245
browser does not support Long Task or Long Animation Frame evidence, any stream
246246
overflows, or any uncaught error is observed.
247247

248+
Before clicking, use `createLivePerformanceCompletionPreparationExpression()` with
249+
raw CDP or `prepareLivePerformanceProbeCompletion()` in Playwright and wait for its
250+
immediate `{ prepared: true }` result. After the business action, collect the stored
251+
promise with `createLivePerformanceCompletionResultExpression()` or
252+
`finishLivePerformanceProbeWhenReady()`. This explicit prepare/action/collect order
253+
prevents observer-installation latency from entering the sample. The completion
254+
observer runs inside the target page, verifies the exact
255+
no-order/cancel/confirm terminal contract, marks semantic completion immediately, and
256+
keeps only the performance observers alive through the next page task. This preserves
257+
the true completion timestamp while still collecting the tail of the click task,
258+
without adding browser-control or animation-frame scheduling jitter. Do not poll the
259+
page from the automation process and call
260+
`finish()` afterward: browser-control round trips would be counted as application
261+
latency and would corrupt `clickToFinalReady` or `decisionToFinalReady`. Probe finish
262+
also drains `PerformanceObserver.takeRecords()` before disconnecting, so a final long
263+
task or long animation frame cannot disappear merely because its callback has not run.
264+
The completion observer rechecks in a microtask after relevant DOM mutations so it
265+
sees the probe's semantic sample from the same mutation batch; it must not wait for an
266+
unrelated later mutation to discover that the action already finished.
267+
Each isolated sample must begin from the normal enabled `撤单` state with no native
268+
dialog visible. `arm()` rejects a sample while the prior `无挂单` feedback window is
269+
still active, rather than recording that carry-over as the next run's first feedback.
270+
248271
Pass the validated probe snapshots to
249272
`e2e/binance-orderbook/helpers/live-capture-builder.js`. The builder derives every
250273
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
265288
or an existing capture. This command is the standard boundary between CDP collection
266289
and capture summary; do not replace it with an ad hoc Node snippet.
267290

268-
The current checked-in L4 reference is the isolated zero-order HYPEUSDT run:
291+
The current checked-in L4 reference is the isolated zero-order HYPEUSDT run. The
292+
older captures remain immutable evidence; the active baseline comes from the latest
293+
page-owned completion sample:
269294

270-
- `e2e/binance-orderbook/live-baselines/no-orders-2026-08-27.capture.json`
295+
- `e2e/binance-orderbook/live-baselines/no-orders-2026-08-27-page-ready.capture.json`
271296
- `e2e/binance-orderbook/live-baselines/no-orders.baseline.json`
272297

273298
Each scenario kind owns one exact wall-clock segment contract. A scenario cannot add,

‎e2e/binance-orderbook/helpers/live-performance-probe.js‎

Lines changed: 234 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const DEFAULT_GLOBAL_NAME = '__BINANCE_LIVE_PERFORMANCE_PROBE__';
2+
const DEFAULT_COMPLETION_GLOBAL_NAME = '__BINANCE_LIVE_PERFORMANCE_COMPLETION__';
23

34
function assertRecord(value, path) {
45
if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -66,11 +67,122 @@ export function validateLivePerformanceProbeSnapshot(snapshot) {
6667
return snapshot;
6768
}
6869

70+
export function waitForBinanceLivePerformanceCompletion(options = {}) {
71+
const globalName = options.globalName || '__BINANCE_LIVE_PERFORMANCE_PROBE__';
72+
const kind = options.kind;
73+
const timeoutMs = options.timeoutMs || 5_000;
74+
const panelSelector = options.panelSelector || '#jh-binance-close-qty-multiplier-panel';
75+
const cancelButtonSelector = options.cancelButtonSelector
76+
|| '[data-ladder-cancel-symbol="true"]';
77+
const statusSelector = options.statusSelector || '#jh-binance-ladder-status';
78+
const dialogSelector = options.dialogSelector
79+
|| '[role="dialog"], [class*="modal"], [class*="Modal"]';
80+
const supportedKinds = ['no-orders', 'dialog-cancel', 'dialog-confirm'];
81+
if (!supportedKinds.includes(kind)) {
82+
throw new Error(`Unsupported live completion kind: ${kind}`);
83+
}
84+
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
85+
throw new Error('timeoutMs must be a positive integer');
86+
}
87+
if (!document.body) throw new Error('Live completion wait requires document.body');
88+
const probe = window[globalName];
89+
if (!probe) throw new Error(`Live performance probe is not installed: ${globalName}`);
90+
91+
const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
92+
const isVisible = (element) => Boolean(element && element.getClientRects().length);
93+
const matchesDialogText = (value) => /取消全部订单|Cancel all orders/i.test(value);
94+
const readVisibleDialog = () => Array.from(document.querySelectorAll(dialogSelector))
95+
.filter(isVisible)
96+
.find((dialog) => matchesDialogText(normalizeText(dialog.textContent))) || null;
97+
const readState = () => {
98+
const panel = document.querySelector(panelSelector);
99+
const cancelButton = panel?.querySelector(cancelButtonSelector) || null;
100+
return {
101+
cancelButtonPresent: Boolean(cancelButton),
102+
cancelButtonDisabled: Boolean(cancelButton?.disabled),
103+
cancelButtonText: normalizeText(cancelButton?.textContent),
104+
statusText: normalizeText(panel?.querySelector(statusSelector)?.textContent),
105+
dialogVisible: Boolean(readVisibleDialog()),
106+
};
107+
};
108+
const hasEvent = (snapshot, eventKind) => (
109+
snapshot.events.some((event) => event.kind === eventKind)
110+
);
111+
const isReady = () => {
112+
const snapshot = probe.snapshot();
113+
if (snapshot.startedAtMonotonicMs === null || snapshot.finishedAtMonotonicMs !== null) {
114+
return false;
115+
}
116+
if (!snapshot.firstFeedbackCaptured || !hasEvent(snapshot, 'first-feedback')) return false;
117+
const state = readState();
118+
if (!state.cancelButtonPresent || state.cancelButtonDisabled || state.dialogVisible) return false;
119+
120+
if (kind === 'no-orders') {
121+
if (hasEvent(snapshot, 'dialog-visible') || hasEvent(snapshot, 'dialog-action')) return false;
122+
return state.cancelButtonText === '无挂单' || /当前币无挂单/.test(state.statusText);
123+
}
124+
125+
const dialogActions = snapshot.events.filter((event) => event.kind === 'dialog-action');
126+
if (dialogActions.length !== 1 || !hasEvent(snapshot, 'dialog-hidden')) return false;
127+
const expectsPrimary = kind === 'dialog-confirm';
128+
if (dialogActions[0].detail?.primary !== expectsPrimary) return false;
129+
return kind === 'dialog-cancel'
130+
? /已取消撤单.*已恢复页面状态/.test(state.statusText)
131+
: /撤单流程结束.*已恢复筛选状态/.test(state.statusText);
132+
};
133+
134+
return new Promise((resolve, reject) => {
135+
let settled = false;
136+
let checkQueued = false;
137+
const queueAttempt = () => {
138+
if (settled || checkQueued) return;
139+
checkQueued = true;
140+
queueMicrotask(() => {
141+
checkQueued = false;
142+
attemptFinish();
143+
});
144+
};
145+
const observer = new MutationObserver(queueAttempt);
146+
const cleanup = () => {
147+
observer.disconnect();
148+
window.clearTimeout(timeoutId);
149+
};
150+
const fail = (error) => {
151+
if (settled) return;
152+
settled = true;
153+
cleanup();
154+
reject(error);
155+
};
156+
function attemptFinish() {
157+
if (settled || !isReady()) return;
158+
settled = true;
159+
cleanup();
160+
try {
161+
resolve(probe.finishAfterPerformanceTail());
162+
} catch (error) {
163+
reject(error);
164+
}
165+
}
166+
const timeoutId = window.setTimeout(() => {
167+
fail(new Error(`Live completion timed out for ${kind}`));
168+
}, timeoutMs);
169+
observer.observe(document.body, {
170+
subtree: true,
171+
childList: true,
172+
characterData: true,
173+
attributes: true,
174+
attributeFilter: ['disabled', 'aria-disabled', 'class', 'style'],
175+
});
176+
attemptFinish();
177+
});
178+
}
179+
69180
export function installBinanceLivePerformanceProbe(options = {}) {
70181
const globalName = options.globalName || '__BINANCE_LIVE_PERFORMANCE_PROBE__';
71182
const panelSelector = options.panelSelector || '#jh-binance-close-qty-multiplier-panel';
72183
const cancelButtonSelector = options.cancelButtonSelector
73184
|| '[data-ladder-cancel-symbol="true"]';
185+
const readyCancelButtonText = options.readyCancelButtonText || '撤单';
74186
const statusSelector = options.statusSelector || '#jh-binance-ladder-status';
75187
const dialogSelector = options.dialogSelector
76188
|| '[role="dialog"], [class*="modal"], [class*="Modal"]';
@@ -139,6 +251,8 @@ export function installBinanceLivePerformanceProbe(options = {}) {
139251
let observedDialogRoot = null;
140252
let longTaskObserver = null;
141253
let longAnimationFrameObserver = null;
254+
let performanceCollectionActive = false;
255+
let finalizationPromise = null;
142256
let sampleQueued = false;
143257
const hasStarted = () => Boolean(run && run.startedAtMonotonicMs !== null);
144258
const isCollecting = () => Boolean(hasStarted() && run.finishedAtMonotonicMs === null);
@@ -159,40 +273,57 @@ export function installBinanceLivePerformanceProbe(options = {}) {
159273
if (!isCollecting()) return;
160274
appendBounded('events', { kind, atMs: relativeNow(), detail }, eventLimit);
161275
};
276+
const recordLongTasks = (entries) => {
277+
if (!performanceCollectionActive) return;
278+
const cutoff = run.finishedAtMonotonicMs ?? Number.POSITIVE_INFINITY;
279+
for (const entry of entries) {
280+
if (entry.startTime > cutoff) continue;
281+
appendBounded('longTasks', {
282+
startTime: entry.startTime,
283+
duration: entry.duration,
284+
}, performanceLimit);
285+
}
286+
};
287+
const recordLongAnimationFrames = (entries) => {
288+
if (!performanceCollectionActive) return;
289+
const cutoff = run.finishedAtMonotonicMs ?? Number.POSITIVE_INFINITY;
290+
for (const entry of entries) {
291+
if (entry.startTime > cutoff) continue;
292+
appendBounded('longAnimationFrames', {
293+
startTime: entry.startTime,
294+
duration: entry.duration,
295+
blockingDuration: entry.blockingDuration,
296+
forcedStyleAndLayoutDuration: entry.scripts?.reduce(
297+
(total, script) => total + (script.forcedStyleAndLayoutDuration || 0),
298+
0,
299+
) || 0,
300+
}, performanceLimit);
301+
}
302+
};
303+
const flushPerformanceObservers = () => {
304+
recordLongTasks(longTaskObserver?.takeRecords?.() || []);
305+
recordLongAnimationFrames(longAnimationFrameObserver?.takeRecords?.() || []);
306+
};
162307
const stopPerformanceObservers = () => {
308+
flushPerformanceObservers();
163309
longTaskObserver?.disconnect();
164310
longAnimationFrameObserver?.disconnect();
165311
longTaskObserver = null;
166312
longAnimationFrameObserver = null;
313+
performanceCollectionActive = false;
167314
};
168315
const startPerformanceObservers = () => {
169316
stopPerformanceObservers();
317+
performanceCollectionActive = true;
170318
if (run.performanceSupport.longTask) {
171319
longTaskObserver = new PerformanceObserver((list) => {
172-
if (!isCollecting()) return;
173-
for (const entry of list.getEntries()) {
174-
appendBounded('longTasks', {
175-
startTime: entry.startTime,
176-
duration: entry.duration,
177-
}, performanceLimit);
178-
}
320+
recordLongTasks(list.getEntries());
179321
});
180322
longTaskObserver.observe({ type: 'longtask' });
181323
}
182324
if (run.performanceSupport.longAnimationFrame) {
183325
longAnimationFrameObserver = new PerformanceObserver((list) => {
184-
if (!isCollecting()) return;
185-
for (const entry of list.getEntries()) {
186-
appendBounded('longAnimationFrames', {
187-
startTime: entry.startTime,
188-
duration: entry.duration,
189-
blockingDuration: entry.blockingDuration,
190-
forcedStyleAndLayoutDuration: entry.scripts?.reduce(
191-
(total, script) => total + (script.forcedStyleAndLayoutDuration || 0),
192-
0,
193-
) || 0,
194-
}, performanceLimit);
195-
}
326+
recordLongAnimationFrames(list.getEntries());
196327
});
197328
longAnimationFrameObserver.observe({ type: 'long-animation-frame' });
198329
}
@@ -346,16 +477,38 @@ export function installBinanceLivePerformanceProbe(options = {}) {
346477
window.addEventListener('unhandledrejection', handleUnhandledRejection);
347478

348479
const cloneRun = () => structuredClone(run);
480+
const markRunFinished = () => {
481+
if (!hasStarted()) throw new Error('Live performance probe has not started');
482+
if (run.finishedAtMonotonicMs === null) {
483+
recordSemanticState('finish');
484+
appendEvent('finished');
485+
run.finishedAtMonotonicMs = performance.now();
486+
stopMutationObservers();
487+
}
488+
};
349489
const api = {
350490
arm(scenarioName) {
351491
if (destroyed) throw new Error('Live performance probe is destroyed');
492+
if (finalizationPromise) {
493+
throw new Error('Cannot arm while live performance evidence is finalizing');
494+
}
352495
if (typeof scenarioName !== 'string' || scenarioName.length === 0) {
353496
throw new Error('scenarioName must be a non-empty string');
354497
}
355498
if (run && run.finishedAtMonotonicMs === null) {
356499
if (run.startedAtMonotonicMs === null && run.scenarioName === scenarioName) return cloneRun();
357500
throw new Error('Cannot arm while a live performance run is active');
358501
}
502+
const initialState = readSemanticState();
503+
if (
504+
!initialState.panelPresent
505+
|| !initialState.cancelButtonPresent
506+
|| initialState.cancelButtonDisabled
507+
|| initialState.cancelButtonText !== readyCancelButtonText
508+
|| initialState.dialogVisible
509+
) {
510+
throw new Error('Live performance probe cannot arm before the cancel UI is fully ready');
511+
}
359512
stopPerformanceObservers();
360513
const armedAtMonotonicMs = performance.now();
361514
run = {
@@ -391,16 +544,26 @@ export function installBinanceLivePerformanceProbe(options = {}) {
391544
return cloneRun();
392545
},
393546
finish() {
394-
if (!hasStarted()) throw new Error('Live performance probe has not started');
395-
if (run.finishedAtMonotonicMs === null) {
396-
recordSemanticState('finish');
397-
appendEvent('finished');
398-
run.finishedAtMonotonicMs = performance.now();
399-
stopPerformanceObservers();
400-
stopMutationObservers();
547+
if (finalizationPromise) {
548+
throw new Error('Live performance evidence is still finalizing');
401549
}
550+
markRunFinished();
551+
stopPerformanceObservers();
402552
return cloneRun();
403553
},
554+
finishAfterPerformanceTail() {
555+
if (finalizationPromise) return finalizationPromise;
556+
markRunFinished();
557+
finalizationPromise = new Promise((resolve) => {
558+
window.setTimeout(() => {
559+
stopPerformanceObservers();
560+
const snapshot = cloneRun();
561+
finalizationPromise = null;
562+
resolve(snapshot);
563+
}, 0);
564+
});
565+
return finalizationPromise;
566+
},
404567
destroy() {
405568
if (destroyed) return run ? cloneRun() : null;
406569
destroyed = true;
@@ -421,6 +584,39 @@ export function createLivePerformanceProbeExpression(options = {}) {
421584
return `(${installBinanceLivePerformanceProbe.toString()})(${JSON.stringify(options)})`;
422585
}
423586

587+
export function createLivePerformanceCompletionExpression(options = {}) {
588+
return `(${waitForBinanceLivePerformanceCompletion.toString()})(${JSON.stringify(options)})`;
589+
}
590+
591+
export function createLivePerformanceCompletionPreparationExpression(options = {}) {
592+
const completionGlobalName = options.completionGlobalName || DEFAULT_COMPLETION_GLOBAL_NAME;
593+
const waitOptions = { ...options };
594+
delete waitOptions.completionGlobalName;
595+
const waitExpression = createLivePerformanceCompletionExpression(waitOptions);
596+
return `(() => {
597+
const completionGlobalName = ${JSON.stringify(completionGlobalName)};
598+
if (window[completionGlobalName]) {
599+
throw new Error('Live performance completion is already prepared');
600+
}
601+
window[completionGlobalName] = ${waitExpression};
602+
return { completionGlobalName, prepared: true };
603+
})()`;
604+
}
605+
606+
export function createLivePerformanceCompletionResultExpression(options = {}) {
607+
const completionGlobalName = options.completionGlobalName || DEFAULT_COMPLETION_GLOBAL_NAME;
608+
return `(async () => {
609+
const completionGlobalName = ${JSON.stringify(completionGlobalName)};
610+
const completion = window[completionGlobalName];
611+
if (!completion) throw new Error('Live performance completion is not prepared');
612+
try {
613+
return await completion;
614+
} finally {
615+
delete window[completionGlobalName];
616+
}
617+
})()`;
618+
}
619+
424620
export async function installLivePerformanceProbe(page, options = {}) {
425621
return page.evaluate(installBinanceLivePerformanceProbe, options);
426622
}
@@ -436,6 +632,18 @@ export async function finishLivePerformanceProbe(page) {
436632
return page.evaluate((globalName) => window[globalName].finish(), DEFAULT_GLOBAL_NAME);
437633
}
438634

635+
export async function prepareLivePerformanceProbeCompletion(page, kind, options = {}) {
636+
return page.evaluate(createLivePerformanceCompletionPreparationExpression({
637+
...options,
638+
globalName: DEFAULT_GLOBAL_NAME,
639+
kind,
640+
}));
641+
}
642+
643+
export async function finishLivePerformanceProbeWhenReady(page, options = {}) {
644+
return page.evaluate(createLivePerformanceCompletionResultExpression(options));
645+
}
646+
439647
export async function destroyLivePerformanceProbe(page) {
440648
return page.evaluate((globalName) => window[globalName]?.destroy?.() || null, DEFAULT_GLOBAL_NAME);
441649
}

0 commit comments

Comments
 (0)