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 @@ -62,13 +62,21 @@ Every cancel-current-symbol scenario must assert all applicable invariants:

## Performance Contract

Deterministic L2 tests use hard local budgets:

- click-to-first-feedback: at most 100 ms;
- no userscript-attributable long task above 50 ms;
Deterministic L2 functional tests use hard user-facing budgets while retaining
lower-level performance signals for diagnosis:

- click-to-first-feedback: at most 200 ms;
- no observed long task or long animation frame above 200 ms;
- every task above 50 ms remains recorded as a diagnostic signal, but it is not
attributed to the userscript without a supporting Long Animation Frame script
entry or CDP trace;
- no unexpected movement of unrelated action controls;
- no repeated DOM writes after the scenario reaches its final state.

Dedicated performance runs use multiple isolated samples and compare median and
p95 values with the checked-in baseline. A single un-attributed task from a shared
headless browser is not a stable regression gate.

L4 records wall-clock segments instead of using a single brittle total timeout:

1. click to first feedback;
Expand Down
115 changes: 112 additions & 3 deletions e2e/binance-orderbook/fixtures/binance-futures.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ export function renderBinanceFuturesFixture(scenario) {
.order-entry button { width: 48%; height: 36px; }
#futuresOrderbook { width: 300px; min-height: 300px; }
.orderbook-tickSize, .row-content, .tradew-tradelist { min-height: 24px; }
.orderbook-tickSize { position: relative; width: 120px; }
.bn-tooltips-ele { display: inline-block; min-width: 70px; min-height: 24px; }
.ob-ticksize-overlay { position: absolute; top: 24px; left: 0; z-index: 20; width: 120px; padding: 4px; background: #fff; border: 1px solid #d8dce1; }
.ob-ticksize-item { display: block; min-height: 28px; padding: 4px; }
.row-content { display: flex; gap: 16px; }
.emit-price { display: inline-block; min-width: 70px; min-height: 20px; }
.chart-widget-root { width: 700px; height: 300px; margin-top: 20px; border: 1px solid #d8dce1; }
Expand All @@ -47,11 +51,19 @@ export function renderBinanceFuturesFixture(scenario) {
<div id="fixture-layout">
<main id="fixture-main">
<section id="futuresOrderbook">
<div class="orderbook-tickSize"><span class="tick-content">0.1</span></div>
<div class="orderbook-tickSize"><div class="bn-tooltips-ele"><span class="tick-content">0.1</span></div></div>
<div class="row-content"><span class="ask-light emit-price">81.2</span></div>
<div class="row-content"><span class="ask-light emit-price">81.1</span></div>
<div class="row-content"><span class="ask-light emit-price">81.05</span></div>
<div class="row-content"><span class="ask-light emit-price">81.04</span></div>
<div class="row-content"><span class="ask-light emit-price">81.03</span></div>
<div class="row-content"><span class="ask-light emit-price">81.02</span></div>
<div class="row-content"><span class="bid-light emit-price">81.0</span></div>
<div class="row-content"><span class="bid-light emit-price">80.9</span></div>
<div class="row-content"><span class="bid-light emit-price">80.8</span></div>
<div class="row-content"><span class="bid-light emit-price">80.7</span></div>
<div class="row-content"><span class="bid-light emit-price">80.6</span></div>
<div class="row-content"><span class="bid-light emit-price">80.5</span></div>
</section>
<section class="tradew-tradelist">
<span class="price emit-price">81.00</span><span class="price emit-price">81.01</span>
Expand All @@ -66,8 +78,8 @@ export function renderBinanceFuturesFixture(scenario) {
<div class="trade-mode-row">
<div class="trade-mode-column">
<div id="position-direction">
<div role="tab" aria-selected="true">开仓</div>
<div role="tab" aria-selected="false">平仓</div>
<div role="tab" data-trade-mode="OPEN" aria-selected="true">开仓</div>
<div role="tab" data-trade-mode="CLOSE" aria-selected="false">平仓</div>
</div>
<div class="order-type-tabs"><div role="tab" data-tab-key="POST_ONLY" aria-selected="true">只做Maker</div></div>
</div>
Expand All @@ -92,6 +104,9 @@ export function renderBinanceFuturesFixture(scenario) {
openOrdersSubTab: scenario.ui.openOrdersSubTab,
hideOtherSymbols: scenario.ui.hideOtherSymbols,
showOrders: scenario.ui.showOrders,
tradeMode: scenario.ui.tradeMode,
orderbookPrecision: scenario.ui.orderbookPrecision,
leverage: scenario.ui.leverage,
dialogOpen: false,
events: [],
};
Expand All @@ -105,6 +120,90 @@ export function renderBinanceFuturesFixture(scenario) {
const visibleOrders = () => state.hideOtherSymbols ? currentOrders() : state.orders;
const selected = (value, expected) => String(value === expected);
const scheduleCommit = (callback) => setTimeout(callback, scenario.host.mutationDelayMs);
const userscriptFetch = window.fetch;
window.fetch = async (...args) => {
const response = await userscriptFetch(...args);
const url = new URL(typeof args[0] === 'string' ? args[0] : args[0].url, location.href);
if (url.pathname === '/bapi/futures/v1/private/future/user-data/adjustLeverage' && response.ok) {
const body = JSON.parse(args[1].body);
state.leverage = body.leverage;
document.querySelector('.quick-controls button:nth-child(2)').textContent = body.leverage + 'x';
record('leverage-adjusted', { symbol: body.symbol, leverage: body.leverage });
}
return response;
};

function currentPositionQuantity(side) {
return state.positions
.filter((item) => item.symbol === scenario.currentSymbol && item.side === side)
.reduce((total, item) => total + Number(item.quantity), 0);
}

function renderTradeMode() {
const direction = document.querySelector('#position-direction');
direction.innerHTML =
'<div role="tab" data-trade-mode="OPEN" aria-selected="' + selected(state.tradeMode, 'OPEN') + '">开仓</div>' +
'<div role="tab" data-trade-mode="CLOSE" aria-selected="' + selected(state.tradeMode, 'CLOSE') + '">平仓</div>';
const orderEntry = document.querySelector('.order-entry');
if (state.tradeMode === 'OPEN') {
orderEntry.innerHTML =
'<input id="limitPrice-open" value="81.0"><input id="unitAmount-open" value="">' +
'<button type="button">开多</button><button type="button">开空</button>' +
'<div data-testid="max-buy-amount">可开 10 HYPE</div>' +
'<div data-testid="max-sell-amount">可开 10 HYPE</div>';
} else {
orderEntry.innerHTML =
'<input id="limitPrice-close" value="81.0"><input id="unitAmount-close" value="">' +
'<button type="button">平多</button><button type="button">平空</button>' +
'<div data-testid="max-sell-amount">可平 ' + currentPositionQuantity('LONG') + ' HYPE</div>' +
'<div data-testid="max-buy-amount">可平 ' + currentPositionQuantity('SHORT') + ' HYPE</div>';
}
direction.querySelectorAll('[data-trade-mode]').forEach((tab) => {
tab.addEventListener('click', () => scheduleCommit(() => {
state.tradeMode = tab.dataset.tradeMode;
record('trade-mode', { value: state.tradeMode });
renderTradeMode();
}));
});
orderEntry.querySelectorAll('button').forEach((button) => {
button.addEventListener('click', () => {
const feedback = document.createElement('div');
feedback.setAttribute('role', 'alert');
feedback.textContent = '订单已提交成功';
document.body.append(feedback);
record('order-submitted', {
action: button.textContent.trim(),
price: orderEntry.querySelector('input[id^="limitPrice-"]')?.value || '',
quantity: orderEntry.querySelector('input[id^="unitAmount-"]')?.value || '',
});
});
});
}

function renderPrecisionOverlay() {
const tickSize = document.querySelector('#futuresOrderbook .orderbook-tickSize');
const existing = tickSize.querySelector('.ob-ticksize-overlay');
if (existing) {
existing.remove();
record('precision-overlay-closed');
return;
}
const overlay = document.createElement('div');
overlay.className = 'ob-ticksize-overlay';
overlay.innerHTML = scenario.host.precisionOptions.map((value) => (
'<div class="ob-ticksize-item" data-precision-value="' + value + '"><span>' + value + '</span></div>'
)).join('');
tickSize.append(overlay);
record('precision-overlay-opened');
overlay.querySelectorAll('[data-precision-value]').forEach((option) => {
option.addEventListener('click', () => {
state.orderbookPrecision = option.dataset.precisionValue;
tickSize.querySelector('.tick-content').textContent = state.orderbookPrecision;
record('precision-selected', { value: state.orderbookPrecision });
overlay.remove();
});
});
}

localStorage.setItem('jh_binance_ladder_expanded', scenario.ui.ladderExpanded ? 'true' : 'false');
localStorage.setItem('jh_binance_orderbook_precision_samples_v3:' + scenario.currentSymbol, '["81.0","81.01","81.02","81.03","81.04","81.05"]');
Expand Down Expand Up @@ -243,11 +342,21 @@ export function renderBinanceFuturesFixture(scenario) {
openOrdersSubTab: state.openOrdersSubTab,
hideOtherSymbols: state.hideOtherSymbols,
showOrders: state.showOrders,
tradeMode: state.tradeMode,
orderbookPrecision: state.orderbookPrecision,
leverage: state.leverage,
dialogOpen: state.dialogOpen,
events: state.events,
})),
};
document.querySelector('.tick-content').textContent = state.orderbookPrecision;
document.querySelector('.quick-controls button:nth-child(2)').textContent = state.leverage + 'x';
document.querySelector('.bn-tooltips-ele').addEventListener('click', renderPrecisionOverlay);
renderTradeMode();
renderAccountWidget();
window.fetch('/bapi/fixture-bootstrap', {
headers: { csrftoken: 'fixture' },
});
})();
</script>
</body>
Expand Down
33 changes: 33 additions & 0 deletions e2e/binance-orderbook/helpers/interaction-probe.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export async function installInteractionProbe(page, targetSelector) {
startedAt: null,
firstFeedbackAt: null,
longTasks: [],
longAnimationFrames: [],
baseline: {},
};
for (const button of panel.querySelectorAll('[data-ladder-action], [data-ladder-stop]')) {
Expand Down Expand Up @@ -45,10 +46,32 @@ export async function installInteractionProbe(page, targetSelector) {
longTaskObserver.observe({ type: 'longtask', buffered: true });
}

let longAnimationFrameObserver = null;
if (PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) {
longAnimationFrameObserver = new PerformanceObserver((list) => {
state.longAnimationFrames.push(...list.getEntries().map((entry) => ({
startTime: entry.startTime,
duration: entry.duration,
blockingDuration: entry.blockingDuration,
forcedStyleAndLayoutDuration: entry.scripts?.reduce(
(total, script) => total + (script.forcedStyleAndLayoutDuration || 0),
0
) || 0,
scripts: entry.scripts?.map((script) => ({
sourceURL: script.sourceURL,
functionName: script.functionName,
duration: script.duration,
})) || [],
})));
});
longAnimationFrameObserver.observe({ type: 'long-animation-frame', buffered: true });
}

window.__UI_INTERACTION_PROBE__ = {
finish() {
mutationObserver.disconnect();
longTaskObserver?.disconnect();
longAnimationFrameObserver?.disconnect();
const current = {};
for (const button of panel.querySelectorAll('[data-ladder-action], [data-ladder-stop]')) {
const key = button.getAttribute('data-ladder-action') || 'stop';
Expand All @@ -62,6 +85,9 @@ export async function installInteractionProbe(page, targetSelector) {
longTasks: state.longTasks.filter((entry) => (
state.startedAt !== null && entry.startTime >= state.startedAt
)),
longAnimationFrames: state.longAnimationFrames.filter((entry) => (
state.startedAt !== null && entry.startTime >= state.startedAt
)),
baseline: state.baseline,
current,
};
Expand All @@ -74,6 +100,13 @@ export async function finishInteractionProbe(page) {
return page.evaluate(() => window.__UI_INTERACTION_PROBE__.finish());
}

export function assertResponsiveInteraction(expect, probe) {
expect(probe.firstFeedbackMs).not.toBeNull();
expect(probe.firstFeedbackMs).toBeLessThanOrEqual(200);
expect(Math.max(0, ...probe.longTasks.map((entry) => entry.duration))).toBeLessThanOrEqual(200);
expect(Math.max(0, ...probe.longAnimationFrames.map((entry) => entry.duration))).toBeLessThanOrEqual(200);
}

export function assertStableGeometry(expect, baseline, current) {
for (const [key, before] of Object.entries(baseline)) {
expect(current[key], `missing control geometry for ${key}`).toEqual(before);
Expand Down
33 changes: 33 additions & 0 deletions e2e/binance-orderbook/helpers/userscript-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,39 @@ export async function openUserscriptScenario(page, scenario) {
});
return;
}
if (url.pathname === '/bapi/fixture-bootstrap') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true }),
});
return;
}
if (url.pathname === '/bapi/futures/v6/private/future/user-data/user-position') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: scenario.positions.map((position) => ({
symbol: position.symbol,
positionSide: position.side,
positionAmount: position.side === 'SHORT'
? `-${position.quantity}`
: position.quantity,
})),
}),
});
return;
}
if (url.pathname === '/bapi/futures/v1/private/future/user-data/adjustLeverage') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true }),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'text/html',
Expand Down
13 changes: 13 additions & 0 deletions e2e/binance-orderbook/scenarios/cancel-current-symbol.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export function createCancelScenario(overrides = {}) {
hideOtherSymbols: false,
showOrders: true,
ladderExpanded: true,
tradeMode: 'OPEN',
orderbookPrecision: '0.1',
leverage: 2,
...overrides.ui,
},
host: {
Expand All @@ -53,6 +56,7 @@ export function createCancelScenario(overrides = {}) {
dialogMode: 'normal',
dialogReplacementDelayMs: null,
clearMode: 'currentSymbol',
precisionOptions: ['0.001', '0.01', '0.1', '1'],
...overrides.host,
},
};
Expand All @@ -63,6 +67,15 @@ export function createCancelScenario(overrides = {}) {
if (!['basic', 'conditional'].includes(scenario.ui.openOrdersSubTab)) {
throw new Error(`Unsupported open-orders sub-tab: ${scenario.ui.openOrdersSubTab}`);
}
if (!['OPEN', 'CLOSE'].includes(scenario.ui.tradeMode)) {
throw new Error(`Unsupported trade mode: ${scenario.ui.tradeMode}`);
}
if (!Number.isInteger(scenario.ui.leverage) || scenario.ui.leverage <= 0) {
throw new Error('Leverage must be a positive integer');
}
if (!scenario.host.precisionOptions.includes(scenario.ui.orderbookPrecision)) {
throw new Error('Current orderbook precision must be one of the native options');
}
if (!Array.isArray(scenario.positions) || !Array.isArray(scenario.orders)) {
throw new Error('Scenario positions and orders must be arrays');
}
Expand Down
9 changes: 3 additions & 6 deletions e2e/binance-orderbook/specs/cancel-current-symbol.pw.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
readFixtureState,
} from '../helpers/userscript-page.js';
import {
assertResponsiveInteraction,
assertStableGeometry,
finishInteractionProbe,
installInteractionProbe,
Expand Down Expand Up @@ -52,9 +53,7 @@ test('no position and no orders returns immediate stable no-order feedback', asy
expect(state.orders).toEqual([]);
await expectRestoredState(page, scenario);
const probe = await finishInteractionProbe(page);
expect(probe.firstFeedbackMs).not.toBeNull();
expect(probe.firstFeedbackMs).toBeLessThanOrEqual(100);
expect(probe.longTasks).toEqual([]);
assertResponsiveInteraction(expect, probe);
assertStableGeometry(expect, probe.baseline, probe.current);
expect(errors).toEqual([]);
});
Expand Down Expand Up @@ -101,9 +100,7 @@ test('cancelling the native dialog preserves current and other orders and restor
expect(state.orders).toEqual(ORDER_SETS.both);
await expectRestoredState(page, scenario);
const probe = await finishInteractionProbe(page);
expect(probe.firstFeedbackMs).not.toBeNull();
expect(probe.firstFeedbackMs).toBeLessThanOrEqual(100);
expect(probe.longTasks).toEqual([]);
assertResponsiveInteraction(expect, probe);
assertStableGeometry(expect, probe.baseline, probe.current);
expect(errors).toEqual([]);
});
Expand Down
Loading
Loading