From 9ac1149d50886a622186569b439c5106d133993a Mon Sep 17 00:00:00 2001
From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com>
Date: Wed, 26 Aug 2026 12:54:00 +0800
Subject: [PATCH 1/2] test: add Binance browser automation foundation
---
.github/workflows/binance-orderbook-ui.yml | 51 ++++
.gitignore | 2 +
docs/binance-orderbook-trade-ui-automation.md | 146 ++++++++++++
.../fixtures/binance-futures.js | 220 ++++++++++++++++++
.../helpers/interaction-probe.js | 81 +++++++
.../helpers/userscript-page.js | 58 +++++
.../scenarios/cancel-current-symbol.js | 67 ++++++
.../specs/cancel-current-symbol.pw.js | 168 +++++++++++++
package-lock.json | 64 +++++
package.json | 6 +-
playwright.config.js | 26 +++
11 files changed, 888 insertions(+), 1 deletion(-)
create mode 100644 .github/workflows/binance-orderbook-ui.yml
create mode 100644 docs/binance-orderbook-trade-ui-automation.md
create mode 100644 e2e/binance-orderbook/fixtures/binance-futures.js
create mode 100644 e2e/binance-orderbook/helpers/interaction-probe.js
create mode 100644 e2e/binance-orderbook/helpers/userscript-page.js
create mode 100644 e2e/binance-orderbook/scenarios/cancel-current-symbol.js
create mode 100644 e2e/binance-orderbook/specs/cancel-current-symbol.pw.js
create mode 100644 playwright.config.js
diff --git a/.github/workflows/binance-orderbook-ui.yml b/.github/workflows/binance-orderbook-ui.yml
new file mode 100644
index 0000000..1b7618b
--- /dev/null
+++ b/.github/workflows/binance-orderbook-ui.yml
@@ -0,0 +1,51 @@
+name: Binance Orderbook UI
+
+on:
+ pull_request:
+ paths:
+ - ".github/workflows/binance-orderbook-ui.yml"
+ - "package.json"
+ - "package-lock.json"
+ - "playwright.config.js"
+ - "src/binance-orderbook-trade/**"
+ - "scripts/binance-orderbook-trade.user.js"
+ - "e2e/binance-orderbook/**"
+ push:
+ branches:
+ - main
+ paths:
+ - ".github/workflows/binance-orderbook-ui.yml"
+ - "package.json"
+ - "package-lock.json"
+ - "playwright.config.js"
+ - "src/binance-orderbook-trade/**"
+ - "scripts/binance-orderbook-trade.user.js"
+ - "e2e/binance-orderbook/**"
+
+permissions:
+ contents: read
+
+jobs:
+ ui:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ - run: npm ci
+ - run: npx playwright install --with-deps chromium
+ - run: npm test
+ - run: npm run build:binance-orderbook-trade
+ - run: git diff --exit-code -- scripts/binance-orderbook-trade.user.js
+ - run: npm run test:ui
+ - name: Upload Playwright report
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-report
+ path: playwright-report/
+ if-no-files-found: ignore
+ retention-days: 7
diff --git a/.gitignore b/.gitignore
index f5d04ce..1b2e7d4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,5 @@ __pycache__/
*.pyo
.DS_Store
node_modules/
+playwright-report/
+test-results/
diff --git a/docs/binance-orderbook-trade-ui-automation.md b/docs/binance-orderbook-trade-ui-automation.md
new file mode 100644
index 0000000..1dbd9a6
--- /dev/null
+++ b/docs/binance-orderbook-trade-ui-automation.md
@@ -0,0 +1,146 @@
+# Binance Orderbook Userscript UI Automation
+
+## Goal
+
+Build a repeatable UI verification system for `binance-orderbook-trade` that covers
+business correctness, host-page integration, user feedback, layout stability, and
+interaction performance without making every regression test depend on a live
+Binance account.
+
+The generated userscript remains the system under test. Browser tests must inject
+`scripts/binance-orderbook-trade.user.js`; they must not reimplement the production
+workflow in a test-only controller.
+
+## Test Layers
+
+| Layer | Runtime | Contract |
+| --- | --- | --- |
+| L0 | Node.js | Pure calculations and state machines |
+| L1 | jsdom | Binance DOM contracts, semantic lookup, and mutation waits |
+| L2 | Playwright with a deterministic Binance fixture | Real browser clicks, rendering, React-style replacement, dialog lifecycle, layout, and performance |
+| L3 | Chrome with Tampermonkey | Metadata, injection timing, installed-source version, and extension runtime |
+| L4 | Logged-in Binance Chrome page with raw CDP | Current production DOM, real host performance, and minimal live behavior paths |
+
+L0 and L1 remain fast PR gates. L2 owns the full scenario matrix. L3 and L4 are
+smaller integration and release gates because extension state, network timing, and
+market state are not deterministic enough for the full matrix.
+
+## Scenario Model
+
+Scenarios are data, not copied test procedures. Each scenario declares these axes:
+
+- positions: none, current symbol, other symbol, or both;
+- orders: none, current symbol, other symbol, or both;
+- order scale: 1, 20, 40, 120, and a separately verified live boundary;
+- initial account tab and open-order sub-tab;
+- initial `Hide Other Symbols` state;
+- initial TradingView `showOrders` state;
+- trade mode, chart mode, locale, and panel expansion;
+- dialog outcome: confirm, cancel, Escape, backdrop, page lifecycle abort, or no decision;
+- host behavior: synchronous commit, delayed commit, React root replacement, or symbol change;
+- upstream outcome: success, delayed success, rejection, or incomplete clearing.
+
+Do not generate the Cartesian product. Maintain a mandatory risk-core matrix for
+business invariants, then generate pairwise or three-way covering combinations for
+the remaining axes. Production regressions become permanent named scenarios.
+
+## Required Invariants
+
+Every cancel-current-symbol scenario must assert all applicable invariants:
+
+1. Other-symbol orders are never cancelled.
+2. Cancelling the native dialog does not mutate orders.
+3. Confirming clears only the captured current symbol.
+4. The original account tab, sub-tab, symbol filter, and TradingView order-line
+ visibility are restored exactly unless page navigation invalidates the workflow.
+5. Every state-changing host click is followed by semantic DOM reacquisition.
+6. The workflow is single-flight; repeated clicks do not open multiple dialogs.
+7. Unrelated ladder controls do not flash, move, or change enabled state.
+8. The action provides prompt feedback and finishes in a stable final state.
+9. No uncaught userscript error, leaked observer, or residual test-owned order remains.
+10. The loaded userscript version and source hash match the generated artifact.
+
+## 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;
+- no unexpected movement of unrelated action controls;
+- no repeated DOM writes after the scenario reaches its final state.
+
+L4 records wall-clock segments instead of using a single brittle total timeout:
+
+1. click to first feedback;
+2. preflight and current-symbol filtering;
+3. TradingView order-line hide;
+4. native dialog discovery and user decision;
+5. current-symbol clearing observation;
+6. chart, tab, and filter restoration;
+7. final button readiness.
+
+Live results are compared with checked-in median and p95 baselines. Network-dependent
+wall-clock duration is not a deterministic PR assertion. Raw CDP tracing is required
+when a regression must be attributed to Binance, TradingView, rendering, or the
+userscript.
+
+## Artifacts
+
+Failed L2 tests retain:
+
+- Playwright trace with DOM snapshots, network, console, and screenshots;
+- scenario input and random seed;
+- userscript version and source hash;
+- interaction timing and long-task JSON;
+- before/after control geometry;
+- final fixture state and event ledger.
+
+Live tests additionally retain a test-owned order ledger keyed by symbol, side,
+price, quantity, and creation time. Concurrent user orders are not inferred to be
+test orders from count changes alone.
+
+## Delivery Stages
+
+### Stage 1: Deterministic Browser Foundation
+
+- Add Playwright Test and repository scripts.
+- Inject the generated userscript during head parsing, before the fixture body is
+ available. Stage 3 separately verifies Tampermonkey's exact `document-start`
+ lifecycle.
+- Add a deterministic Binance Futures fixture with React-style subtree replacement.
+- Add scenario data, interaction probes, and the core cancel-current-symbol matrix.
+- Run L2 in the PR gate.
+
+### Stage 2: Expanded Host and Fault Matrix
+
+- Cover backdrop, Escape, navigation, symbol change, delayed DOM commits, malformed
+ contracts, and error outcomes.
+- Add open/close switching, orderbook precision, ladder start/stop, and leverage flows.
+- Add pairwise/three-way scenario generation and stable visual snapshots.
+
+### Stage 3: Tampermonkey Integration
+
+- Sync the generated artifact through the existing Tampermonkey MCP path.
+- Read back installed source and version.
+- Verify injection timing and the minimum critical paths in an isolated Chrome profile.
+
+### Stage 4: Live Binance Acceptance and Baselines
+
+- Hard reload the logged-in page and verify the loaded source through raw CDP.
+- Run no-order, dialog-cancel, and explicitly authorized dialog-confirm paths.
+- Run 1/20/40/120 order-scale performance samples without exceeding the current live
+ exchange limit minus one.
+- Verify no fills, no residual test-owned orders, and restored page state after each run.
+
+## Change Workflow
+
+1. Convert a defect into a failing scenario or invariant assertion.
+2. Fix the source and rebuild the generated userscript.
+3. Run L0, L1, and the affected L2 matrix.
+4. Review retained trace and performance artifacts for failures.
+5. Merge through a PR.
+6. Complete L3 and the affected L4 release path.
+7. Store any production regression as a permanent named scenario.
+
+Retries must not turn a failing scenario green. A retry may diagnose flakiness, but a
+flaky result remains a release finding until its source is understood.
diff --git a/e2e/binance-orderbook/fixtures/binance-futures.js b/e2e/binance-orderbook/fixtures/binance-futures.js
new file mode 100644
index 0000000..96f2db2
--- /dev/null
+++ b/e2e/binance-orderbook/fixtures/binance-futures.js
@@ -0,0 +1,220 @@
+function escapeJsonForScript(value) {
+ return JSON.stringify(value).replace(/
+
+
+
+ Binance Futures UI Fixture
+
+
+
+
+
+
+
+ 0.1
+ 81.2
+ 81.1
+ 81.0
+ 80.9
+
+
+ 81.0081.01
+ 81.0281.03
+ 81.0481.05
+
+
+
+
+
+
+
+
+
+`;
+}
diff --git a/e2e/binance-orderbook/helpers/interaction-probe.js b/e2e/binance-orderbook/helpers/interaction-probe.js
new file mode 100644
index 0000000..fdf5325
--- /dev/null
+++ b/e2e/binance-orderbook/helpers/interaction-probe.js
@@ -0,0 +1,81 @@
+export async function installInteractionProbe(page, targetSelector) {
+ await page.evaluate((selector) => {
+ const panel = document.querySelector('#jh-binance-close-qty-multiplier-panel');
+ const target = document.querySelector(selector);
+ if (!panel || !target) throw new Error(`Interaction probe target is unavailable: ${selector}`);
+
+ const state = {
+ startedAt: null,
+ firstFeedbackAt: null,
+ longTasks: [],
+ baseline: {},
+ };
+ for (const button of panel.querySelectorAll('[data-ladder-action], [data-ladder-stop]')) {
+ const key = button.getAttribute('data-ladder-action') || 'stop';
+ const rect = button.getBoundingClientRect();
+ state.baseline[key] = { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
+ }
+
+ target.addEventListener('click', () => {
+ state.startedAt = performance.now();
+ }, { capture: true, once: true });
+
+ const mutationObserver = new MutationObserver(() => {
+ if (state.startedAt !== null && state.firstFeedbackAt === null) {
+ state.firstFeedbackAt = performance.now();
+ }
+ });
+ mutationObserver.observe(panel, {
+ subtree: true,
+ childList: true,
+ characterData: true,
+ attributes: true,
+ attributeFilter: ['disabled', 'aria-disabled', 'style'],
+ });
+
+ let longTaskObserver = null;
+ if (PerformanceObserver.supportedEntryTypes?.includes('longtask')) {
+ longTaskObserver = new PerformanceObserver((list) => {
+ state.longTasks.push(...list.getEntries().map((entry) => ({
+ name: entry.name,
+ startTime: entry.startTime,
+ duration: entry.duration,
+ })));
+ });
+ longTaskObserver.observe({ type: 'longtask', buffered: true });
+ }
+
+ window.__UI_INTERACTION_PROBE__ = {
+ finish() {
+ mutationObserver.disconnect();
+ longTaskObserver?.disconnect();
+ const current = {};
+ for (const button of panel.querySelectorAll('[data-ladder-action], [data-ladder-stop]')) {
+ const key = button.getAttribute('data-ladder-action') || 'stop';
+ const rect = button.getBoundingClientRect();
+ current[key] = { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
+ }
+ return {
+ firstFeedbackMs: state.startedAt !== null && state.firstFeedbackAt !== null
+ ? state.firstFeedbackAt - state.startedAt
+ : null,
+ longTasks: state.longTasks.filter((entry) => (
+ state.startedAt !== null && entry.startTime >= state.startedAt
+ )),
+ baseline: state.baseline,
+ current,
+ };
+ },
+ };
+ }, targetSelector);
+}
+
+export async function finishInteractionProbe(page) {
+ return page.evaluate(() => window.__UI_INTERACTION_PROBE__.finish());
+}
+
+export function assertStableGeometry(expect, baseline, current) {
+ for (const [key, before] of Object.entries(baseline)) {
+ expect(current[key], `missing control geometry for ${key}`).toEqual(before);
+ }
+}
diff --git a/e2e/binance-orderbook/helpers/userscript-page.js b/e2e/binance-orderbook/helpers/userscript-page.js
new file mode 100644
index 0000000..70de455
--- /dev/null
+++ b/e2e/binance-orderbook/helpers/userscript-page.js
@@ -0,0 +1,58 @@
+import { fileURLToPath } from 'node:url';
+import { readFile } from 'node:fs/promises';
+
+import { renderBinanceFuturesFixture } from '../fixtures/binance-futures.js';
+
+const USERSCRIPT_PATH = fileURLToPath(
+ new URL('../../../scripts/binance-orderbook-trade.user.js', import.meta.url),
+);
+
+export async function openUserscriptScenario(page, scenario) {
+ const errors = [];
+ page.on('pageerror', (error) => errors.push(String(error?.stack || error)));
+ const userscriptSource = await readFile(USERSCRIPT_PATH, 'utf8');
+ await page.route('https://www.binance.com/**', async (route) => {
+ const url = new URL(route.request().url());
+ if (url.pathname === '/__binance_orderbook_userscript__.js') {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/javascript',
+ body: userscriptSource,
+ });
+ return;
+ }
+ await route.fulfill({
+ status: 200,
+ contentType: 'text/html',
+ body: renderBinanceFuturesFixture(scenario),
+ });
+ });
+ await page.route('https://fapi.binance.com/fapi/v1/exchangeInfo**', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ symbols: [{
+ symbol: scenario.currentSymbol,
+ filters: [
+ { filterType: 'LOT_SIZE', minQty: '0.01', stepSize: '0.01' },
+ { filterType: 'MARKET_LOT_SIZE', minQty: '0.01', stepSize: '0.01' },
+ { filterType: 'MIN_NOTIONAL', notional: '5' },
+ ],
+ }],
+ }),
+ });
+ });
+
+ await page.goto(`https://www.binance.com/zh-CN/futures/${scenario.currentSymbol}`);
+ await page.locator('#jh-binance-close-qty-multiplier-panel').waitFor({ state: 'visible' });
+ await page.getByRole('button', { name: '撤本币挂单' }).waitFor({ state: 'visible' });
+ await page.evaluate(() => new Promise((resolve) => {
+ requestAnimationFrame(() => requestAnimationFrame(resolve));
+ }));
+ return { errors };
+}
+
+export async function readFixtureState(page) {
+ return page.evaluate(() => window.__BINANCE_FIXTURE__.snapshot());
+}
diff --git a/e2e/binance-orderbook/scenarios/cancel-current-symbol.js b/e2e/binance-orderbook/scenarios/cancel-current-symbol.js
new file mode 100644
index 0000000..a805125
--- /dev/null
+++ b/e2e/binance-orderbook/scenarios/cancel-current-symbol.js
@@ -0,0 +1,67 @@
+export const CURRENT_SYMBOL = 'HYPEUSDT';
+export const OTHER_SYMBOL = 'BTCUSDT';
+
+function position(symbol, side, quantity) {
+ return { symbol, side, quantity: String(quantity) };
+}
+
+function order(id, symbol, side = 'SELL') {
+ return {
+ id,
+ symbol,
+ side,
+ price: symbol === CURRENT_SYMBOL ? '90.0' : '120000.0',
+ quantity: '0.01',
+ };
+}
+
+export const POSITION_SETS = Object.freeze({
+ none: [],
+ current: [position(CURRENT_SYMBOL, 'LONG', '0.1')],
+ other: [position(OTHER_SYMBOL, 'LONG', '0.001')],
+ both: [
+ position(CURRENT_SYMBOL, 'LONG', '0.1'),
+ position(OTHER_SYMBOL, 'SHORT', '0.001'),
+ ],
+});
+
+export const ORDER_SETS = Object.freeze({
+ none: [],
+ current: [order('current-1', CURRENT_SYMBOL)],
+ other: [order('other-1', OTHER_SYMBOL)],
+ both: [order('current-1', CURRENT_SYMBOL), order('other-1', OTHER_SYMBOL, 'BUY')],
+});
+
+export function createCancelScenario(overrides = {}) {
+ const scenario = {
+ name: 'cancel-current-symbol',
+ currentSymbol: CURRENT_SYMBOL,
+ positions: POSITION_SETS.none,
+ orders: ORDER_SETS.none,
+ ...overrides,
+ ui: {
+ accountTab: 'positions',
+ openOrdersSubTab: 'basic',
+ hideOtherSymbols: false,
+ showOrders: true,
+ ladderExpanded: true,
+ ...overrides.ui,
+ },
+ host: {
+ mutationDelayMs: 0,
+ clearDelayMs: 0,
+ ...overrides.host,
+ },
+ };
+
+ if (!['positions', 'openOrders', 'history'].includes(scenario.ui.accountTab)) {
+ throw new Error(`Unsupported account tab: ${scenario.ui.accountTab}`);
+ }
+ if (!['basic', 'conditional'].includes(scenario.ui.openOrdersSubTab)) {
+ throw new Error(`Unsupported open-orders sub-tab: ${scenario.ui.openOrdersSubTab}`);
+ }
+ if (!Array.isArray(scenario.positions) || !Array.isArray(scenario.orders)) {
+ throw new Error('Scenario positions and orders must be arrays');
+ }
+ return scenario;
+}
diff --git a/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js b/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js
new file mode 100644
index 0000000..100f60b
--- /dev/null
+++ b/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js
@@ -0,0 +1,168 @@
+import { test, expect } from '@playwright/test';
+
+import {
+ ORDER_SETS,
+ POSITION_SETS,
+ createCancelScenario,
+} from '../scenarios/cancel-current-symbol.js';
+import {
+ openUserscriptScenario,
+ readFixtureState,
+} from '../helpers/userscript-page.js';
+import {
+ assertStableGeometry,
+ finishInteractionProbe,
+ installInteractionProbe,
+} from '../helpers/interaction-probe.js';
+
+const CANCEL_BUTTON_SELECTOR = '[data-ladder-cancel-symbol="true"]';
+
+function otherSymbolOrders(scenario) {
+ return scenario.orders.filter((order) => order.symbol !== scenario.currentSymbol);
+}
+
+async function expectRestoredState(page, scenario) {
+ await expect.poll(async () => {
+ const state = await readFixtureState(page);
+ return {
+ accountTab: state.accountTab,
+ openOrdersSubTab: state.openOrdersSubTab,
+ hideOtherSymbols: state.hideOtherSymbols,
+ showOrders: state.showOrders,
+ };
+ }).toEqual({
+ accountTab: scenario.ui.accountTab,
+ openOrdersSubTab: scenario.ui.openOrdersSubTab,
+ hideOtherSymbols: scenario.ui.hideOtherSymbols,
+ showOrders: scenario.ui.showOrders,
+ });
+}
+
+test('no position and no orders returns immediate stable no-order feedback', async ({ page }) => {
+ const scenario = createCancelScenario();
+ const { errors } = await openUserscriptScenario(page, scenario);
+ await installInteractionProbe(page, CANCEL_BUTTON_SELECTOR);
+
+ await page.getByRole('button', { name: '撤本币挂单' }).click();
+ await expect(page.getByRole('button', { name: '无挂单' })).toBeVisible();
+
+ const state = await readFixtureState(page);
+ expect(state.dialogOpen).toBe(false);
+ 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([]);
+ assertStableGeometry(expect, probe.baseline, probe.current);
+ expect(errors).toEqual([]);
+});
+
+test('other-symbol position and orders never open a current-symbol cancel dialog', async ({ page }) => {
+ const scenario = createCancelScenario({
+ positions: POSITION_SETS.other,
+ orders: ORDER_SETS.other,
+ ui: { hideOtherSymbols: false, accountTab: 'positions' },
+ });
+ const { errors } = await openUserscriptScenario(page, scenario);
+
+ await page.getByRole('button', { name: '撤本币挂单' }).click();
+ await expect(page.getByRole('button', { name: '无挂单' })).toBeVisible();
+
+ const state = await readFixtureState(page);
+ expect(state.dialogOpen).toBe(false);
+ expect(state.orders).toEqual(otherSymbolOrders(scenario));
+ await expectRestoredState(page, scenario);
+ expect(errors).toEqual([]);
+});
+
+test('cancelling the native dialog preserves current and other orders and restores UI', async ({ page }) => {
+ const scenario = createCancelScenario({
+ positions: POSITION_SETS.both,
+ orders: ORDER_SETS.both,
+ ui: {
+ hideOtherSymbols: false,
+ accountTab: 'positions',
+ openOrdersSubTab: 'conditional',
+ showOrders: true,
+ },
+ });
+ const { errors } = await openUserscriptScenario(page, scenario);
+ await installInteractionProbe(page, CANCEL_BUTTON_SELECTOR);
+
+ await page.getByRole('button', { name: '撤本币挂单' }).click();
+ await expect(page.getByRole('dialog')).toBeVisible();
+ await expect.poll(async () => (await readFixtureState(page)).showOrders).toBe(false);
+ await page.getByRole('button', { name: '取消' }).click();
+ await expect(page.getByText('HYPEUSDT 已取消撤单,已恢复页面状态')).toBeVisible();
+
+ const state = await readFixtureState(page);
+ 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([]);
+ assertStableGeometry(expect, probe.baseline, probe.current);
+ expect(errors).toEqual([]);
+});
+
+test('confirming with mixed-symbol orders clears only the current symbol', async ({ page }) => {
+ const scenario = createCancelScenario({
+ positions: POSITION_SETS.both,
+ orders: ORDER_SETS.both,
+ ui: { hideOtherSymbols: false, accountTab: 'positions', showOrders: true },
+ });
+ const { errors } = await openUserscriptScenario(page, scenario);
+
+ await page.getByRole('button', { name: '撤本币挂单' }).click();
+ await expect(page.getByRole('dialog')).toBeVisible();
+ await page.getByRole('button', { name: '确认' }).click();
+ await expect(page.getByText('HYPEUSDT 撤单流程结束,已恢复筛选状态')).toBeVisible();
+
+ const state = await readFixtureState(page);
+ expect(state.orders).toEqual(otherSymbolOrders(scenario));
+ expect(state.events.filter((event) => event.type === 'cancel-requested')).toHaveLength(1);
+ await expectRestoredState(page, scenario);
+ expect(errors).toEqual([]);
+});
+
+test('an originally enabled symbol filter remains enabled after confirmation', async ({ page }) => {
+ const scenario = createCancelScenario({
+ positions: POSITION_SETS.current,
+ orders: ORDER_SETS.both,
+ ui: { hideOtherSymbols: true, accountTab: 'openOrders', showOrders: false },
+ });
+ const { errors } = await openUserscriptScenario(page, scenario);
+
+ await page.getByRole('button', { name: '撤本币挂单' }).click();
+ await expect(page.getByRole('dialog')).toBeVisible();
+ await page.getByRole('button', { name: '确认' }).click();
+ await expect(page.getByText('HYPEUSDT 撤单流程结束,已恢复筛选状态')).toBeVisible();
+
+ const state = await readFixtureState(page);
+ expect(state.orders).toEqual(otherSymbolOrders(scenario));
+ await expectRestoredState(page, scenario);
+ expect(state.events.filter((event) => event.type === 'show-orders')).toEqual([]);
+ expect(errors).toEqual([]);
+});
+
+test('the cancel workflow remains single-flight during rapid repeated clicks', async ({ page }) => {
+ const scenario = createCancelScenario({
+ positions: POSITION_SETS.current,
+ orders: ORDER_SETS.current,
+ host: { mutationDelayMs: 20 },
+ });
+ const { errors } = await openUserscriptScenario(page, scenario);
+
+ await page.locator(CANCEL_BUTTON_SELECTOR).evaluate((button) => {
+ button.click();
+ button.click();
+ });
+ await expect(page.getByRole('dialog')).toBeVisible();
+ const state = await readFixtureState(page);
+ expect(state.events.filter((event) => event.type === 'dialog-opened')).toHaveLength(1);
+ await page.getByRole('button', { name: '取消' }).click();
+ await expectRestoredState(page, scenario);
+ expect(errors).toEqual([]);
+});
diff --git a/package-lock.json b/package-lock.json
index 4492c00..dc29107 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5,6 +5,7 @@
"packages": {
"": {
"devDependencies": {
+ "@playwright/test": "^1.62.1",
"esbuild": "^0.25.0",
"jsdom": "^29.1.1"
}
@@ -656,6 +657,22 @@
}
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
+ "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@@ -771,6 +788,21 @@
"node": ">=18"
}
},
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
@@ -862,6 +894,38 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
+ "node_modules/playwright": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
diff --git a/package.json b/package.json
index 5ea0cec..08e68a9 100644
--- a/package.json
+++ b/package.json
@@ -14,9 +14,13 @@
"check:binance-userscripts": "node --check scripts/binance-orderbook-trade.user.js && node --check scripts/binance-trading-data.user.js && node --check scripts/binance-coinmarketcap-data.user.js",
"check:binance-orderbook-trade": "node --check scripts/binance-orderbook-trade.user.js",
"check:m3u8-downloader": "node --check scripts/m3u8-downloader.user.js",
- "test": "node --test"
+ "test": "node --test",
+ "test:ui": "playwright test",
+ "test:ui:headed": "playwright test --headed",
+ "test:ui:debug": "playwright test --debug"
},
"devDependencies": {
+ "@playwright/test": "^1.62.1",
"esbuild": "^0.25.0",
"jsdom": "^29.1.1"
}
diff --git a/playwright.config.js b/playwright.config.js
new file mode 100644
index 0000000..8a68273
--- /dev/null
+++ b/playwright.config.js
@@ -0,0 +1,26 @@
+import { defineConfig } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './e2e/binance-orderbook/specs',
+ testMatch: '**/*.pw.js',
+ fullyParallel: false,
+ forbidOnly: Boolean(process.env.CI),
+ retries: 0,
+ workers: 1,
+ timeout: 15_000,
+ expect: {
+ timeout: 3_000,
+ },
+ outputDir: 'test-results/ui',
+ reporter: process.env.CI
+ ? [['line'], ['html', { outputFolder: 'playwright-report', open: 'never' }]]
+ : [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
+ use: {
+ browserName: 'chromium',
+ viewport: { width: 1440, height: 1000 },
+ locale: 'zh-CN',
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ video: 'off',
+ },
+});
From f143d6f2315b27170220e161e9406b931969df21 Mon Sep 17 00:00:00 2001
From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com>
Date: Wed, 26 Aug 2026 12:57:14 +0800
Subject: [PATCH 2/2] ci: scope browser gate to orderbook tests
---
.github/workflows/binance-orderbook-ui.yml | 2 +-
docs/binance-orderbook-trade-ui-automation.md | 2 +-
package.json | 1 +
3 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/binance-orderbook-ui.yml b/.github/workflows/binance-orderbook-ui.yml
index 1b7618b..e798eac 100644
--- a/.github/workflows/binance-orderbook-ui.yml
+++ b/.github/workflows/binance-orderbook-ui.yml
@@ -37,7 +37,7 @@ jobs:
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- - run: npm test
+ - run: npm run test:binance-orderbook-trade
- run: npm run build:binance-orderbook-trade
- run: git diff --exit-code -- scripts/binance-orderbook-trade.user.js
- run: npm run test:ui
diff --git a/docs/binance-orderbook-trade-ui-automation.md b/docs/binance-orderbook-trade-ui-automation.md
index 1dbd9a6..3c58cb1 100644
--- a/docs/binance-orderbook-trade-ui-automation.md
+++ b/docs/binance-orderbook-trade-ui-automation.md
@@ -136,7 +136,7 @@ test orders from count changes alone.
1. Convert a defect into a failing scenario or invariant assertion.
2. Fix the source and rebuild the generated userscript.
-3. Run L0, L1, and the affected L2 matrix.
+3. Run `npm run test:binance-orderbook-trade` and the affected L2 matrix.
4. Review retained trace and performance artifacts for failures.
5. Merge through a PR.
6. Complete L3 and the affected L4 release path.
diff --git a/package.json b/package.json
index 08e68a9..54d20e8 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"check:binance-orderbook-trade": "node --check scripts/binance-orderbook-trade.user.js",
"check:m3u8-downloader": "node --check scripts/m3u8-downloader.user.js",
"test": "node --test",
+ "test:binance-orderbook-trade": "node --test test/unit/binance-orderbook-trade/*.test.js test/dom/binance-orderbook-trade/*.test.js",
"test:ui": "playwright test",
"test:ui:headed": "playwright test --headed",
"test:ui:debug": "playwright test --debug"