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 +
+
+
+
+
+
+
+
+
+
开仓
+
平仓
+
+
只做Maker
+
+
+
可用100.00 USDT
+
+
+ + +
+
+
+
+ + + +`; +} 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"