From 15b7c18ad7c797aed30a0035bb24bbd65739f1c2 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:38:57 +0800 Subject: [PATCH] fix: continue Binance tasks in hidden tabs --- docs/binance-orderbook-trade-development.md | 10 + docs/binance-orderbook-trade-ui-automation.md | 10 + .../helpers/simulated-visibility.js | 49 +++++ .../specs/account-rebalance-behavior.pw.js | 28 +++ .../specs/cancel-current-symbol.pw.js | 30 +++ .../specs/continuous-readiness-behavior.pw.js | 26 +++ .../specs/order-submit-behavior.pw.js | 117 +++++++++++ scripts/binance-orderbook-trade.user.js | 188 +++++++++++------- src/binance-orderbook-trade/dom/trade-form.js | 171 +++++++++------- src/binance-orderbook-trade/index.user.js | 43 ++-- test/helpers/dom.js | 1 + .../trade-form.test.js | 105 ++++++++++ 12 files changed, 616 insertions(+), 162 deletions(-) create mode 100644 e2e/binance-orderbook/helpers/simulated-visibility.js diff --git a/docs/binance-orderbook-trade-development.md b/docs/binance-orderbook-trade-development.md index 8ce2e7b..8ab415e 100644 --- a/docs/binance-orderbook-trade-development.md +++ b/docs/binance-orderbook-trade-development.md @@ -276,6 +276,8 @@ The rebalance path is pinned to the current Binance page bundle contract instead The user must approve one native confirmation that shows the current balances, 5:4:1 targets, and exact transfer list. After confirmation, the script rechecks all positions, all open orders, and the exact three-account balance snapshot before every transfer. Each successful response must then be reflected by a fresh balance read before the next transfer starts. An intervening position, order, or balance change stops the task; a partial completion is reported explicitly and is never retried or rolled back automatically. +Hiding the tab after the user confirms a rebalance does not cancel its remaining transfers. The initial eligibility and preview still require a visible trading page; each confirmed transfer continues to require the same fresh account checks, matching balances, and current trading route while hidden. + Ladder replacement must stay scoped and direction-aware. Automatic replacement may cancel only visible basic open-order rows for the current symbol and the same plan direction (`开多`, `开空`, `平多`, or `平空`). It must not use current-symbol cancel-all for ladder replacement, must not touch conditional/protection orders, and must retry the ladder plan only after the replacement path is validated by current DOM rows. User-facing failures must preserve the observed reason instead of collapsing multiple states into one generic message. In particular, an unread openable quantity, a confirmed zero available balance, and a Binance-calculated openable quantity of zero are separate outcomes. Use the panel term `价格精度` consistently instead of exposing the internal `缩放值` name. Cancellation feedback should describe the user-visible action and result, not implementation details such as row-by-row processing, observer roots, context loss, or temporary chart-state manipulation. Combined `A or B` failures are allowed only when the code cannot distinguish the causes; when each branch is already known, report that branch directly. @@ -286,6 +288,8 @@ Every observed place-order response keeps a sanitized diagnostic contract: HTTP Continuous ladder trading is available only for close actions through `Option/Alt + click`; an ordinary click remains one round. A round is the complete existing ladder-close workflow, including any scoped same-direction replacement and cleanup. After a completed round, the runner must observe the same symbol, close mode, current precision, and native close button as ready before starting a full one-second cooldown. It must validate readiness again after the cooldown; losing readiness restarts the wait and a new full cooldown. Every new round must rebuild its plan from the latest panel profile and live trading context. A failed, stopped, or interrupted round ends the continuous session. +An already clicked ladder or single-order task may continue when Chrome hides the page. Input and action-button stability checks use separate timer tasks while hidden because paint frames can pause; they still re-read live controls, current symbol, mode, precision, price, quantity, and the exact native response before moving to the next order. A single-order draft expires 15 seconds after its trusted price click, so a long background stall cannot later submit its old clicked price and quantity. Continuous close readiness and reduce-only recovery use those same live conditions without treating `document.hidden` as a refusal. Stop aborts an active ladder wait before any later submit. Background timers can be throttled, so neither order timing nor the one-second continuous cooldown has a strict wall-clock guarantee while hidden. A frozen or discarded page cannot run page JavaScript until Chrome resumes or reloads it; this userscript does not resume an unconfirmed order automatically after reload. + Continuous-session feedback stays in the shared ladder status row and uses `连续阶梯平多` / `连续阶梯平空` as the stable action name. The action, phase, and counters are separated with ` · ` instead of concatenating `连续` after the ordinary ladder label. `2/3 轮` means two rounds completed out of three started, `本轮 1/3 笔` reports the active or latest partial plan, and `累计 7 笔` reports all confirmed submissions across the session. Confirmed cancellations are appended only when greater than zero. The active round must combine its live progress with the completed-round aggregate; ordinary single-round status text must never overwrite the continuous-session identity. Round outcomes must expose a detached progress snapshot so a terminal continuous summary cannot be overwritten by the latest single-round message. A session waiting for readiness, stopping, or failing before its first recorded @@ -334,6 +338,12 @@ Run manual checks when behavior touches trading flow, DOM selectors, account ord - verify the precision apply button changes Binance orderbook precision only after an explicit user click - verify precision decrease/increase selects the exact native divide-by-10/multiply-by-10 option, restores the corresponding symbol-mode-precision panel profile, and stops at a missing native decade option - start ladder order, confirm start buttons are disabled while running +- after clicking ordinary open and close ladders, hide or minimize Chrome while the first order is pending; verify the task completes the remaining orders with the captured direction, and that Stop prevents a pending hidden control check from submitting +- hide the page immediately after a trusted single-order price click; verify exactly one current-symbol order is submitted after the input settles +- hold a hidden single-order draft for longer than 15 seconds, then resume the page; verify the stale clicked price is refused without a native order request +- hide the page after one continuous-close round is acknowledged; verify the next round can start after live readiness and cooldown, then Stop while its response is pending and verify no additional order starts +- hide the page after confirming current-symbol cancellation or a reviewed USDT rebalance; verify the original scope, manual confirmation, per-step account checks, final balance, and temporary UI restoration +- repeat one background scenario in an actual Chrome/Tampermonkey tab and after minimizing Chrome; record the installed userscript version, hidden/frozen/discarded state, elapsed time, request count, exact exchange response, and refreshed authoritative order/account state - Option/Alt-click a close ladder button, confirm the next round starts only after the prior round is complete, the native close action is ready, and one full second has elapsed; change ratio, levels, row span, and precision during the wait and confirm the next plan uses the updated profile - during a continuous close cooldown, make the native close action temporarily unavailable and confirm the cooldown restarts only after readiness returns; confirm Stop also aborts the cooldown immediately - complete multiple continuous close rounds, then stop both during a round and during cooldown; confirm every active-round status starts with `连续阶梯平多` or `连续阶梯平空` and shows completed/started rounds, live-round order ratio, cumulative confirmed submissions, and no zero-cancellation segment diff --git a/docs/binance-orderbook-trade-ui-automation.md b/docs/binance-orderbook-trade-ui-automation.md index 621df9b..096f4e2 100644 --- a/docs/binance-orderbook-trade-ui-automation.md +++ b/docs/binance-orderbook-trade-ui-automation.md @@ -50,6 +50,16 @@ not a latency or throughput measurement. The ordinary single-round unknown submission scenario must not be generalized to continuous mode, whose existing `submit_unconfirmed` policy deliberately permits a later recovery round. +Background-tab L2 scenarios override `document.hidden`, dispatch +`visibilitychange`, and pause paint-frame callbacks after the real generated +userscript is loaded. They verify that already clicked open/close ladders, +continuous close, single-order submission, confirmed cancellation, and confirmed +rebalance continue through the fixture's native request and response boundaries. +This harness does not model Chrome timer throttling, page freeze, discard, or +Tampermonkey injection. Those require a separate L3/L4 observation in an actual +background or minimized Chrome tab; record elapsed time and authoritative +exchange/account state before claiming that the live task completed. + `npm run test:coverage` additionally collects V8 execution and maps it to the complete production source set. The collector's own browser proof uses virtual code and remains separate from production coverage. See [Source Coverage](test-coverage.md) diff --git a/e2e/binance-orderbook/helpers/simulated-visibility.js b/e2e/binance-orderbook/helpers/simulated-visibility.js new file mode 100644 index 0000000..7a322f0 --- /dev/null +++ b/e2e/binance-orderbook/helpers/simulated-visibility.js @@ -0,0 +1,49 @@ +/** Simulate a background tab after the real userscript has loaded in the fixture. */ +export async function installSimulatedVisibility(page) { + await page.evaluate(() => { + const nativeRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const nativeCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + const pendingHiddenFrames = new Map(); + const resumedHiddenFrames = new Map(); + let nextHiddenFrame = 1_000_000; + const state = { + hidden: false, + setHidden(hidden) { + if (this.hidden === hidden) return; + this.hidden = hidden; + document.dispatchEvent(new Event('visibilitychange')); + if (!hidden) { + for (const [handle, callback] of pendingHiddenFrames) { + pendingHiddenFrames.delete(handle); + resumedHiddenFrames.set(handle, nativeRequestAnimationFrame(timestamp => { + resumedHiddenFrames.delete(handle); + callback(timestamp); + })); + } + } + }, + }; + Object.defineProperty(document, 'hidden', { configurable: true, get: () => state.hidden }); + window.requestAnimationFrame = callback => { + if (!state.hidden) return nativeRequestAnimationFrame(callback); + const handle = nextHiddenFrame++; + pendingHiddenFrames.set(handle, callback); + return handle; + }; + window.cancelAnimationFrame = handle => { + if (pendingHiddenFrames.delete(handle)) return; + const resumedHandle = resumedHiddenFrames.get(handle); + if (resumedHiddenFrames.has(handle)) { + resumedHiddenFrames.delete(handle); + nativeCancelAnimationFrame(resumedHandle); + return; + } + nativeCancelAnimationFrame(handle); + }; + window.__SIMULATED_VISIBILITY__ = state; + }); +} + +export async function setSimulatedVisibility(page, hidden) { + await page.evaluate(value => window.__SIMULATED_VISIBILITY__.setHidden(value), hidden); +} diff --git a/e2e/binance-orderbook/specs/account-rebalance-behavior.pw.js b/e2e/binance-orderbook/specs/account-rebalance-behavior.pw.js index 4cee469..b4c0afe 100644 --- a/e2e/binance-orderbook/specs/account-rebalance-behavior.pw.js +++ b/e2e/binance-orderbook/specs/account-rebalance-behavior.pw.js @@ -3,6 +3,7 @@ import { ACCOUNT_PATHS, createAccountRebalanceApi } from '../fixtures/account-re import { createCancelScenario } from '../scenarios/cancel-current-symbol.js'; import { openUserscriptScenario, readFixtureState } from '../helpers/userscript-page.js'; import { installScenarioClock, pauseScenarioClock } from '../helpers/scenario-clock.js'; +import { installSimulatedVisibility, setSimulatedVisibility } from '../helpers/simulated-visibility.js'; async function openRebalance(page, balances, options) { await installScenarioClock(page); @@ -53,6 +54,33 @@ test('user completes exactly two USDT transfers only after confirming the comple expect(errors).toEqual([]); }); +test('user completes a confirmed account rebalance after the tab becomes hidden', async ({ page }) => { + // Given the user has reviewed a two-transfer plan while the trading page is visible. + const { api, errors, action, status } = await openRebalance(page, { + FUNDING: '100', MAIN: '0', UMFUTURE: '0', + }); + await installSimulatedVisibility(page); + await action.evaluate(button => button.click()); + const dialog = page.getByRole('dialog', { name: '账户再平衡' }); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText('40 USDT'); + await expect(dialog).toContainText('10 USDT'); + + // When confirmation is clicked and the tab hides in the same event turn. + await dialog.getByRole('button', { name: '确认再平衡', exact: true }).evaluate(button => { + button.click(); + window.__SIMULATED_VISIBILITY__.setHidden(true); + }); + + // Then each transfer still requires fresh account checks and reaches the reviewed target. + await expect.poll(() => api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.transfer)) + .toHaveLength(2); + await setSimulatedVisibility(page, false); + await expect(status).toHaveText('账户再平衡已完成 · 2/2 笔'); + expect(api.snapshot().balances).toEqual({ FUNDING: '50', MAIN: '40', UMFUTURE: '10' }); + expect(errors).toEqual([]); +}); + test('user cancels an account preview without sending a transfer', async ({ page }) => { // Given a flat account has a valid two-transfer rebalance plan. const { api, errors, action, status } = await openRebalance(page, { FUNDING: '100', MAIN: '0', UMFUTURE: '0' }); diff --git a/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js b/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js index bffde24..d6589f0 100644 --- a/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js +++ b/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js @@ -11,6 +11,7 @@ import { readFixtureState, } from '../helpers/userscript-page.js'; import { installScenarioClock, pauseScenarioClock } from '../helpers/scenario-clock.js'; +import { installSimulatedVisibility, setSimulatedVisibility } from '../helpers/simulated-visibility.js'; import { assertResponsiveInteraction, assertStableGeometry, @@ -216,6 +217,35 @@ test('user confirms cancellation for the current symbol while other-symbol order expect(errors).toEqual([]); }); +test('user finishes confirmed current-symbol cancellation after the tab becomes hidden', async ({ page }) => { + // Given a native confirmation is open for current and other-symbol Basic orders. + 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 installSimulatedVisibility(page); + await page.getByRole('button', { name: '撤单' }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + // When the user confirms, then moves the page to a background tab. + await dialog.getByRole('button', { name: '确认' }).evaluate(button => { + button.click(); + window.__SIMULATED_VISIBILITY__.setHidden(true); + }); + + // Then only the captured current-symbol order is cancelled and UI state is restored. + await expect.poll(async () => (await readFixtureState(page)).events + .filter(({ type }) => type === 'cancel-requested')).toHaveLength(1); + await setSimulatedVisibility(page, false); + await expect(page.getByText('撤单已完成')).toBeVisible(); + expect((await readFixtureState(page)).orders).toEqual(otherSymbolOrders(scenario)); + await expectRestoredState(page, scenario); + expect(errors).toEqual([]); +}); + test('user keeps an already enabled symbol filter after confirming cancellation', async ({ page }) => { // Given the initial open-orders view has Hide Other Symbols enabled and chart orders hidden. const scenario = createCancelScenario({ diff --git a/e2e/binance-orderbook/specs/continuous-readiness-behavior.pw.js b/e2e/binance-orderbook/specs/continuous-readiness-behavior.pw.js index 812361b..080d7b3 100644 --- a/e2e/binance-orderbook/specs/continuous-readiness-behavior.pw.js +++ b/e2e/binance-orderbook/specs/continuous-readiness-behavior.pw.js @@ -2,6 +2,7 @@ import { test, expect } from '../test.js'; import { CURRENT_SYMBOL, createCancelScenario } from '../scenarios/cancel-current-symbol.js'; import { openUserscriptScenario, readFixtureState } from '../helpers/userscript-page.js'; import { installScenarioClock, pauseScenarioClock } from '../helpers/scenario-clock.js'; +import { installSimulatedVisibility, setSimulatedVisibility } from '../helpers/simulated-visibility.js'; const PANEL = '#jh-binance-close-qty-multiplier-panel'; const STATUS = '#jh-binance-ladder-status'; @@ -174,6 +175,31 @@ test('user completes two close-short rounds with a full cooldown and exact cumul expect(host.errors).toEqual([]); }); +test('user continues a confirmed close round while hidden and can stop the next hidden round', async ({ page }) => { + // Given the first round's final native acknowledgement is held after a visible user click. + const host = await openPendingFirstRound(page); + await installSimulatedVisibility(page); + + // When the tab becomes hidden and the held acknowledgement completes. + await setSimulatedVisibility(page, true); + await host.releaseSubmitResponse(3); + await page.clock.resume(); + + // Then the next round begins after its readiness check without a foreground frame. + await expect.poll(host.pendingSubmitSequences, { timeout: 10_000 }).toEqual([4]); + expect((await readSubmissions(page)).map(({ action }) => action)).toEqual(Array(4).fill('平空')); + + // When Stop is clicked while that fourth native response remains held. + await host.panel.locator('[data-ladder-stop]').evaluate(button => button.click()); + await host.releaseSubmitResponse(4); + await setSimulatedVisibility(page, false); + + // Then no later order starts and the acknowledged progress remains exact. + await expect(host.status).toContainText('已停止'); + expect(await readSubmissions(page)).toHaveLength(4); + expect(host.errors).toEqual([]); +}); + test('user waits for a disabled close button and then receives a complete cooldown', async ({ page }) => { // Given the current round can finish while the native close button is disabled. const host = await openPendingFirstRound(page); diff --git a/e2e/binance-orderbook/specs/order-submit-behavior.pw.js b/e2e/binance-orderbook/specs/order-submit-behavior.pw.js index 39d5c20..04f0de7 100644 --- a/e2e/binance-orderbook/specs/order-submit-behavior.pw.js +++ b/e2e/binance-orderbook/specs/order-submit-behavior.pw.js @@ -2,6 +2,7 @@ import { test, expect } from '../test.js'; import { CURRENT_SYMBOL, createCancelScenario } from '../scenarios/cancel-current-symbol.js'; import { openUserscriptScenario, readFixtureState } from '../helpers/userscript-page.js'; import { installScenarioClock, pauseScenarioClock } from '../helpers/scenario-clock.js'; +import { installSimulatedVisibility, setSimulatedVisibility } from '../helpers/simulated-visibility.js'; const PLACE_ORDER = '**/bapi/futures/v1/private/future/order/place-order'; const STATUS = '#jh-binance-ladder-status'; @@ -12,6 +13,122 @@ const DIRECTIONS = [ { action: 'CLOSE_SHORT', mode: 'CLOSE', side: 'SHORT', label: '平空', qty: '0.06', prices: ['80.9', '80.4', '79.9', '81.9', '81.4', '80.9'] }, ]; +for (const direction of [ + { action: 'OPEN_LONG', mode: 'OPEN', label: '开多' }, + { action: 'CLOSE_SHORT', mode: 'CLOSE', label: '平空' }, +]) { + test(`user completes a clicked ${direction.action} ladder after its tab becomes hidden`, async ({ page }) => { + // Given the first native order request is held after the visible button click. + const host = await openUserscriptScenario(page, createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: '100' }], + ui: { tradeMode: direction.mode }, + host: { submitApiResponses: [ + { outcome: 'success', delivery: 'manual' }, + ...Array.from({ length: 4 }, () => ({ outcome: 'success', delivery: 'immediate' })), + ] }, + })); + await installSimulatedVisibility(page); + await page.locator(`[data-ladder-action="${direction.action}"]`).click(); + await expect.poll(host.pendingSubmitSequences).toEqual([1]); + + // When the tab becomes hidden before the first response arrives. + await setSimulatedVisibility(page, true); + await host.releaseSubmitResponse(1); + + // Then the original task submits each remaining order in the captured direction. + await expect.poll(async () => (await readFixtureState(page)).events + .filter(({ type }) => type === 'order-submitted'), { timeout: 10_000 }).toHaveLength(5); + await setSimulatedVisibility(page, false); + await expect(page.locator(STATUS)).toContainText('已完成'); + const submitted = (await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted'); + expect(submitted.map(({ action }) => action)).toEqual(Array(5).fill(direction.label)); + expect(host.errors).toEqual([]); + }); +} + +test('user completes one orderbook click after the tab becomes hidden before input settlement', async ({ page }) => { + // Given the native orderbook click starts the actual single-order input workflow. + const host = await openUserscriptScenario(page, createCancelScenario({ host: { + submitApiResponses: [{ outcome: 'success', delivery: 'manual' }], + } })); + await installSimulatedVisibility(page); + + // When a trusted click reaches the page and the same event turn hides the tab. + await page.evaluate(() => { + document.addEventListener('click', event => { + if (event.target.closest('#futuresOrderbook .bid-light.emit-price')) { + queueMicrotask(() => window.__SIMULATED_VISIBILITY__.setHidden(true)); + } + }, { capture: true, once: true }); + }); + await page.locator('#futuresOrderbook .bid-light.emit-price').first().click(); + expect(await page.evaluate(() => document.hidden)).toBe(true); + + // Then one native request is made and the acknowledgement completes that task. + await expect.poll(host.pendingSubmitSequences, { timeout: 10_000 }).toEqual([1]); + await host.releaseSubmitResponse(1); + await setSimulatedVisibility(page, false); + await expect(page.locator(STATUS)).toHaveText('单击开多已提交 · 81.0 × 0.07'); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted')) + .toEqual([expect.objectContaining({ action: '开多', price: '81.0', quantity: '0.07' })]); + expect(host.errors).toEqual([]); +}); + +test('user does not submit an old orderbook click after a long hidden-page stall', async ({ page }) => { + // Given a trusted single-order click starts before Chrome hides the page. + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario()); + await installSimulatedVisibility(page); + await page.evaluate(() => { + document.addEventListener('click', event => { + if (event.target.closest('#futuresOrderbook .bid-light.emit-price')) { + queueMicrotask(() => window.__SIMULATED_VISIBILITY__.setHidden(true)); + } + }, { capture: true, once: true }); + }); + await page.locator('#futuresOrderbook .bid-light.emit-price').first().click(); + expect(await page.evaluate(() => document.hidden)).toBe(true); + + // When a long frozen-style time jump passes before background checks can settle. + await pauseScenarioClock(page); + await page.clock.fastForward(30_000); + await page.clock.runFor(500); + await setSimulatedVisibility(page, false); + + // Then the original clicked price is expired and cannot become a native order. + await expect(page.locator(STATUS)).toContainText('点击后等待过久'); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted')) + .toEqual([]); + expect(host.errors).toEqual([]); +}); + +test('user stops a hidden ladder before its pending control check can submit', async ({ page }) => { + // Given a trusted ladder click starts while the foreground form is ready. + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario()); + await installSimulatedVisibility(page); + await page.evaluate(() => { + document.addEventListener('click', event => { + if (event.target.closest('[data-ladder-action="OPEN_LONG"]')) { + queueMicrotask(() => window.__SIMULATED_VISIBILITY__.setHidden(true)); + } + }, { capture: true, once: true }); + }); + + // When the tab hides in that click turn and Stop cancels the pending wait. + await page.locator('[data-ladder-action="OPEN_LONG"]').click(); + await page.evaluate(() => window.__TM_CLOSE_LONG_DEBUG__.stopLadder()); + await setSimulatedVisibility(page, false); + + // Then no native submit is made after the cancelled wait receives more time. + await pauseScenarioClock(page); + await page.clock.runFor(400); + await expect(page.locator(STATUS)).toContainText('已停止'); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted')) + .toEqual([]); + expect(host.errors).toEqual([]); +}); + for (const direction of DIRECTIONS) { test(`user reprices only the three remaining ${direction.action} orders after a native maker rejection`, async ({ page }) => { // Given the first two orders succeed and the third meets a changed native book. diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index 6924290..2d3db4f 100644 --- a/scripts/binance-orderbook-trade.user.js +++ b/scripts/binance-orderbook-trade.user.js @@ -3,7 +3,7 @@ // @namespace binance.orderbook.trade // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 2.7.211 +// @version 2.7.212 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -2703,97 +2703,116 @@ check(); }); } - function waitForTradeFormFrameState(observationRoot, readState, timeoutMs, requiredStableFrames = 2) { - const view = observationRoot?.ownerDocument?.defaultView; + function waitForStableTradeControl(observationRoot, readCandidate, isSameCandidate, timeoutMs, requiredStableFrames, abortSignal, missingSchedulerMessage) { + const document2 = observationRoot?.ownerDocument || observationRoot; + const view = document2?.defaultView; if (!view || typeof view.requestAnimationFrame !== "function" || typeof view.cancelAnimationFrame !== "function") { - throw new Error("交易表单帧调度器不可用"); + throw new Error(missingSchedulerMessage); } if (!Number.isInteger(requiredStableFrames) || requiredStableFrames < 1) { throw new Error("稳定帧数必须为正整数"); } - return new Promise((resolve) => { + return new Promise((resolve, reject) => { let settled = false; let frameHandle = 0; - let timer = 0; + let observationTimer = 0; + let deadlineTimer = 0; let stableFrames = 0; - let stableState = null; - const finish = (value) => { + let stableCandidate = null; + let hiddenObservations = 0; + const maxHiddenObservations = Math.max(requiredStableFrames, Math.ceil(timeoutMs / 100)); + const finish = (value, error = null) => { if (settled) return; settled = true; if (frameHandle) view.cancelAnimationFrame(frameHandle); - view.clearTimeout(timer); - resolve(value); + view.clearTimeout(observationTimer); + view.clearTimeout(deadlineTimer); + document2.removeEventListener("visibilitychange", onVisibilityChange); + abortSignal?.removeEventListener("abort", onAbort); + if (error) reject(error); + else resolve(value); + }; + const onAbort = () => finish(null, abortSignal.reason); + const scheduleObservation = () => { + if (document2.hidden) { + observationTimer = view.setTimeout(check, 100); + } else { + frameHandle = view.requestAnimationFrame(check); + } }; const check = () => { frameHandle = 0; - const state = readState(); - if (state) { - stableFrames += 1; - stableState = state; + observationTimer = 0; + const candidate = readCandidate(); + if (candidate) { + stableFrames = isSameCandidate(candidate, stableCandidate) ? stableFrames + 1 : 1; + stableCandidate = candidate; if (stableFrames >= requiredStableFrames) { - finish(stableState); + finish(candidate); return; } } else { stableFrames = 0; - stableState = null; + stableCandidate = null; + } + if (document2.hidden) { + hiddenObservations += 1; + if (hiddenObservations >= maxHiddenObservations) { + finish(null); + return; + } } - frameHandle = view.requestAnimationFrame(check); + scheduleObservation(); + }; + const onVisibilityChange = () => { + if (frameHandle) view.cancelAnimationFrame(frameHandle); + view.clearTimeout(observationTimer); + view.clearTimeout(deadlineTimer); + frameHandle = 0; + observationTimer = 0; + if (!document2.hidden) deadlineTimer = view.setTimeout(() => finish(null), timeoutMs); + scheduleObservation(); }; - timer = view.setTimeout(() => finish(null), timeoutMs); - frameHandle = view.requestAnimationFrame(check); + document2.addEventListener("visibilitychange", onVisibilityChange); + abortSignal?.addEventListener("abort", onAbort, { once: true }); + if (abortSignal?.aborted) { + onAbort(); + return; + } + if (!document2.hidden) deadlineTimer = view.setTimeout(() => finish(null), timeoutMs); + scheduleObservation(); }); } - function waitForTradeActionButtonFrameState(observationRoot, findButton, isVisibleElement, timeoutMs, requiredStableFrames = 2) { - const view = observationRoot?.ownerDocument?.defaultView || observationRoot?.defaultView; - if (!view || typeof view.requestAnimationFrame !== "function" || typeof view.cancelAnimationFrame !== "function") { - throw new Error("下单按钮帧调度器不可用"); - } + function waitForTradeFormFrameState(observationRoot, readState, timeoutMs, requiredStableFrames = 2, abortSignal = null) { + return waitForStableTradeControl( + observationRoot, + readState, + () => true, + timeoutMs, + requiredStableFrames, + abortSignal, + "交易表单帧调度器不可用" + ); + } + function waitForTradeActionButtonFrameState(observationRoot, findButton, isVisibleElement, timeoutMs, requiredStableFrames = 2, abortSignal = null) { if (typeof findButton !== "function" || typeof isVisibleElement !== "function") { throw new Error("下单按钮定位器不可用"); } - if (!Number.isInteger(requiredStableFrames) || requiredStableFrames < 1) { - throw new Error("稳定帧数必须为正整数"); - } - return new Promise((resolve) => { - let settled = false; - let frameHandle = 0; - let timer = 0; - let stableFrames = 0; - let stableButton = null; - const finish = (value) => { - if (settled) return; - settled = true; - if (frameHandle) view.cancelAnimationFrame(frameHandle); - view.clearTimeout(timer); - resolve(value); - }; - const check = () => { - frameHandle = 0; + return waitForStableTradeControl( + observationRoot, + () => { const button = findButton(); const actionable = Boolean( button && button.isConnected && isVisibleElement(button) && !button.disabled && button.getAttribute("aria-disabled") !== "true" ); - if (actionable) { - if (button === stableButton) { - stableFrames += 1; - } else { - stableButton = button; - stableFrames = 1; - } - if (stableFrames >= requiredStableFrames) { - finish(button); - return; - } - } else { - stableButton = null; - stableFrames = 0; - } - frameHandle = view.requestAnimationFrame(check); - }; - timer = view.setTimeout(() => finish(null), timeoutMs); - frameHandle = view.requestAnimationFrame(check); - }); + return actionable ? button : null; + }, + (button, previousButton) => button === previousButton, + timeoutMs, + requiredStableFrames, + abortSignal, + "下单按钮帧调度器不可用" + ); } function isTradeModeTab(node, { panelId }) { if (!node?.matches?.('[role="tab"]')) return false; @@ -5225,6 +5244,7 @@ const USDT_REBALANCE_BALANCE_POLL_MS = 1e3; const LADDER_OPEN_QTY_READY_TIMEOUT_MS = 1200; const TRADE_INPUT_SYNC_TIMEOUT_MS = 350; + const SINGLE_ORDER_DRAFT_MAX_AGE_MS = 15e3; const TRADE_INPUT_SYNC_STABLE_FRAMES = 2; const LADDER_INPUT_SETTLE_TIMEOUT_MS = 1200; const LADDER_INPUT_SETTLE_STABLE_MS = 180; @@ -7470,7 +7490,8 @@ observationRoot, readTradeState, syncTimeoutMs, - TRADE_INPUT_SYNC_STABLE_FRAMES + TRADE_INPUT_SYNC_STABLE_FRAMES, + options?.abortSignal ); if (synchronized) return synchronized; assertSubmittedPriceMatchesExpectedPrice( @@ -7490,7 +7511,7 @@ const cls = String(button.className || "").toLowerCase(); return button.disabled || button.getAttribute("aria-disabled") === "true" || button.getAttribute("aria-busy") === "true" || button.getAttribute("data-loading") === "true" || includesBinancePageText(text, BINANCE_PAGE_TEXT.submitBusy) || cls.includes("loading") || !!button.querySelector('[class*="loading"], [class*="spinner"], [aria-busy="true"]'); } - async function waitForReadyLadderSubmitButton(plan) { + async function waitForReadyLadderSubmitButton(plan, abortSignal) { const resolveReadyButton = () => { const candidate = plan.spec.buttonGetter(); return candidate && !isSubmitButtonBusy(candidate) ? candidate : null; @@ -7499,7 +7520,9 @@ document, resolveReadyButton, isVisibleElement, - TRADE_ACTION_BUTTON_READY_TIMEOUT_MS + TRADE_ACTION_BUTTON_READY_TIMEOUT_MS, + 2, + abortSignal ); if (button) return button; const currentButton = plan.spec.buttonGetter(); @@ -7734,7 +7757,7 @@ throwIfAborted(abortSignal); assertLadderExecutionContext(plan); assertLadderMakerPrice(plan, order.price); - await waitForReadyLadderSubmitButton(plan); + await waitForReadyLadderSubmitButton(plan, abortSignal); throwIfAborted(abortSignal); assertLadderExecutionContext(plan); assertLadderMakerPrice(plan, order.price); @@ -7756,13 +7779,14 @@ priceLabel: "计划价", qtyLabel: "计划量", settleControlledForm: true, - previousSubmittedInputs: previousAcknowledgedInputs + previousSubmittedInputs: previousAcknowledgedInputs, + abortSignal }); throwIfAborted(abortSignal); const submittedPrice = synchronizedInputs.submittedPrice; assertLadderExecutionContext(plan); assertLadderMakerPrice(plan, submittedPrice); - const button = await waitForReadyLadderSubmitButton(plan); + const button = await waitForReadyLadderSubmitButton(plan, abortSignal); throwIfAborted(abortSignal); assertLadderExecutionContext(plan); assertSubmittedPriceMatchesExpectedPrice( @@ -8164,7 +8188,7 @@ if (getActiveTradeMode() !== spec.mode) { return { status: "stopped", reason: "mode_changed" }; } - if (document.hidden || ladderTask || singleOrderTask || cancelCurrentSymbolOpenOrdersTask || !readCurrentOrderbookPrecisionValue()) { + if (ladderTask || singleOrderTask || cancelCurrentSymbolOpenOrdersTask || !readCurrentOrderbookPrecisionValue()) { return { status: "waiting" }; } const button = spec.buttonGetter(); @@ -10157,8 +10181,8 @@ readReadiness: async () => { assertLadderExecutionContext(plan); const button = plan.spec.buttonGetter(); - const ready = !document.hidden && isCloseSnapshotReady(plan.symbol) && button && button.isConnected && isVisibleElement(button) && !isSubmitButtonBusy(button); - if (!ready && !document.hidden && Date.now() >= nextPositionCheckAt) { + const ready = isCloseSnapshotReady(plan.symbol) && button && button.isConnected && isVisibleElement(button) && !isSubmitButtonBusy(button); + if (!ready && Date.now() >= nextPositionCheckAt) { await throwIfClosePositionCompleted(plan, abortSignal); nextPositionCheckAt = Date.now() + CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS; assertLadderExecutionContext(plan); @@ -10554,8 +10578,10 @@ futuresPayload ); } - async function assertUsdtRebalanceTradingState() { - if (!isFuturesTradingPage() || document.hidden) throw new Error("当前不在可操作的合约页面"); + async function assertUsdtRebalanceTradingState({ allowHidden = false } = {}) { + if (!isFuturesTradingPage() || !allowHidden && document.hidden) { + throw new Error("当前不在可操作的合约页面"); + } if (ladderTask || continuousLadderTask || singleOrderTask || cancelCurrentSymbolOpenOrdersTask) { throw new Error("当前仍有交易任务运行"); } @@ -10640,7 +10666,7 @@ } let expectedBalances = initialBalances; for (const transfer of plan.transfers) { - await assertUsdtRebalanceTradingState(); + await assertUsdtRebalanceTradingState({ allowHidden: true }); const currentBalances = await readCurrentUsdtRebalanceBalances(); if (!areUsdtBalancesEqual(currentBalances, expectedBalances)) { throw new Error("账户余额已变化,已停止账户再平衡"); @@ -12236,6 +12262,7 @@ }); } const now = Date.now(); + const clickedAt = performance.now(); if (CFG.COOLDOWN_MS > 0 && now - lastTs < CFG.COOLDOWN_MS) { if (CFG.DEBUG) warn("跳过:cooldown"); return; @@ -12331,6 +12358,9 @@ if (!isCurrentObservedSymbol(qtyPlan.symbol)) { throw new Error("提交前交易对已变化,已停止"); } + if (Math.max(Date.now() - now, performance.now() - clickedAt) > SINGLE_ORDER_DRAFT_MAX_AGE_MS) { + throw new Error("点击后等待过久,已停止提交"); + } const previousFeedback = takeOrderFeedbackSnapshot(); const submitCaptureId = beginLadderSubmitResponseCapture(); try { @@ -12428,13 +12458,13 @@ ensureDepthProfileObserver(); scheduleDepthProfileSync(); } - function stopTradingTimers() { + function stopTradingTimers({ preserveTradeUiMutationWait = false } = {}) { stopTradeModeTabObserver(); stopAccountPositionObserver(); stopOrderbookPrecisionObserver(); stopDepthProfileObserver(); stopDepthProfileSession(); - clearTradeUiMutationWait(); + if (!preserveTradeUiMutationWait) clearTradeUiMutationWait(); } function syncRouteState() { if (document.hidden) return; @@ -12504,7 +12534,11 @@ } catch (error) { err("页面隐藏前图表保存刷新失败:", error); } - stopTradingTimers(); + stopTradingTimers({ + preserveTradeUiMutationWait: Boolean( + ladderTask || continuousLadderTask || singleOrderTask || cancelCurrentSymbolOpenOrdersTask + ) + }); stopRouteWatcher(); return; } diff --git a/src/binance-orderbook-trade/dom/trade-form.js b/src/binance-orderbook-trade/dom/trade-form.js index df811be..a78fdea 100644 --- a/src/binance-orderbook-trade/dom/trade-form.js +++ b/src/binance-orderbook-trade/dom/trade-form.js @@ -554,66 +554,130 @@ export function waitForTradeFormMutationState(observationRoot, readState, timeou } /** - * Confirm property-based controlled-input state across consecutive paint frames. - * MutationObserver cannot observe React restoring an input's value property, so - * trade submission must read the current live inputs after React has settled. + * Observe live trade controls on paint frames while visible and separate timer + * tasks while hidden. Chrome pauses paint frames in background tabs; a wall + * timeout there could expire before the second independent observation. */ -export function waitForTradeFormFrameState( +function waitForStableTradeControl( observationRoot, - readState, + readCandidate, + isSameCandidate, timeoutMs, - requiredStableFrames = 2, + requiredStableFrames, + abortSignal, + missingSchedulerMessage, ) { - const view = observationRoot?.ownerDocument?.defaultView; + const document = observationRoot?.ownerDocument || observationRoot; + const view = document?.defaultView; if ( !view || typeof view.requestAnimationFrame !== 'function' || typeof view.cancelAnimationFrame !== 'function' ) { - throw new Error('交易表单帧调度器不可用'); + throw new Error(missingSchedulerMessage); } if (!Number.isInteger(requiredStableFrames) || requiredStableFrames < 1) { throw new Error('稳定帧数必须为正整数'); } - return new Promise((resolve) => { + return new Promise((resolve, reject) => { let settled = false; let frameHandle = 0; - let timer = 0; + let observationTimer = 0; + let deadlineTimer = 0; let stableFrames = 0; - let stableState = null; - const finish = (value) => { + let stableCandidate = null; + let hiddenObservations = 0; + const maxHiddenObservations = Math.max(requiredStableFrames, Math.ceil(timeoutMs / 100)); + const finish = (value, error = null) => { if (settled) return; settled = true; if (frameHandle) view.cancelAnimationFrame(frameHandle); - view.clearTimeout(timer); - resolve(value); + view.clearTimeout(observationTimer); + view.clearTimeout(deadlineTimer); + document.removeEventListener('visibilitychange', onVisibilityChange); + abortSignal?.removeEventListener('abort', onAbort); + if (error) reject(error); + else resolve(value); + }; + const onAbort = () => finish(null, abortSignal.reason); + const scheduleObservation = () => { + if (document.hidden) { + observationTimer = view.setTimeout(check, 100); + } else { + frameHandle = view.requestAnimationFrame(check); + } }; const check = () => { frameHandle = 0; - const state = readState(); - if (state) { - stableFrames += 1; - stableState = state; + observationTimer = 0; + const candidate = readCandidate(); + if (candidate) { + stableFrames = isSameCandidate(candidate, stableCandidate) ? stableFrames + 1 : 1; + stableCandidate = candidate; if (stableFrames >= requiredStableFrames) { - finish(stableState); + finish(candidate); return; } } else { stableFrames = 0; - stableState = null; + stableCandidate = null; + } + if (document.hidden) { + hiddenObservations += 1; + if (hiddenObservations >= maxHiddenObservations) { + finish(null); + return; + } } - frameHandle = view.requestAnimationFrame(check); + scheduleObservation(); + }; + const onVisibilityChange = () => { + if (frameHandle) view.cancelAnimationFrame(frameHandle); + view.clearTimeout(observationTimer); + view.clearTimeout(deadlineTimer); + frameHandle = 0; + observationTimer = 0; + if (!document.hidden) deadlineTimer = view.setTimeout(() => finish(null), timeoutMs); + scheduleObservation(); }; - timer = view.setTimeout(() => finish(null), timeoutMs); - frameHandle = view.requestAnimationFrame(check); + document.addEventListener('visibilitychange', onVisibilityChange); + abortSignal?.addEventListener('abort', onAbort, { once: true }); + if (abortSignal?.aborted) { + onAbort(); + return; + } + if (!document.hidden) deadlineTimer = view.setTimeout(() => finish(null), timeoutMs); + scheduleObservation(); }); } +/** + * MutationObserver cannot observe React restoring an input's value property, so + * trade submission must read the current live inputs after React has settled. + */ +export function waitForTradeFormFrameState( + observationRoot, + readState, + timeoutMs, + requiredStableFrames = 2, + abortSignal = null, +) { + return waitForStableTradeControl( + observationRoot, + readState, + () => true, + timeoutMs, + requiredStableFrames, + abortSignal, + '交易表单帧调度器不可用', + ); +} + /** * Binance can mark the requested trade mode active before React replaces the * native action buttons. Require one live button identity to remain actionable - * across consecutive paint frames so callers never click the outgoing node. + * across independent observations so callers never click the outgoing node. */ export function waitForTradeActionButtonFrameState( observationRoot, @@ -621,37 +685,14 @@ export function waitForTradeActionButtonFrameState( isVisibleElement, timeoutMs, requiredStableFrames = 2, + abortSignal = null, ) { - const view = observationRoot?.ownerDocument?.defaultView || observationRoot?.defaultView; - if ( - !view - || typeof view.requestAnimationFrame !== 'function' - || typeof view.cancelAnimationFrame !== 'function' - ) { - throw new Error('下单按钮帧调度器不可用'); - } if (typeof findButton !== 'function' || typeof isVisibleElement !== 'function') { throw new Error('下单按钮定位器不可用'); } - if (!Number.isInteger(requiredStableFrames) || requiredStableFrames < 1) { - throw new Error('稳定帧数必须为正整数'); - } - - return new Promise((resolve) => { - let settled = false; - let frameHandle = 0; - let timer = 0; - let stableFrames = 0; - let stableButton = null; - const finish = (value) => { - if (settled) return; - settled = true; - if (frameHandle) view.cancelAnimationFrame(frameHandle); - view.clearTimeout(timer); - resolve(value); - }; - const check = () => { - frameHandle = 0; + return waitForStableTradeControl( + observationRoot, + () => { const button = findButton(); const actionable = Boolean( button @@ -660,26 +701,14 @@ export function waitForTradeActionButtonFrameState( && !button.disabled && button.getAttribute('aria-disabled') !== 'true' ); - if (actionable) { - if (button === stableButton) { - stableFrames += 1; - } else { - stableButton = button; - stableFrames = 1; - } - if (stableFrames >= requiredStableFrames) { - finish(button); - return; - } - } else { - stableButton = null; - stableFrames = 0; - } - frameHandle = view.requestAnimationFrame(check); - }; - timer = view.setTimeout(() => finish(null), timeoutMs); - frameHandle = view.requestAnimationFrame(check); - }); + return actionable ? button : null; + }, + (button, previousButton) => button === previousButton, + timeoutMs, + requiredStableFrames, + abortSignal, + '下单按钮帧调度器不可用', + ); } export function isTradeModeTab(node, { panelId }) { diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index d8fc020..ba74560 100644 --- a/src/binance-orderbook-trade/index.user.js +++ b/src/binance-orderbook-trade/index.user.js @@ -3,7 +3,7 @@ // @namespace binance.orderbook.trade // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 2.7.211 +// @version 2.7.212 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -378,6 +378,8 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; const USDT_REBALANCE_BALANCE_POLL_MS = 1000; const LADDER_OPEN_QTY_READY_TIMEOUT_MS = 1200; const TRADE_INPUT_SYNC_TIMEOUT_MS = 350; + // A clicked limit price has no ladder-style maker-price recheck after a long background stall. + const SINGLE_ORDER_DRAFT_MAX_AGE_MS = 15000; const TRADE_INPUT_SYNC_STABLE_FRAMES = 2; const LADDER_INPUT_SETTLE_TIMEOUT_MS = 1200; const LADDER_INPUT_SETTLE_STABLE_MS = 180; @@ -3067,6 +3069,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; readTradeState, syncTimeoutMs, TRADE_INPUT_SYNC_STABLE_FRAMES, + options?.abortSignal, ); if (synchronized) return synchronized; @@ -3097,7 +3100,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; ); } - async function waitForReadyLadderSubmitButton(plan) { + async function waitForReadyLadderSubmitButton(plan, abortSignal) { const resolveReadyButton = () => { const candidate = plan.spec.buttonGetter(); return candidate && !isSubmitButtonBusy(candidate) ? candidate : null; @@ -3107,6 +3110,8 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; resolveReadyButton, isVisibleElement, TRADE_ACTION_BUTTON_READY_TIMEOUT_MS, + 2, + abortSignal, ); if (button) return button; @@ -3407,7 +3412,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; assertLadderExecutionContext(plan); assertLadderMakerPrice(plan, order.price); - await waitForReadyLadderSubmitButton(plan); + await waitForReadyLadderSubmitButton(plan, abortSignal); throwIfAborted(abortSignal); assertLadderExecutionContext(plan); assertLadderMakerPrice(plan, order.price); @@ -3432,13 +3437,14 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; qtyLabel: '计划量', settleControlledForm: true, previousSubmittedInputs: previousAcknowledgedInputs, + abortSignal, }); throwIfAborted(abortSignal); const submittedPrice = synchronizedInputs.submittedPrice; assertLadderExecutionContext(plan); assertLadderMakerPrice(plan, submittedPrice); - const button = await waitForReadyLadderSubmitButton(plan); + const button = await waitForReadyLadderSubmitButton(plan, abortSignal); throwIfAborted(abortSignal); assertLadderExecutionContext(plan); assertSubmittedPriceMatchesExpectedPrice( @@ -3867,8 +3873,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; return { status: 'stopped', reason: 'mode_changed' }; } if ( - document.hidden - || ladderTask + ladderTask || singleOrderTask || cancelCurrentSymbolOpenOrdersTask || !readCurrentOrderbookPrecisionValue() @@ -6209,11 +6214,11 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; readReadiness: async () => { assertLadderExecutionContext(plan); const button = plan.spec.buttonGetter(); - const ready = !document.hidden && isCloseSnapshotReady(plan.symbol) + const ready = isCloseSnapshotReady(plan.symbol) && button && button.isConnected && isVisibleElement(button) && !isSubmitButtonBusy(button); // A completed close can disable the button permanently; recheck the // position while waiting rather than waiting for an impossible ready state. - if (!ready && !document.hidden && Date.now() >= nextPositionCheckAt) { + if (!ready && Date.now() >= nextPositionCheckAt) { await throwIfClosePositionCompleted(plan, abortSignal); nextPositionCheckAt = Date.now() + CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS; assertLadderExecutionContext(plan); @@ -6675,8 +6680,10 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; ); } - async function assertUsdtRebalanceTradingState() { - if (!isFuturesTradingPage() || document.hidden) throw new Error('当前不在可操作的合约页面'); + async function assertUsdtRebalanceTradingState({ allowHidden = false } = {}) { + if (!isFuturesTradingPage() || (!allowHidden && document.hidden)) { + throw new Error('当前不在可操作的合约页面'); + } if (ladderTask || continuousLadderTask || singleOrderTask || cancelCurrentSymbolOpenOrdersTask) { throw new Error('当前仍有交易任务运行'); } @@ -6766,7 +6773,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; let expectedBalances = initialBalances; for (const transfer of plan.transfers) { - await assertUsdtRebalanceTradingState(); + await assertUsdtRebalanceTradingState({ allowHidden: true }); const currentBalances = await readCurrentUsdtRebalanceBalances(); if (!areUsdtBalancesEqual(currentBalances, expectedBalances)) { throw new Error('账户余额已变化,已停止账户再平衡'); @@ -8593,6 +8600,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; } const now = Date.now(); + const clickedAt = performance.now(); if (CFG.COOLDOWN_MS > 0 && now - lastTs < CFG.COOLDOWN_MS) { if (CFG.DEBUG) warn('跳过:cooldown'); return; @@ -8701,6 +8709,9 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; if (!isCurrentObservedSymbol(qtyPlan.symbol)) { throw new Error('提交前交易对已变化,已停止'); } + if (Math.max(Date.now() - now, performance.now() - clickedAt) > SINGLE_ORDER_DRAFT_MAX_AGE_MS) { + throw new Error('点击后等待过久,已停止提交'); + } const previousFeedback = takeOrderFeedbackSnapshot(); const submitCaptureId = beginLadderSubmitResponseCapture(); try { @@ -8811,13 +8822,13 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; scheduleDepthProfileSync(); } - function stopTradingTimers() { + function stopTradingTimers({ preserveTradeUiMutationWait = false } = {}) { stopTradeModeTabObserver(); stopAccountPositionObserver(); stopOrderbookPrecisionObserver(); stopDepthProfileObserver(); stopDepthProfileSession(); - clearTradeUiMutationWait(); + if (!preserveTradeUiMutationWait) clearTradeUiMutationWait(); } function syncRouteState() { @@ -8895,7 +8906,11 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; } catch (error) { err('页面隐藏前图表保存刷新失败:', error); } - stopTradingTimers(); + stopTradingTimers({ + preserveTradeUiMutationWait: Boolean( + ladderTask || continuousLadderTask || singleOrderTask || cancelCurrentSymbolOpenOrdersTask + ), + }); stopRouteWatcher(); return; } diff --git a/test/helpers/dom.js b/test/helpers/dom.js index 41cffe8..840943b 100644 --- a/test/helpers/dom.js +++ b/test/helpers/dom.js @@ -3,6 +3,7 @@ import { JSDOM } from 'jsdom'; export function loadFixtureDom(html) { const dom = new JSDOM(html); const { window } = dom; + Object.defineProperty(window.document, 'hidden', { configurable: true, value: false }); window.HTMLElement.prototype.getClientRects = function getClientRects() { return this.hasAttribute('data-hidden') ? [] : [{ width: 100, height: 24 }]; diff --git a/test/unit/binance-orderbook-trade/trade-form.test.js b/test/unit/binance-orderbook-trade/trade-form.test.js index ba5c4b0..6252a88 100644 --- a/test/unit/binance-orderbook-trade/trade-form.test.js +++ b/test/unit/binance-orderbook-trade/trade-form.test.js @@ -316,6 +316,111 @@ test('user confirms controlled trade inputs only after consecutive stable frames assert.equal(frames.pendingCount, 0); }); +test('user confirms stable trade inputs while the tab is hidden and paint frames are paused', async (t) => { + // Given native inputs are ready but Chrome has hidden the document and paused paint frames + const dom = loadFixtureDom('
'); + const root = dom.window.document.querySelector('section'); + const frames = createAnimationFrameBoundary(dom.window); + Object.defineProperty(dom.window.document, 'hidden', { configurable: true, value: true }); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const pending = waitForTradeFormFrameState(root, () => ({ + price: root.querySelector('#price').value, + qty: root.querySelector('#qty').value, + }), 1200, 2); + + // When background task opportunities advance without any animation frame + t.mock.timers.tick(100); + t.mock.timers.tick(100); + const observed = await pending; + + // Then the exact stable inputs are accepted without depending on a paint frame + assert.deepEqual(observed, { price: '81.9', qty: '0.01' }); + assert.equal(frames.pendingCount, 0); +}); + +test('user confirms one connected action button while the tab is hidden', async (t) => { + // Given the native action button is ready but background paint frames are paused + const dom = loadFixtureDom('
'); + const { document } = dom.window; + const button = document.querySelector('button'); + const frames = createAnimationFrameBoundary(dom.window); + Object.defineProperty(document, 'hidden', { configurable: true, value: true }); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const pending = waitForTradeActionButtonFrameState(document, () => button, () => true, 3000, 2); + + // When background task opportunities advance without any animation frame + t.mock.timers.tick(100); + t.mock.timers.tick(100); + const observed = await pending; + + // Then the exact current button is accepted without depending on a paint frame + assert.equal(observed, button); + assert.equal(frames.pendingCount, 0); +}); + +test('user follows a replacement native button when the page hides before its next paint frame', async (t) => { + // Given a visible trade form has scheduled its first paint observation. + const dom = loadFixtureDom('
'); + const { document } = dom.window; + const root = document.querySelector('section'); + const frames = createAnimationFrameBoundary(dom.window); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const pending = waitForTradeActionButtonFrameState(document, () => root.querySelector('button'), () => true, 3000, 2); + assert.equal(frames.pendingCount, 1); + + // When Chrome hides the page and React replaces the native action button. + Object.defineProperty(document, 'hidden', { configurable: true, value: true }); + document.dispatchEvent(new dom.window.Event('visibilitychange')); + assert.equal(frames.pendingCount, 0); + t.mock.timers.tick(100); + root.innerHTML = ''; + const replacement = root.querySelector('button'); + t.mock.timers.tick(100); + t.mock.timers.tick(100); + + // Then the later connected identity, not the removed node, is accepted. + assert.equal(await pending, replacement); + assert.equal(frames.pendingCount, 0); +}); + +test('user rejects a hidden trade form that never reaches a stable input state', async (t) => { + // Given React keeps the native quantity at a wrong value in the hidden tab. + const dom = loadFixtureDom('
'); + const root = dom.window.document.querySelector('section'); + const frames = createAnimationFrameBoundary(dom.window); + Object.defineProperty(dom.window.document, 'hidden', { configurable: true, value: true }); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const pending = waitForTradeFormFrameState(root, () => null, 1200, 2); + + // When every allowed independent background observation still finds invalid inputs. + for (let observation = 0; observation < 12; observation += 1) t.mock.timers.tick(100); + + // Then the task refuses to accept a form state and leaves no paint callback. + assert.equal(await pending, null); + assert.equal(frames.pendingCount, 0); +}); + +test('user stops a hidden trade-input wait before it can accept a later button click', async (t) => { + // Given the hidden tab has no stable input state and a stop signal owns the pending wait + const dom = loadFixtureDom('
'); + const root = dom.window.document.querySelector('section'); + const frames = createAnimationFrameBoundary(dom.window); + Object.defineProperty(dom.window.document, 'hidden', { configurable: true, value: true }); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const stop = new AbortController(); + const stoppedError = new Error('Stopped'); + stoppedError.name = 'LadderStoppedError'; + const pending = waitForTradeFormFrameState(root, () => null, 1200, 2, stop.signal); + + // When the user stops while hidden and browser task time advances + stop.abort(stoppedError); + t.mock.timers.tick(1200); + + // Then the wait ends with the stop reason and does not retain a paint callback + await assert.rejects(pending, { name: 'LadderStoppedError', message: 'Stopped' }); + assert.equal(frames.pendingCount, 0); +}); + test('user rejects trade inputs that keep rolling back before their virtual deadline', async (t) => { // Given a real native quantity input alternates between the expected and rolled-back values const dom = loadFixtureDom('
');