diff --git a/e2e/binance-orderbook/fixtures/binance-futures.js b/e2e/binance-orderbook/fixtures/binance-futures.js index 96f2db2..af3c5b9 100644 --- a/e2e/binance-orderbook/fixtures/binance-futures.js +++ b/e2e/binance-orderbook/fixtures/binance-futures.js @@ -161,30 +161,65 @@ export function renderBinanceFuturesFixture(scenario) { } function closeDialog(action) { + document.removeEventListener('keydown', handleDialogKeydown); document.querySelector('.bn-modal-root')?.remove(); state.dialogOpen = false; record('dialog-closed', { action }); } + function handleDialogKeydown(event) { + if (event.key === 'Escape' && state.dialogOpen) closeDialog('escape'); + } + + function attachDialogHandlers(root) { + root.addEventListener('click', (event) => { + if (event.target === root) closeDialog('backdrop'); + }); + root.querySelector('[data-dialog-action="cancel"]').addEventListener('click', () => closeDialog('cancel')); + root.querySelector('[data-dialog-action="confirm"]').addEventListener('click', () => { + record('cancel-requested', { symbol: scenario.currentSymbol }); + setTimeout(() => { + if (scenario.host.clearMode === 'currentSymbol') { + state.orders = state.orders.filter((item) => item.symbol !== scenario.currentSymbol); + } + closeDialog('confirm'); + renderAccountWidget(); + }, scenario.host.clearDelayMs); + }); + } + function openCancelDialog() { if (state.dialogOpen) throw new Error('Fixture opened a duplicate cancel dialog'); + if (scenario.host.dialogMode === 'missing') { + record('dialog-missing'); + return; + } state.dialogOpen = true; record('dialog-opened'); const root = document.createElement('div'); root.className = 'bn-modal-root'; + const primaryClass = scenario.host.dialogMode === 'missingPrimary' + ? '' + : ' class="bn-button bn-button__primary"'; + const extraAction = scenario.host.dialogMode === 'extraAction' + ? '' + : ''; root.innerHTML = '
确定取消全部订单?
' + '' + - '
'; + '' + + extraAction + ''; document.body.append(root); - root.querySelector('[data-dialog-action="cancel"]').addEventListener('click', () => closeDialog('cancel')); - root.querySelector('[data-dialog-action="confirm"]').addEventListener('click', () => { - record('cancel-requested', { symbol: scenario.currentSymbol }); + document.addEventListener('keydown', handleDialogKeydown); + attachDialogHandlers(root); + if (scenario.host.dialogReplacementDelayMs !== null) { setTimeout(() => { - state.orders = state.orders.filter((item) => item.symbol !== scenario.currentSymbol); - closeDialog('confirm'); - renderAccountWidget(); - }, scenario.host.clearDelayMs); - }); + if (!state.dialogOpen || !root.isConnected) return; + const replacement = root.cloneNode(true); + root.replaceWith(replacement); + attachDialogHandlers(replacement); + record('dialog-replaced'); + }, scenario.host.dialogReplacementDelayMs); + } } const chart = { diff --git a/e2e/binance-orderbook/helpers/userscript-page.js b/e2e/binance-orderbook/helpers/userscript-page.js index 70de455..71b5ccd 100644 --- a/e2e/binance-orderbook/helpers/userscript-page.js +++ b/e2e/binance-orderbook/helpers/userscript-page.js @@ -32,14 +32,14 @@ export async function openUserscriptScenario(page, scenario) { status: 200, contentType: 'application/json', body: JSON.stringify({ - symbols: [{ - symbol: scenario.currentSymbol, + symbols: [scenario.currentSymbol, 'BTCUSDT'].map((symbol) => ({ + symbol, 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' }, ], - }], + })), }), }); }); diff --git a/e2e/binance-orderbook/scenarios/cancel-current-symbol.js b/e2e/binance-orderbook/scenarios/cancel-current-symbol.js index a805125..3d866a1 100644 --- a/e2e/binance-orderbook/scenarios/cancel-current-symbol.js +++ b/e2e/binance-orderbook/scenarios/cancel-current-symbol.js @@ -50,6 +50,9 @@ export function createCancelScenario(overrides = {}) { host: { mutationDelayMs: 0, clearDelayMs: 0, + dialogMode: 'normal', + dialogReplacementDelayMs: null, + clearMode: 'currentSymbol', ...overrides.host, }, }; @@ -63,5 +66,18 @@ export function createCancelScenario(overrides = {}) { if (!Array.isArray(scenario.positions) || !Array.isArray(scenario.orders)) { throw new Error('Scenario positions and orders must be arrays'); } + if (!['normal', 'missing', 'extraAction', 'missingPrimary'].includes(scenario.host.dialogMode)) { + throw new Error(`Unsupported dialog mode: ${scenario.host.dialogMode}`); + } + if (!['currentSymbol', 'none'].includes(scenario.host.clearMode)) { + throw new Error(`Unsupported clear mode: ${scenario.host.clearMode}`); + } + if ( + scenario.host.dialogReplacementDelayMs !== null + && (!Number.isInteger(scenario.host.dialogReplacementDelayMs) + || scenario.host.dialogReplacementDelayMs < 0) + ) { + throw new Error('Dialog replacement delay must be a non-negative integer or null'); + } return scenario; } diff --git a/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js b/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js index 100f60b..ce79c35 100644 --- a/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js +++ b/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js @@ -1,6 +1,7 @@ import { test, expect } from '@playwright/test'; import { + OTHER_SYMBOL, ORDER_SETS, POSITION_SETS, createCancelScenario, @@ -166,3 +167,208 @@ test('the cancel workflow remains single-flight during rapid repeated clicks', a await expectRestoredState(page, scenario); expect(errors).toEqual([]); }); + +for (const closeMethod of ['Escape', 'backdrop']) { + test(`closing the native dialog with ${closeMethod} is a cancellation 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 page.getByRole('button', { name: '撤本币挂单' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + if (closeMethod === 'Escape') { + await page.keyboard.press('Escape'); + } else { + await page.locator('.bn-modal-root').click({ position: { x: 4, y: 4 } }); + } + await expect(page.getByText('HYPEUSDT 已取消撤单,已恢复页面状态')).toBeVisible(); + + const state = await readFixtureState(page); + expect(state.orders).toEqual(ORDER_SETS.both); + expect(state.events.filter((event) => event.type === 'cancel-requested')).toEqual([]); + await expectRestoredState(page, scenario); + expect(errors).toEqual([]); + }); +} + +test('a BFCache pagehide does not abort the active native dialog', async ({ page }) => { + const scenario = createCancelScenario({ + positions: POSITION_SETS.current, + orders: ORDER_SETS.current, + 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.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { + persisted: true, + }))); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.getByRole('button', { name: '取消' }).click(); + await expect(page.getByText('HYPEUSDT 已取消撤单,已恢复页面状态')).toBeVisible(); + await expectRestoredState(page, scenario); + expect(errors).toEqual([]); +}); + +test('a real pagehide aborts dialog tracking without mutating orders', async ({ page }) => { + const scenario = createCancelScenario({ + positions: POSITION_SETS.current, + orders: ORDER_SETS.current, + 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.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { + persisted: false, + }))); + await expect(page.getByText('HYPEUSDT 页面已离开,撤单确认跟踪已停止')).toBeVisible(); + + const state = await readFixtureState(page); + expect(state.orders).toEqual(ORDER_SETS.current); + expect(state.events.filter((event) => event.type === 'cancel-requested')).toEqual([]); + expect(state.showOrders).toBe(false); + expect(state.hideOtherSymbols).toBe(true); + expect(errors).toEqual([]); +}); + +test('a missing native dialog stops cleanly and restores temporary UI state', async ({ page }) => { + const scenario = createCancelScenario({ + positions: POSITION_SETS.current, + orders: ORDER_SETS.current, + ui: { hideOtherSymbols: false, accountTab: 'positions', showOrders: true }, + host: { dialogMode: 'missing' }, + }); + const { errors } = await openUserscriptScenario(page, scenario); + + await page.getByRole('button', { name: '撤本币挂单' }).click(); + await expect(page.getByText('HYPEUSDT 未识别到撤单确认弹窗,未继续撤单流程')).toBeVisible({ + timeout: 3_000, + }); + + const state = await readFixtureState(page); + expect(state.orders).toEqual(ORDER_SETS.current); + await expectRestoredState(page, scenario); + expect(errors).toEqual([]); +}); + +for (const dialogMode of ['extraAction', 'missingPrimary']) { + test(`an invalid ${dialogMode} dialog contract fails explicitly and preserves recovery state`, async ({ page }) => { + const scenario = createCancelScenario({ + positions: POSITION_SETS.current, + orders: ORDER_SETS.current, + ui: { hideOtherSymbols: false, accountTab: 'positions', showOrders: true }, + host: { dialogMode }, + }); + const { errors } = await openUserscriptScenario(page, scenario); + + await page.getByRole('button', { name: '撤本币挂单' }).click(); + await expect(page.getByText( + 'HYPEUSDT 撤单确认弹窗结构异常,图表当前委托保持隐藏', + )).toBeVisible(); + + const state = await readFixtureState(page); + expect(state.orders).toEqual(ORDER_SETS.current); + expect(state.showOrders).toBe(false); + expect(state.hideOtherSymbols).toBe(true); + expect(errors).toEqual([]); + }); +} + +test('a delayed confirmation keeps visible progress and 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 }, + host: { clearDelayMs: 250 }, + }); + const { errors } = await openUserscriptScenario(page, scenario); + + await page.getByRole('button', { name: '撤本币挂单' }).click(); + await page.getByRole('button', { name: '确认' }).click(); + await expect(page.getByText('HYPEUSDT 已确认撤单,等待当前币挂单清空')).toBeVisible(); + await expect(page.getByText('HYPEUSDT 撤单流程结束,已恢复筛选状态')).toBeVisible(); + + const state = await readFixtureState(page); + expect(state.orders).toEqual(otherSymbolOrders(scenario)); + await expectRestoredState(page, scenario); + expect(errors).toEqual([]); +}); + +test('dialog tracking survives React replacing the native dialog subtree', async ({ page }) => { + const scenario = createCancelScenario({ + positions: POSITION_SETS.current, + orders: ORDER_SETS.current, + ui: { hideOtherSymbols: false, accountTab: 'positions', showOrders: true }, + host: { dialogReplacementDelayMs: 20 }, + }); + const { errors } = await openUserscriptScenario(page, scenario); + + await page.getByRole('button', { name: '撤本币挂单' }).click(); + await expect.poll(async () => ( + await readFixtureState(page) + ).events.filter((event) => event.type === 'dialog-replaced').length).toBe(1); + await page.getByRole('button', { name: '取消' }).click(); + await expect(page.getByText('HYPEUSDT 已取消撤单,已恢复页面状态')).toBeVisible(); + + const state = await readFixtureState(page); + expect(state.orders).toEqual(ORDER_SETS.current); + await expectRestoredState(page, scenario); + expect(errors).toEqual([]); +}); + +test('a confirmed dialog that does not clear current orders reports incomplete cancellation', async ({ page }) => { + test.setTimeout(15_000); + const scenario = createCancelScenario({ + positions: POSITION_SETS.current, + orders: ORDER_SETS.current, + ui: { hideOtherSymbols: false, accountTab: 'positions', showOrders: true }, + host: { clearMode: 'none' }, + }); + const { errors } = await openUserscriptScenario(page, scenario); + + await page.getByRole('button', { name: '撤本币挂单' }).click(); + await page.getByRole('button', { name: '确认' }).click(); + await expect(page.getByText('HYPEUSDT 当前币挂单仍存在,撤单流程未完成')).toBeVisible({ + timeout: 10_000, + }); + + const state = await readFixtureState(page); + expect(state.orders).toEqual(ORDER_SETS.current); + await expectRestoredState(page, scenario); + expect(errors).toEqual([]); +}); + +test('a symbol change before the dialog decision stops the captured-symbol workflow', 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.evaluate((symbol) => { + history.pushState({}, '', `/zh-CN/futures/${symbol}`); + }, OTHER_SYMBOL); + await expect.poll(() => page.evaluate(() => location.pathname)).toContain(OTHER_SYMBOL); + await page.waitForTimeout(600); + await page.getByRole('button', { name: '取消' }).click(); + await expect(page.getByText('确认撤单前交易对已变化')).toBeVisible(); + + const state = await readFixtureState(page); + expect(state.orders).toEqual(ORDER_SETS.both); + expect(state.events.filter((event) => event.type === 'cancel-requested')).toEqual([]); + expect(errors).toEqual([]); +});