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([]);
+});