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
53 changes: 44 additions & 9 deletions e2e/binance-orderbook/fixtures/binance-futures.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
? '<button type="button" data-dialog-action="extra">稍后</button>'
: '';
root.innerHTML = '<div role="dialog"><div class="bn-modal-title">确定取消全部订单?</div>' +
'<button type="button" data-dialog-action="cancel">取消</button>' +
'<button type="button" class="bn-button bn-button__primary" data-dialog-action="confirm"><span>确认</span></button></div>';
'<button type="button"' + primaryClass + ' data-dialog-action="confirm"><span>确认</span></button>' +
extraAction + '</div>';
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 = {
Expand Down
6 changes: 3 additions & 3 deletions e2e/binance-orderbook/helpers/userscript-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
}],
})),
}),
});
});
Expand Down
16 changes: 16 additions & 0 deletions e2e/binance-orderbook/scenarios/cancel-current-symbol.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export function createCancelScenario(overrides = {}) {
host: {
mutationDelayMs: 0,
clearDelayMs: 0,
dialogMode: 'normal',
dialogReplacementDelayMs: null,
clearMode: 'currentSymbol',
...overrides.host,
},
};
Expand All @@ -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;
}
206 changes: 206 additions & 0 deletions e2e/binance-orderbook/specs/cancel-current-symbol.pw.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { test, expect } from '@playwright/test';

import {
OTHER_SYMBOL,
ORDER_SETS,
POSITION_SETS,
createCancelScenario,
Expand Down Expand Up @@ -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([]);
});
Loading