From 176d9ff6a33512a25c8e8e38e245bbf84504c9c1 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:45:10 +0800 Subject: [PATCH 1/2] test: establish behavioral test policy and coverage gates --- .github/workflows/binance-orderbook-ui.yml | 4 +- .../workflows/binance-strategy27-events.yml | 5 +- .github/workflows/userscript-tests.yml | 78 ++ AGENTS.md | 4 + docs/binance-orderbook-trade-development.md | 10 + docs/binance-orderbook-trade-ui-automation.md | 21 + docs/test-coverage.md | 128 ++ docs/test-policy.md | 208 +++ docs/test-selection.md | 164 +++ docs/userscript-validation.md | 13 + .../fixtures/binance-futures.js | 81 +- .../helpers/scenario-clock.js | 12 + .../helpers/userscript-page.js | 44 +- .../scenarios/cancel-current-symbol.js | 38 +- .../specs/cancel-covering-matrix.pw.js | 5 +- .../specs/cancel-current-symbol.pw.js | 126 +- .../specs/close-ladder-recovery.pw.js | 15 +- .../specs/control-flows.pw.js | 241 +++- .../specs/coverage-merge.pw.js | 96 ++ .../specs/depth-profile-labels.pw.js | 32 +- .../specs/live-performance-probe.pw.js | 63 +- .../specs/panel-visual-contract.pw.js | 20 +- .../specs/precision-controls.pw.js | 35 +- .../specs/strategy29-coexistence.pw.js | 13 +- .../specs/strategy29-panel-drag.pw.js | 19 +- .../specs/unicode-symbols.pw.js | 17 +- .../specs/userscript-performance.pw.js | 16 +- e2e/binance-orderbook/test.js | 14 + eslint.config.js | 37 + package-lock.json | 1224 ++++++++++++++++- package.json | 11 +- scripts/binance-orderbook-trade.user.js | 13 +- scripts/test-coverage/branch-policy.json | 12 + scripts/test-coverage/browser-reporter.mjs | 26 + scripts/test-coverage/capture-contract.mjs | 29 + scripts/test-coverage/collect-browser.mjs | 30 + scripts/test-coverage/collect-node.mjs | 39 + scripts/test-coverage/config.mjs | 44 + scripts/test-coverage/gates.mjs | 37 + scripts/test-coverage/merge-proof.mjs | 170 +++ scripts/test-coverage/report.mjs | 79 ++ scripts/test-coverage/run.mjs | 98 ++ scripts/test-coverage/source-maps.mjs | 136 ++ scripts/test-coverage/split-entries.mjs | 129 ++ scripts/test-policy/ast.js | 160 +++ scripts/test-policy/eslint-plugin.js | 309 +++++ scripts/test-policy/migration-inventory.js | 176 +++ scripts/test-selection/graph.mjs | 283 ++++ scripts/test-selection/node-runner.mjs | 30 + scripts/test-selection/run.mjs | 139 ++ .../core/continuous-ladder.js | 2 +- src/binance-orderbook-trade/core/quantity.js | 10 +- src/binance-orderbook-trade/index.user.js | 2 +- test/unit/binance-fixture-contract.test.js | 198 +++ .../binance-orderbook-trade/cancel.test.js | 842 +++++++----- .../chart-save-coalescer.test.js | 942 ++++++++++--- .../close-action.test.js | 195 +-- .../close-ladder-recovery.test.js | 204 ++- .../continuous-ladder.test.js | 432 ++++-- .../order-feedback.test.js | 711 +++++++--- .../binance-orderbook-trade/quantity.test.js | 143 +- .../binance-orderbook-trade/smoke.test.js | 6 - .../source-regressions.test.js | 7 - test/unit/coverage-capture.test.js | 81 ++ test/unit/coverage-gates.test.js | 97 ++ test/unit/coverage-report.test.js | 88 ++ test/unit/coverage-split.test.js | 146 ++ test/unit/test-policy.test.js | 414 ++++++ test/unit/test-selection.test.js | 584 ++++++++ 69 files changed, 8661 insertions(+), 1176 deletions(-) create mode 100644 .github/workflows/userscript-tests.yml create mode 100644 docs/test-coverage.md create mode 100644 docs/test-policy.md create mode 100644 docs/test-selection.md create mode 100644 e2e/binance-orderbook/helpers/scenario-clock.js create mode 100644 e2e/binance-orderbook/specs/coverage-merge.pw.js create mode 100644 eslint.config.js create mode 100644 scripts/test-coverage/branch-policy.json create mode 100644 scripts/test-coverage/browser-reporter.mjs create mode 100644 scripts/test-coverage/capture-contract.mjs create mode 100644 scripts/test-coverage/collect-browser.mjs create mode 100644 scripts/test-coverage/collect-node.mjs create mode 100644 scripts/test-coverage/config.mjs create mode 100644 scripts/test-coverage/gates.mjs create mode 100644 scripts/test-coverage/merge-proof.mjs create mode 100644 scripts/test-coverage/report.mjs create mode 100644 scripts/test-coverage/run.mjs create mode 100644 scripts/test-coverage/source-maps.mjs create mode 100644 scripts/test-coverage/split-entries.mjs create mode 100644 scripts/test-policy/ast.js create mode 100644 scripts/test-policy/eslint-plugin.js create mode 100644 scripts/test-policy/migration-inventory.js create mode 100644 scripts/test-selection/graph.mjs create mode 100644 scripts/test-selection/node-runner.mjs create mode 100644 scripts/test-selection/run.mjs create mode 100644 test/unit/binance-fixture-contract.test.js delete mode 100644 test/unit/binance-orderbook-trade/smoke.test.js create mode 100644 test/unit/coverage-capture.test.js create mode 100644 test/unit/coverage-gates.test.js create mode 100644 test/unit/coverage-report.test.js create mode 100644 test/unit/coverage-split.test.js create mode 100644 test/unit/test-policy.test.js create mode 100644 test/unit/test-selection.test.js diff --git a/.github/workflows/binance-orderbook-ui.yml b/.github/workflows/binance-orderbook-ui.yml index 1b61026..593d1a0 100644 --- a/.github/workflows/binance-orderbook-ui.yml +++ b/.github/workflows/binance-orderbook-ui.yml @@ -6,6 +6,7 @@ on: - ".github/workflows/binance-orderbook-ui.yml" - "package.json" - "package-lock.json" + - ".nvmrc" - "playwright.config.js" - "src/binance-orderbook-trade/**" - "src/binance-strategy29-bollinger/**" @@ -29,6 +30,7 @@ on: - ".github/workflows/binance-orderbook-ui.yml" - "package.json" - "package-lock.json" + - ".nvmrc" - "playwright.config.js" - "src/binance-orderbook-trade/**" - "src/binance-strategy29-bollinger/**" @@ -57,7 +59,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 22 + node-version-file: .nvmrc cache: npm - run: npm ci - run: npx playwright install --with-deps chromium diff --git a/.github/workflows/binance-strategy27-events.yml b/.github/workflows/binance-strategy27-events.yml index 5cb75f7..2f05a63 100644 --- a/.github/workflows/binance-strategy27-events.yml +++ b/.github/workflows/binance-strategy27-events.yml @@ -6,6 +6,7 @@ on: - ".github/workflows/binance-strategy27-events.yml" - "package.json" - "package-lock.json" + - ".nvmrc" - "scripts/build-userscript.mjs" - "scripts/userscript-release-contract.mjs" - "src/binance-strategy27-events/**" @@ -23,6 +24,7 @@ on: - ".github/workflows/binance-strategy27-events.yml" - "package.json" - "package-lock.json" + - ".nvmrc" - "scripts/build-userscript.mjs" - "scripts/userscript-release-contract.mjs" - "src/binance-strategy27-events/**" @@ -45,7 +47,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 22 + node-version-file: .nvmrc cache: npm - run: npm ci - run: npm run test:binance-strategy27-events @@ -53,4 +55,3 @@ jobs: - run: git diff --exit-code -- scripts/binance-strategy27-events.user.js - run: npm run check:binance-userscripts - run: node scripts/userscript-release-contract.mjs scripts/binance-strategy27-events.user.js - diff --git a/.github/workflows/userscript-tests.yml b/.github/workflows/userscript-tests.yml new file mode 100644 index 0000000..4460633 --- /dev/null +++ b/.github/workflows/userscript-tests.yml @@ -0,0 +1,78 @@ +name: Userscript Tests + +on: + pull_request: + push: + branches: [main] + schedule: + - cron: "0 20 * * 0" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: userscript-tests-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + tests: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: npm run lint:tests + - run: npm run build:userscripts + - run: git diff --exit-code -- 'scripts/*.user.js' + - run: npm run check:binance-userscripts + - run: npm run check:m3u8-downloader + - name: Select affected tests + id: selection + env: + TEST_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + TEST_FULL: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} + run: | + mkdir -p test-results + if [ "$TEST_FULL" = "true" ]; then + node scripts/test-selection/run.mjs --full --list > test-results/selection.json + else + node scripts/test-selection/run.mjs --base "$TEST_BASE_SHA" --list > test-results/selection.json + fi + node --input-type=module <<'JS' + import { appendFileSync, readFileSync } from 'node:fs'; + const plan = JSON.parse(readFileSync('test-results/selection.json', 'utf8')); + appendFileSync(process.env.GITHUB_OUTPUT, 'browser=' + (plan.browserTests.length > 0) + '\n'); + process.stdout.write(JSON.stringify(plan, null, 2) + '\n'); + JS + - name: Install browser for selected scenarios + if: steps.selection.outputs.browser == 'true' + run: npx playwright install --with-deps chromium + - name: Run affected tests + if: github.event_name != 'schedule' && github.event_name != 'workflow_dispatch' + env: + TEST_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + run: npm run test:affected -- --base "$TEST_BASE_SHA" + - name: Run complete source coverage + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + run: npm run test:coverage + - name: Upload test selection and reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: userscript-test-evidence + path: | + test-results/selection.json + playwright-report/ + test-results/coverage/latest.json + test-results/coverage/run-*/report/ + test-results/coverage/run-*/node-results.txt + if-no-files-found: ignore + retention-days: 14 diff --git a/AGENTS.md b/AGENTS.md index 2217071..b80d800 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ | Strategy 29 observer or cross-script chart coordination | docs/binance-strategy29-bollinger-development.md | | Brooks/m3u8 indexing, export state, timing, or captions | docs/brooks-media-sync-workflow.md | | Trading-data, CoinMarketCap-data, auto-refresh, or cross-script validation | docs/userscript-validation.md | +| Behavioral tests, test lint, affected selection, or source coverage | docs/test-policy.md | | Read-only review | skills/userscript-review/SKILL.md | | Release or publish | skills/userscript-release/SKILL.md | | Codex/browser/proxy/helper/connection timeout | global timeout rule; if available, ~/.dotfiles/knowledge/shared/CODEX_TOOL_TIMEOUT_TRIAGE.md | @@ -78,6 +79,9 @@ ## Validation +- Changed tests must pass `npm run lint:tests`. The behavioral test policy and + explicit migration inventory are owned by `docs/test-policy.md`; affected + selection and coverage commands do not replace required builds or live checks. - Run the affected tests, build, syntax/check commands, and git diff --check for behavior changes. The release skill owns stage-specific validation and reuse of passing local checks for unchanged inputs. diff --git a/docs/binance-orderbook-trade-development.md b/docs/binance-orderbook-trade-development.md index add000d..8ce2e7b 100644 --- a/docs/binance-orderbook-trade-development.md +++ b/docs/binance-orderbook-trade-development.md @@ -56,6 +56,11 @@ Include affected shared-contract and integration checks when the change crosses script boundaries. Full-suite release validation and reuse of passing local results are defined in `skills/userscript-release/SKILL.md`. +Test work also follows [Behavioral Test Policy](test-policy.md). Prefer executable +entry-to-outcome scenarios over source-text assertions for runtime behavior; +retain metadata, generated-artifact, and module-boundary contracts. Test changes +must pass `npm run lint:tests` in addition to the affected behavior checks. + ## Layout ```text @@ -283,6 +288,11 @@ Continuous ladder trading is available only for close actions through `Option/Al 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 +round has `lastRound: null`. Its status shows zero rounds and zero confirmed +submissions, with no current-plan segment. Formatting this valid initial state +must not fail or invent a round. + The active continuous-close control keeps a compact direction-specific stop action (`停止平多` / `停止平空`) on one line throughout both execution and inter-round waiting. Before the native submit control is ready, the status places `等待按钮恢复` immediately after the continuous action name. Only after the fixed cooldown actually begins may it show `1s 后继续` in that same priority position; this is a static duration label, not a countdown. `停止中`, `已停止`, `失败`, and `已中止` use the same phase slot. The button must not temporarily revert to a ladder-start action between rounds. When a continuous close receives the observed Binance private-page response `code=90802025`, `success=false`, and the max-open-orders message, classify it as a confirmed no-submit capacity rejection. Load the complete lazy current-orders list before ranking rows; the first 50 rendered rows are not a complete candidate set. Release up to 50 current-symbol, same-close-direction Basic limit orders farthest from the current trade price, preserving the nearest orders that are most likely to complete the close. Resume the same unsubmitted ladder level so already confirmed levels are not duplicated. Permit this recovery once per round; a second confirmed capacity rejection ends the current round and lets continuous mode resume after its recovery delay. Do not use Cancel All, and do not touch open-direction, opposite-direction, conditional, or protection orders. If a selected row disappears because it fills while recovery is running, treat that slot as already released but count a cancellation only after the script confirms its own row-cancel action. diff --git a/docs/binance-orderbook-trade-ui-automation.md b/docs/binance-orderbook-trade-ui-automation.md index 1625d5e..621df9b 100644 --- a/docs/binance-orderbook-trade-ui-automation.md +++ b/docs/binance-orderbook-trade-ui-automation.md @@ -34,6 +34,27 @@ L0 and L1 remain fast PR gates. L2 owns the full scenario matrix. L3 and L4 are smaller integration and release gates because extension state, network timing, and market state are not deterministic enough for the full matrix. +Scenario naming, Given/When/Then structure, assertions, and permitted test +boundaries follow [Behavioral Test Policy](test-policy.md). L2 still executes the +generated install artifact. Its reviewed fake is tested separately in +`test/unit/binance-fixture-contract.test.js`: wrong symbol filtering or the wrong +Basic/conditional tab must produce the corresponding wrong cancellation scope, +so the fake cannot silently repair an unsafe caller. Native confirmation and +delayed clearing retain the initiating scope snapshot. + +Use `helpers/scenario-clock.js` for business deadlines, cooldowns, and negative +proofs such as no new submission after Stop. A pending request stays pending +until `releaseSubmitResponse()` explicitly delivers success or rejection. Keep +performance budgets on real `performance` time; advancing a virtual clock is +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. + +`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) +for report completeness and shared-source merge checks. + ## Scenario Model Scenarios are data, not copied test procedures. Each scenario declares these axes: diff --git a/docs/test-coverage.md b/docs/test-coverage.md new file mode 100644 index 0000000..ec02204 --- /dev/null +++ b/docs/test-coverage.md @@ -0,0 +1,128 @@ +# Production Source Coverage + +Coverage measures all JavaScript under `src/` and the two hand-maintained install +scripts, `scripts/auto_refresh.user.js` and +`scripts/coinmarketcap-valuation-helper.user.js`. Generated install artifacts are +mapped back to their original sources, so they do not create another denominator. +Unexecuted source files remain in the report with zero execution credit. Test +files, fixtures, build tooling, and historical installer snapshots are not +production source. The executable scope lives in +[`scripts/test-coverage/config.mjs`](../scripts/test-coverage/config.mjs). + +Use `.nvmrc` and install Chromium before collecting the browser layer: + +```sh +nvm use +npm ci +npx playwright install chromium +npm run test:coverage +``` + +`npm run test:coverage:node` collects only Node unit and JSDOM execution, while +retaining the same complete-source denominator. Its result is labeled Node-only; +it cannot satisfy the merged-coverage gate. Neither command starts a development +server or operates a logged-in browser. + +## Accurate Mapping and Completion + +The coverage compiler reads complete source entries, including userscript +metadata, to preserve original line positions. It requires generated executable +bytes to match the public install artifacts and checks every embedded original +against the current source file. A stale artifact fails collection instead of +producing a report for different code. + +The selected Node files are passed to `node:test` through `run({ files })`, not +the CLI's glob expansion. Coverage imports are installed in those actual test +processes. Selected filenames remain literal, including glob metacharacters, +spaces, and newlines. + +Node's inspector records the actual executed script source as well as precise +V8 ranges. Complete anonymous VM sources can be identified by exact content. +Partial functions extracted from a source file and quoted copies of installer +text receive no credit for executing that source file. Browser collection also +recognizes complete original modules loaded through Blob URLs. + +One browser script can contain several installers. The collector validates exact +installer segments against JavaScript statement boundaries and keeps their V8 +execution ranges associated with the original bytes. Shared originals appear +once in the final report. An isolated Node-and-Chromium proof checks known branch +counts so repeated bundled copies cannot hide a branch executed by another copy. + +Every selected Node test process must finish its capture. Every production +browser scenario must pass and finish its capture, with IDs matched against the +current Playwright run. Missing, stale, skipped, or retried browser evidence +invalidates completeness. The one explicit collector self-test file uses virtual +code; it must pass but does not contribute production coverage. Source-map and +capture tests validate these boundaries independently. + +## Target and Staged Gate + +The final repository target is **90% branch coverage across the complete scope**. +The rollout also uses an explicit **66.5% aggregate floor** and requires each +migrated critical module to reach 90%. The floor rounds the initial 66.58% +measurement down to one decimal place. The checked-in threshold policy names +those files; it does not exclude other production sources from the aggregate. + +The staged gate and final target are separate facts. A run may pass the staged +gate while still reporting `meetsBranchTarget: false`. It must not be described as +reaching the repository target. Threshold decisions use exact covered/total +counts, not rounded display percentages. New failures, recovery paths, and +boundary conditions should close the remaining gap; do not shrink the source +scope, remove a guard, or invent invalid business states to improve the metric. + +`npm run test:coverage -- --require-target` also requires the final aggregate 90% +target. `npm run test:coverage -- --report-only` collects diagnostic evidence +without applying thresholds; CI uses the default gated command. The aggregate +floor and exact critical-source list are stored in +[`branch-policy.json`](../scripts/test-coverage/branch-policy.json). + +The policy applies to merged Node and browser results. The complete pipeline runs +weekly and through manual workflow dispatch in `.github/workflows/userscript-tests.yml`. +PRs and main pushes run test lint, generated-artifact checks, and the +[affected test selection](test-selection.md). The existing script-specific +workflows retain their independent checks. + +## Reports and Interpretation + +The complete baseline on 2026-09-16 used Node 24.16.0, 97 Node test files +(1,326 passing tests), and 92 passing Chromium scenarios. The browser total +contains 87 production scenarios and five collector proofs. All required +captures completed. The merged denominator contains 82 distinct production +source files and **6,765 / 10,160 covered branches (66.58%)**. +The six unmapped VM entries are executions of the three historical installers in +`test/fixtures/strategy29-migration/`, each loaded twice by the settings migration +tests. They remain visible in the report and receive no current-source credit. + +| Migrated critical source | Covered / total branches | Coverage | +| --- | ---: | ---: | +| `core/cancel-orders.js` | 74 / 74 | 100% | +| `core/close-action.js` | 39 / 42 | 92.86% | +| `core/close-ladder-recovery.js` | 39 / 39 | 100% | +| `core/continuous-ladder.js` | 108 / 119 | 90.76% | +| `core/order-feedback.js` | 194 / 204 | 95.10% | +| `core/quantity.js` | 26 / 27 | 96.30% | +| `core/chart-save-coalescer.js` | 247 / 273 | 90.48% | + +These paths are under `src/binance-orderbook-trade/`. The baseline passes the +staged policy and fails the final aggregate 90% target. This is a dated +measurement, not a promise about later revisions; current reports record source +hashes so their scope can be verified. + +Each collection creates `test-results/coverage/run-*/` with the raw captures, +Node test output, and a `report/` directory. `test-results/coverage/latest.json` +points to the last completed report; check its run path and source identity rather +than assuming an older report covers current edits. + +The HTML entry is `report/index.html`. `report/coverage-summary.json` records: + +- Node version, executed layers, source list, and original source identities; +- exact branch, statement, function, line, and byte metrics; +- the final target and whether it was met; +- capture completeness counts and any executed entries that could not be mapped. + +An unmapped historical installer or extracted snippet is visible as unmapped +evidence and is not substituted for current-source execution. Tests passing, +capture completion, meeting a staged threshold, and meeting the final 90% target +are distinct outcomes. Browser fixture coverage is L2 evidence; Tampermonkey +installation and current Binance behavior still require their own authorized +L3/L4 checks. diff --git a/docs/test-policy.md b/docs/test-policy.md new file mode 100644 index 0000000..ff33e8b --- /dev/null +++ b/docs/test-policy.md @@ -0,0 +1,208 @@ +# Behavioral Test Policy + +The repository keeps Node's `node:test` runner, JSDOM, and Playwright. Tests should +describe an observable outcome, execute the real behavior being checked, and fail +when that outcome changes. A new runner or a large mocking framework is not +required for this policy. + +Use the Node version pinned by `.nvmrc`. The relevant commands are: + +| Command | Evidence | +| --- | --- | +| `npm run lint:tests` | Test policy checks across `test/` and `e2e/`; zero warnings are allowed. | +| `npm run test:test-policy` | The ESLint rules accept valid programs and reject concrete invalid programs. | +| `npm test` | Existing Node unit and JSDOM integration tests. | +| `npm run test:ui` | Offline Playwright scenarios using the generated userscripts and controlled host fixtures. | +| `npm run test:affected` | The repository's affected-test selector; consult its selection output before interpreting the result. | +| `npm run test:coverage:node` | Production-source coverage from the Node layer. | +| `npm run test:coverage` | Complete Node and browser source coverage, with the staged aggregate and critical-module gates. | + +The specialized validation paths, builds, and any required live checks remain in +[Userscript Validation](userscript-validation.md) and the linked script manuals. +Browser fixture tests establish the controlled host contract. They do not prove +the current live Binance DOM or grant permission for financial actions. + +## Behavior Names and Stages + +New test files and all `e2e/**/specs/**/*.pw.js` scenarios use a title beginning with +`user `. Describe the observable behavior in the rest of the title. A +parameterized title such as `` `user sees ${quantity} accepted orders` `` keeps a +static `user ` prefix. + +Every test has concrete, ordered Given, When, and Then stages. Prefer awaited +`test.step` calls in Playwright when their scope fits the scenario: + +```js +test('user sees confirmation after the order response is accepted', async ({ page }) => { + await test.step('Given the order panel has a valid quantity', async () => { + await openOrderPanel(page, { quantity: 2 }); + }); + await test.step('When the user submits the order', async () => { + await page.getByRole('button', { name: 'Submit' }).click(); + }); + await test.step('Then the accepted quantity is shown in the status', async () => { + await expect(page.getByRole('status')).toHaveText('Submitted 2'); + }); +}); +``` + +Node tests, and browser scenarios that share local state across stages, may use +comments directly around executable statements: + +```js +test('user keeps existing orders when cancellation is declined', async () => { + // Given the user has current-symbol and unrelated orders + const exchange = createExchangeFixture({ orders: initialOrders }); + + // When the user declines the native cancellation confirmation + await exchange.requestCancellation({ confirmation: 'decline' }); + + // Then every original order remains present + assert.deepEqual(exchange.orders(), initialOrders); +}); +``` + +Each stage needs its own executable setup, action, or assertion and a description +of at least two words. Bare keywords, generic placeholders, three adjacent empty +comments, labels inside strings, and labels inside unused functions do not +satisfy the rule. Put stage boundaries outside conditional branches and loops; +the behavior must have phases for every invocation. Setup and cleanup may use +`try`/`finally` blocks. + +A completed Then stage may be followed by another When/Then pair. A new +Given/When/Then sequence is also allowed after a completed Then. Assertions about +preconditions may appear during Given; assertions are not mechanically restricted +to Then. `test.step` calls must be awaited or returned to preserve execution +order. Inline callbacks keep the stage contract locally inspectable. + +## Boundaries, Clocks, and Assertions + +Prefer real pure functions and real DOM adapters. When a test needs an external +boundary, give its fake explicit state and operations and test that fake's +contract independently. The browser host fixture has contract checks in +`test/unit/binance-fixture-contract.test.js`; changes to its supported DOM, +request/response, event, or cancellation behavior require corresponding checks. +Do not replace a business method just to force the branch under test. + +New uses of `mock.method` and `mock.fn` are rejected, including ordinary imported, +destructured, computed-property, and bound aliases. `mock.timers` remains the +supported Node clock. For browser business timing, install and advance +Playwright's page clock. Negative timing assertions should check the state before +the deadline and after the explicit clock advance. A response gate can preserve +a pending request until the scenario deliberately releases it. + +Do not wait a fixed number of real milliseconds before an assertion. The rule +rejects `waitForTimeout`, Promise-resolving `setTimeout` calls, promise-timer +imports, and calls named `sleep` or `delay`. Use a response gate, observable DOM +state, event completion, or virtual-clock advance. Timeouts that bound an +operation, reject a deadline, or configure the runner are valid. Host-fixture +timers that emit modeled upstream events are also valid; scenarios should control +them through the page clock when checking timing. + +Real performance measurements must observe real browser execution. The one +approved performance task boundary is listed separately below. A virtual-clock +result must not be reported as a measured long-task duration or throughput. + +The policy rejects focused, skipped, pending, or deferred cases (`only`, `skip`, +`todo`, and `fixme`), including Node test options and imperative context skips. +Unrelated data fields named `only` remain valid. No existing skipped-test +exception is recorded. + +Empty callbacks and tests that only compare constants or a value with itself +are rejected. Useful metadata, generated-artifact, and architecture assertions +such as `assert.match(source, /@downloadURL/)` remain valid. Runtime behavior +should be checked through results and effects instead of merely searching for +its implementation text. + +## Staged Migration and Exact Allowances + +The first strict Node behavior group covers these orderbook suites: + +- `cancel`, `close-action`, and `close-ladder-recovery`; +- `continuous-ladder`, `order-feedback`, and `quantity`; +- `chart-save-coalescer`. + +The new policy, fixture-contract, coverage-report, and test-selection suites are +also strict. Every new `*.test.js` file is strict by default. All browser spec +files are strict. + +The remaining existing Node suites are individually listed, with migration +reasons, in +[`scripts/test-policy/migration-inventory.js`](../scripts/test-policy/migration-inventory.js). +Only their BDD organization is deferred. Focus/skip, empty-test, new mock, and new +fixed-wait rules still apply. This is explicit migration debt, not proof that the +legacy suites already satisfy the behavior policy. Add new scenarios in strict +files or migrate the whole existing file and remove its inventory entry. + +The two `source-regressions.test.js` files contain both useful source contracts +and behavioral checks awaiting migration. Neither file is classified wholesale +as an architectural test. Preserve the useful contracts while migrating the +behavioral assertions to executable scenarios. + +The remaining method replacements have exact file/target/count allowances: + +| File | Retained calls | Remaining work | +| --- | --- | --- | +| `test/dom/binance-trading-data-footer.test.js` | `Date.now`: 1 | Move the extracted footer's elapsed-time harness to a deterministic clock. | +| `test/dom/binance-strategy27-events/compound-candidate-controller.test.js` | `crypto.subtle.digest`: 1 | Move the lifecycle hash pause into a contract-tested crypto boundary. | +| `test/dom/binance-strategy27-events/strategy27-entrypoint.test.js` | `Date.now`, page `setInterval`/`clearInterval`, `querySelectorAll`, and `prompt`: 1 each | Replace the clock overrides and move query/prompt instrumentation into explicit fixture contracts. | + +The remaining fixed waits are likewise bounded: + +| File | Retained calls | Remaining work | +| --- | --- | --- | +| `test/unit/m3u8-downloader-course-export.test.js` | `setTimeout(20)`: 19; `650`: 1; `1100`: 1 | Introduce export/download completion signals and a virtual runtime clock. | +| `test/dom/binance-strategy29-bollinger/runtime.test.js` | `f.view.setTimeout(0)`: 5 | Expose remote-request and DOM-render completion signals. | +| `test/unit/binance-orderbook-trade/trade-form.test.js` | `dom.window.setTimeout(0)`: 3 | Await the observed request or mutation completion. | +| `test/unit/binance-orderbook-trade/cancel-all-dialog.test.js` | `dom.window.setTimeout(0)`: 1 | Observe delivery of unrelated mutations for the negative case. | +| `test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js` | `setTimeout(0)`: 1 | Preserve the actual render-task-yield contract through a controlled scheduling boundary. | + +Adding another occurrence or a different target fails lint. Removing an old +occurrence also fails until its allowance is reduced or removed. This makes the +inventory shrink as work is migrated. The complete reasons and counts are stored +in the executable inventory rather than a directory-wide ESLint disable. + +One separate host contract allows exactly one `window.setTimeout(0)` inside +`finishAfterPerformanceTail` in +`e2e/binance-orderbook/helpers/live-performance-probe.js`. PerformanceObserver +entries arrive after the observed host task, so this boundary collects the real +performance tail before tearing down observers. It is not a business wait or a +measurement made with a virtual clock. The rule tests verify that a second call +inside that method or a new wait elsewhere in the same file is rejected. + +Inline `eslint-disable` directives cannot turn off the policy. Historical +installation artifacts under `test/fixtures/` are excluded from lint because +they are test input source snapshots, not executable test definitions. + +## Coverage Target and Evidence Limits + +The repository target is **90% branch coverage**. The coverage collector reports +the complete production-source denominator, including unexecuted files, and +maps generated artifacts back to their source. The denominator and target live +in `scripts/test-coverage/config.mjs`. + +The default complete run enforces a **66.5% aggregate floor** and **90% for each +of the seven migrated critical modules**. The initial complete baseline is +66.58% across 82 production files; the floor rounds that measured value down to +one decimal place. This preserves an explicit starting gate while the remaining +behavioral coverage is migrated. The executable threshold and file list live in +[`branch-policy.json`](../scripts/test-coverage/branch-policy.json). + +A successful staged run does not prove that the repository's final 90% target +was met. Inspect `gate`, `meetsBranchTarget`, `summary.branches.pct`, and the +recorded layers in `coverage-summary.json`. Use +`npm run test:coverage -- --require-target` to require the final aggregate target; +`--report-only` collects diagnostic evidence without enforcing thresholds. +Node-only results must remain labeled Node-only and cannot satisfy the merged +gate. See [Source Coverage](test-coverage.md) for the measured baseline and +complete-source contract. + +The enforced test policy, the explicit legacy migration inventory, and the +coverage target are different facts. Report each separately. Do not shrink the +coverage denominator or mark a legacy suite migrated merely to improve a number. + +Static lint catches the documented syntax and ordinary aliases; it does not +prove that assertions express the correct domain contract or that arbitrary +dynamically generated JavaScript executes the intended phases. Review scenarios +and run them. A useful behavior test must fail when the behavior it protects is +removed or changed. diff --git a/docs/test-selection.md b/docs/test-selection.md new file mode 100644 index 0000000..208f262 --- /dev/null +++ b/docs/test-selection.md @@ -0,0 +1,164 @@ +# Test selection + +The affected-test runner selects complete Node test files and Playwright specs from +the current repository inventory. It combines verified dependency edges with every +consumer whose runtime dependencies cannot be proved. Unknown readers remain in +the plan even when another test is already known to consume a changed file. + +## Commands and scope + +| Scope | Command | Purpose | +| --- | --- | --- | +| Test policy | `npm run lint:tests` | Enforce the executable test-policy rules independently of test selection. | +| Affected behaviors | `npm run test:affected` | Run affected files and all uncertain consumers after local changes. | +| Complete behaviors | `npm run test:affected -- --full` | Run every discovered Node test and Playwright spec. | +| Complete source coverage | `npm run test:coverage` | Collect the full Node and Chromium coverage run and enforce its coverage policy. | + +Use the Node version in `.nvmrc`, for example with `nvm use`, before invoking these +commands. The selector checks the exact running Node version and uses +`process.execPath` for child processes. Lint is a separate command; selection never +silently runs or substitutes for test lint. L0/L1/L2 retain their runtime meanings +from the UI automation manual: Node core, JSDOM, and Playwright respectively. +See [test policy](test-policy.md) and +[coverage](test-coverage.md) for their respective contracts. + +## Commands and change discovery + +Run the commands from the repository root: + +```sh +npm run test:affected +npm run test:affected -- --base origin/main +npm run test:affected -- --full +node scripts/test-selection/run.mjs --list +npm run --silent test:affected -- --base origin/main --list +``` + +Without `--base`, the selector compares the working tree with `HEAD`. It resolves +the base to an available commit, collects `git diff --name-only --no-renames -z` +against that commit, and includes untracked paths from Git. The inventory also +includes untracked tests and excludes tracked files that no longer exist. +NUL-delimited Git output preserves spaces and newlines in filenames. A rename +retains both the old deletion and the new addition in the changed-path set. + +`--base REF` compares against the supplied commit rather than assuming a CI +event's previous SHA exists locally. Missing arguments, all-zero bases, +unavailable commits, an unborn `HEAD`, and unknown or repeated options fail +explicitly. `--full` still requires valid repository state and a resolvable base. +Neither an invalid base nor failed discovery becomes an empty successful plan. + +`--list` prints only the JSON plan and does not run tests. Use npm's `--silent` +option when parsing npm output, since npm otherwise adds its own banner. Store +captured plans under the ignored `test-results/` directory so the output does not +become a new unmapped input to the next selection. + +The JSON interface is versioned: + +```json +{ + "schemaVersion": 1, + "mode": "affected", + "reasons": ["Selected complete test files through dependency edges and all unresolved consumers"], + "changedFiles": ["src/example.js"], + "nodeTests": ["test/unit/example.test.js"], + "browserTests": [] +} +``` + +`mode` is `none`, `affected`, or `full`. Path arrays contain sorted, +repository-relative filenames. `reasons` explains selection and any uncertainty; +consumers should use `mode` and the path arrays rather than parse diagnostic text. + +An actual run prints a plan summary, runs the selected Node files first, and then +runs the selected Playwright specs. A failed runner stops the sequence. Commands +use argument arrays without a shell. Node uses the programmatic `run({ files })` +interface because its CLI interprets positional filenames as glob patterns. +Playwright's filename filters are escaped and suffix-anchored because its CLI +interprets those filters as regular expressions. Selection is by whole file, +never by individual test title. + +## Dependency evidence + +The test roots are: + +- `test/unit/**/*.test.js` +- `test/dom/**/*.test.js` +- `e2e/**/specs/**/*.pw.js`, including specs nested below the specs directory + +The Playwright configuration must expose a static `testDir` under +`e2e/.../specs` and the recursive `testMatch: '**/*.pw.js'` contract. The parser +supports the exported object directly or its `defineConfig(...)` wrapper. + +Acorn parses local static imports, exports, literal dynamic imports, static +`new URL(..., import.meta.url)` expressions, and literal paths that match inventory +files. URL references follow URL encoding rules, including encoded spaces and +newlines. Literal URL strings, templates without substitutions, and literal string +concatenation can be resolved without runtime value analysis. + +Generated userscripts inherit the `entry` declared for their `output` in the +exported `TARGETS` object in `scripts/build-userscript.mjs`. Dependencies then +continue through the source entry and its shared modules. The selector does not +execute the builder or hand-maintain a second artifact-to-source mapping. +Unsupported, unsafe, duplicate, or unavailable build mappings prevent a partial +plan from claiming that the artifact graph is complete. + +Snapshot ownership follows the verified Playwright template: + +```js +snapshotPathTemplate: '{testDir}/{testFilePath}-snapshots/{arg}{ext}' +``` + +For example, `specs/nested/panel.pw.js-snapshots/panel.json` belongs to +`specs/nested/panel.pw.js`. This edge does not require a literal snapshot filename +inside the spec. Other snapshot layouts are not assumed to have equivalent +ownership rules. + +## Uncertainty and full runs + +A module that imports `fs`, `fs/promises`, `child_process`, or `module`, with or +without the `node:` prefix, always has uncertain runtime dependencies. This also +applies to literal dynamic imports of those modules and CommonJS loaders. Static +paths in such a module still add useful edges, but never remove its uncertainty. +This intentionally retains readers accessed through aliases, destructuring, +shadowed parameters, `createRequire`, subprocesses, and directory scans. A module +that only writes files can therefore select more tests than strictly necessary. + +Nonliteral dynamic imports, runtime URL expressions, unsupported executable +dependencies, missing referenced files, and opaque URL module imports also retain +their complete test consumers. Finite-looking arrays are not treated as immutable +path domains: mutation, aliasing, and function calls can change the values used by +a template. If a template-derived resource has no independently verified edge, +changing it still requires a full run. + +Uncertainty records carry explicit `file`, `reason`, and `scope` fields internally. +Consumer-scoped records propagate through reverse dependencies to every owning +test root. Every nonempty change set, including documentation changes, selects +those complete files. Independently verified affected consumers are then added. +Tests outside both sets can be omitted. + +A full run is selected when: + +- `--full` is requested. +- Package manifests or lockfiles, `.nvmrc`, workflows, Playwright configuration, + ESLint configuration, the userscript builder, or `scripts/test-*` infrastructure + changes. +- A changed path was deleted, is unavailable, or has no verified consumer and is + not an eligible documentation file. Unmapped JSON, HTML, images, snapshots, and + source files all remain runtime changes. +- Build mapping, Playwright discovery, or snapshot ownership cannot be verified + globally, or an uncertain dependency cannot be assigned to a test owner. + +A failed file read or Git operation is an explicit command failure rather than a +successful empty result. Protected configuration and credential paths are never +read by graph construction; their presence as changed filenames can still require +a full run. + +`none` is allowed for a valid graph with no changes, or for documentation without +known or uncertain runtime consumers. Eligible documentation is Markdown under +`docs/` and the documented root Markdown names such as `README.md` and `AGENTS.md`. +A Markdown extension alone is not enough to skip a runtime reader. + +The selector does not generate userscripts, install them, activate live browser +sessions, or authorize financial actions. Its browser execution is the project's +configured Playwright test suite; manual or live validation remains a separate +project requirement. diff --git a/docs/userscript-validation.md b/docs/userscript-validation.md index e7a974f..afd30fa 100644 --- a/docs/userscript-validation.md +++ b/docs/userscript-validation.md @@ -11,6 +11,12 @@ are owned by `skills/userscript-release/SKILL.md`. The eight-script performance audit, operation-count baselines, and reproduction commands are recorded in `docs/userscript-performance-review.md`. +The behavioral test rules, reviewed fake contracts, virtual-time boundaries, +and explicit migration inventory are owned by [Behavioral Test Policy](test-policy.md). +Use [Affected Test Selection](test-selection.md) for dependency-based local and CI +selection, and [Source Coverage](test-coverage.md) for the complete-source coverage +scope, threshold policy, and report interpretation. + ## Script Matrix | Script | Editable source | Artifact | Focused checks | Detailed guide | @@ -34,6 +40,13 @@ The default suite runs all `test/unit/**/*.test.js` and `test/dom/**/*.test.js` files. Scripts under `test/manual/` are explicit manual probes and previews; they are not automatically executed by the test runner. +Test changes also run `npm run lint:tests`. `npm run test:affected -- --list` +explains the selected Node and browser files before execution. The scheduled +`Userscript Tests` workflow runs the complete Node and browser coverage pipeline; +ordinary PR and main-push runs select tests using an explicit Git base. Existing +script-specific workflows retain their independent checks. All three workflows +read the Node version from `.nvmrc`. + ## Shared Contracts - A migrated script is edited under `src/`; its generated artifact is rebuilt diff --git a/e2e/binance-orderbook/fixtures/binance-futures.js b/e2e/binance-orderbook/fixtures/binance-futures.js index 2ef2336..d076163 100644 --- a/e2e/binance-orderbook/fixtures/binance-futures.js +++ b/e2e/binance-orderbook/fixtures/binance-futures.js @@ -132,7 +132,10 @@ export function renderBinanceFuturesFixture(scenario) { ...detail, }); const currentOrders = () => state.orders.filter((item) => item.symbol === scenario.currentSymbol); - const visibleOrders = () => state.hideOtherSymbols ? currentOrders() : state.orders; + const visibleOrders = () => state.orders.filter((item) => ( + item.kind === state.openOrdersSubTab + && (!state.hideOtherSymbols || item.symbol === scenario.currentSymbol) + )); const selected = (value, expected) => String(value === expected); const scheduleCommit = (callback) => setTimeout(callback, scenario.host.mutationDelayMs); let orderSubmitSequence = 0; @@ -211,23 +214,29 @@ export function renderBinanceFuturesFixture(scenario) { price: orderEntry.querySelector('input[id^="limitPrice-"]')?.value || '', quantity: orderEntry.querySelector('input[id^="unitAmount-"]')?.value || '', }); - window.fetch('/bapi/futures/v1/private/future/order/place-order', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ submitSequence }), - }).then(() => record('order-submit-api-success', { submitSequence })); - const showFeedback = () => { + const showFeedback = (outcome) => { const feedback = document.createElement('div'); feedback.setAttribute('role', 'alert'); - feedback.textContent = '订单已提交成功'; + feedback.textContent = outcome === 'success' ? '订单已提交成功' : '订单提交失败'; document.body.append(feedback); - record('order-submit-feedback', { action: button.textContent.trim(), submitSequence }); + record('order-submit-feedback', { action: button.textContent.trim(), submitSequence, outcome }); }; - if (scenario.host.submitFeedbackDelayMs > 0) { - setTimeout(showFeedback, scenario.host.submitFeedbackDelayMs); - } else { - showFeedback(); - } + window.fetch('/bapi/futures/v1/private/future/order/place-order', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ submitSequence }), + }).then(async (response) => { + const payload = await response.json(); + const outcome = response.ok && payload.success === true ? 'success' + : payload.success === false ? 'rejected' : 'unknown'; + record('order-submit-api-' + outcome, { submitSequence, code: payload.code }); + if (outcome === 'unknown') return; + if (scenario.host.submitFeedbackDelayMs > 0) { + setTimeout(() => showFeedback(outcome), scenario.host.submitFeedbackDelayMs); + } else { + showFeedback(outcome); + } + }); }); }); orderEntry.querySelectorAll('input').forEach((input) => { @@ -351,6 +360,8 @@ export function renderBinanceFuturesFixture(scenario) { function renderAccountWidget() { const positionCount = state.positions.length; const orderCount = state.orders.length; + const basicCount = state.orders.filter((order) => order.kind === 'basic').length; + const conditionalCount = state.orders.filter((order) => order.kind === 'conditional').length; accountWidget.innerHTML = '
' + '
仓位(' + positionCount + ')
' + @@ -359,12 +370,12 @@ export function renderBinanceFuturesFixture(scenario) { '
' + '
' + '
' + - '
基础单(' + orderCount + ')
' + - '
条件委托(0)
' + + '
基础单(' + basicCount + ')
' + + '
条件委托(' + conditionalCount + ')
' + '
' + '
隐藏其他合约
' + - '
' + renderOrdersRows() + '
' + - (state.openOrdersSubTab === 'basic' && visibleOrders().length ? '
全撤
' : '') + + '
' + renderOrdersRows() + '
' + + (visibleOrders().length ? '
全撤
' : '') + '
'; accountWidget.querySelectorAll('[data-account-tab]').forEach((tab) => { @@ -400,18 +411,19 @@ export function renderBinanceFuturesFixture(scenario) { if (event.key === 'Escape' && state.dialogOpen) closeDialog('escape'); } - function attachDialogHandlers(root) { + function attachDialogHandlers(root, scope) { 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 }); + record('cancel-requested', { ...scope }); setTimeout(() => { - if (scenario.host.clearMode === 'currentSymbol') { - const removedOrders = currentOrders().map((item) => ({ ...item })); - state.orders = state.orders.filter((item) => item.symbol !== scenario.currentSymbol); + if (scenario.host.clearMode === 'capturedScope') { + const removedOrders = state.orders.filter((item) => scope.orderIds.includes(item.id)); + state.orders = state.orders.filter((item) => !scope.orderIds.includes(item.id)); scheduleChartOrderRemovals(removedOrders); + record('cancel-cleared', { ...scope, orderIds: removedOrders.map((item) => item.id) }); } closeDialog('confirm'); renderAccountWidget(); @@ -425,8 +437,16 @@ export function renderBinanceFuturesFixture(scenario) { record('dialog-missing'); return; } + /** The host honors the initiating UI scope, including an unsafe unfiltered request. */ + const scope = Object.freeze({ + symbol: scenario.currentSymbol, + accountTab: state.accountTab, + openOrdersSubTab: state.openOrdersSubTab, + hideOtherSymbols: state.hideOtherSymbols, + orderIds: Object.freeze(visibleOrders().map((item) => item.id)), + }); state.dialogOpen = true; - record('dialog-opened'); + record('dialog-opened', { scope }); const root = document.createElement('div'); root.className = 'bn-modal-root'; const primaryClass = scenario.host.dialogMode === 'missingPrimary' @@ -441,13 +461,13 @@ export function renderBinanceFuturesFixture(scenario) { extraAction + ''; document.body.append(root); document.addEventListener('keydown', handleDialogKeydown); - attachDialogHandlers(root); + attachDialogHandlers(root, scope); if (scenario.host.dialogReplacementDelayMs !== null) { setTimeout(() => { if (!state.dialogOpen || !root.isConnected) return; const replacement = root.cloneNode(true); root.replaceWith(replacement); - attachDialogHandlers(replacement); + attachDialogHandlers(replacement, scope); record('dialog-replaced'); }, scenario.host.dialogReplacementDelayMs); } @@ -552,7 +572,16 @@ export function renderBinanceFuturesFixture(scenario) { window.__BINANCE_FIXTURE__ = { replacePrecisionControl, + switchSymbol(symbol) { + if (typeof symbol !== 'string' || !symbol) throw new Error('A symbol is required'); + scenario.currentSymbol = symbol; + history.pushState({}, '', '/zh-CN/futures/' + symbol); + renderTradeMode(); + renderAccountWidget(); + record('symbol-changed', { symbol }); + }, snapshot: () => JSON.parse(JSON.stringify({ + currentSymbol: scenario.currentSymbol, positions: state.positions, orders: state.orders, accountTab: state.accountTab, diff --git a/e2e/binance-orderbook/helpers/scenario-clock.js b/e2e/binance-orderbook/helpers/scenario-clock.js new file mode 100644 index 0000000..d6651f2 --- /dev/null +++ b/e2e/binance-orderbook/helpers/scenario-clock.js @@ -0,0 +1,12 @@ +const SCENARIO_EPOCH = new Date('2026-09-12T12:00:00Z'); + +/** Install before navigation so every application timer belongs to this clock. */ +export async function installScenarioClock(page) { + await page.clock.install({ time: SCENARIO_EPOCH }); +} + +/** Call only in lifecycle tests; performance-budget scenarios retain the native clock. */ +export async function pauseScenarioClock(page) { + const pageNow = await page.evaluate(() => Date.now()); + await page.clock.pauseAt(pageNow + 100); +} diff --git a/e2e/binance-orderbook/helpers/userscript-page.js b/e2e/binance-orderbook/helpers/userscript-page.js index 137bd1e..fb661e5 100644 --- a/e2e/binance-orderbook/helpers/userscript-page.js +++ b/e2e/binance-orderbook/helpers/userscript-page.js @@ -9,6 +9,16 @@ const USERSCRIPT_PATH = fileURLToPath( ); const evidenceByPage = new WeakMap(); +function submitResponseBody(response) { + if (response.outcome === 'success') return { success: true }; + if (response.outcome === 'rejected' + && typeof response.code === 'string' && response.code + && typeof response.message === 'string' && response.message) { + return { success: false, code: response.code, message: response.message }; + } + throw new Error('A released submit response must explicitly succeed or reject with a reason'); +} + function readUserscriptVersion(source) { const match = source.match(/^\/\/\s*@version\s+(\S+)/m); if (!match) throw new Error('Generated userscript is missing @version metadata'); @@ -29,6 +39,7 @@ export async function openUserscriptScenario(page, scenario, { beforeOrderbook = }; evidenceByPage.set(page, { scenario, userscript, errors }); let placeOrderRequestCount = 0; + const pendingSubmitResponses = new Map(); await page.route('https://www.binance.com/**', async (route) => { const url = new URL(route.request().url()); if (url.pathname === '/__binance_orderbook_userscript__.js') { @@ -48,17 +59,24 @@ export async function openUserscriptScenario(page, scenario, { beforeOrderbook = return; } if (url.pathname === '/bapi/futures/v1/private/future/order/place-order') { - const delayMs = scenario.host.submitApiResponseDelayMsByOrder[placeOrderRequestCount]; + let response = scenario.host.submitApiResponses[placeOrderRequestCount]; placeOrderRequestCount += 1; - if (delayMs === undefined) { - throw new Error('Fixture received more than five ladder order requests'); + if (!response) { + throw new Error('Fixture received an undeclared order request'); + } + let delivered; + if (response.delivery === 'manual') { + const release = Promise.withResolvers(); + delivered = Promise.withResolvers(); + pendingSubmitResponses.set(placeOrderRequestCount, { response, release, delivered }); + response = await release.promise; } - if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs)); await route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify({ success: true }), + body: JSON.stringify(submitResponseBody(response)), }); + delivered?.resolve(); return; } if (url.pathname === '/bapi/futures/v6/private/future/user-data/user-position') { @@ -115,7 +133,21 @@ export async function openUserscriptScenario(page, scenario, { beforeOrderbook = await page.evaluate(() => new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(resolve)); })); - return { errors, userscript }; + return { + errors, + userscript, + /** Network delivery is controlled separately from the page's simulated clock. */ + async releaseSubmitResponse(submitSequence, response) { + const pending = pendingSubmitResponses.get(submitSequence); + if (!pending) throw new Error('No pending submit response exists for this sequence'); + const settledResponse = response === undefined ? pending.response : response; + submitResponseBody(settledResponse); + pendingSubmitResponses.delete(submitSequence); + pending.release.resolve(settledResponse); + await pending.delivered.promise; + }, + pendingSubmitSequences: () => [...pendingSubmitResponses.keys()], + }; } export async function readFixtureState(page) { diff --git a/e2e/binance-orderbook/scenarios/cancel-current-symbol.js b/e2e/binance-orderbook/scenarios/cancel-current-symbol.js index 4ca2ff7..bd5db60 100644 --- a/e2e/binance-orderbook/scenarios/cancel-current-symbol.js +++ b/e2e/binance-orderbook/scenarios/cancel-current-symbol.js @@ -9,6 +9,7 @@ function order(id, symbol, side = 'SELL') { return { id, symbol, + kind: 'basic', side, price: symbol === CURRENT_SYMBOL ? '90.0' : '120000.0', quantity: '0.01', @@ -54,13 +55,15 @@ export function createCancelScenario(overrides = {}) { clearDelayMs: 0, dialogMode: 'normal', dialogReplacementDelayMs: null, - clearMode: 'currentSymbol', + clearMode: 'capturedScope', chartOrdersPopoverCloseMode: 'normal', submitFeedbackDelayMs: 0, submitButtonBusyMs: 0, submitButtonBusyAttribute: 'data-loading', submitButtonClearsInputsWhenReady: false, - submitApiResponseDelayMsByOrder: [0, 0, 0, 0, 0], + submitApiResponses: Array.from({ length: 5 }, () => ({ + outcome: 'success', delivery: 'immediate', + })), precisionOptions: ['0.001', '0.01', '0.1', '1'], ...overrides.host, }, @@ -84,10 +87,13 @@ export function createCancelScenario(overrides = {}) { if (!Array.isArray(scenario.positions) || !Array.isArray(scenario.orders)) { throw new Error('Scenario positions and orders must be arrays'); } + if (scenario.orders.some((order) => !['basic', 'conditional'].includes(order.kind))) { + throw new Error('Every scenario order must declare its basic or conditional kind'); + } 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)) { + if (!['capturedScope', 'none'].includes(scenario.host.clearMode)) { throw new Error(`Unsupported clear mode: ${scenario.host.clearMode}`); } if (!['normal', 'stuck'].includes(scenario.host.chartOrdersPopoverCloseMode)) { @@ -107,13 +113,27 @@ export function createCancelScenario(overrides = {}) { throw new Error('submitButtonClearsInputsWhenReady must be a boolean'); } if ( - !Array.isArray(scenario.host.submitApiResponseDelayMsByOrder) - || scenario.host.submitApiResponseDelayMsByOrder.length !== 5 - || scenario.host.submitApiResponseDelayMsByOrder.some( - (delayMs) => !Number.isInteger(delayMs) || delayMs < 0, - ) + !Array.isArray(scenario.host.submitApiResponses) + || scenario.host.submitApiResponses.length === 0 ) { - throw new Error('submitApiResponseDelayMsByOrder must contain five non-negative integers'); + throw new Error('submitApiResponses must declare at least one response'); + } + for (const response of scenario.host.submitApiResponses) { + if (!['success', 'rejected', 'unknown'].includes(response.outcome)) { + throw new Error('Submit response outcome must be success, rejected, or unknown'); + } + if (!['immediate', 'manual'].includes(response.delivery)) { + throw new Error('Submit response delivery must be immediate or manual'); + } + if (response.outcome === 'unknown' && response.delivery !== 'manual') { + throw new Error('An unknown submit response must remain pending until explicitly released'); + } + if (response.outcome === 'rejected' && ( + typeof response.code !== 'string' || !response.code + || typeof response.message !== 'string' || !response.message + )) { + throw new Error('A rejected submit response must declare its code and message'); + } } if ( scenario.host.dialogReplacementDelayMs !== null diff --git a/e2e/binance-orderbook/specs/cancel-covering-matrix.pw.js b/e2e/binance-orderbook/specs/cancel-covering-matrix.pw.js index 0710b79..701cbb2 100644 --- a/e2e/binance-orderbook/specs/cancel-covering-matrix.pw.js +++ b/e2e/binance-orderbook/specs/cancel-covering-matrix.pw.js @@ -40,12 +40,14 @@ async function expectRestoredState(page, scenario) { } for (const entry of CANCEL_COVERING_SCENARIOS) { - test(`${entry.id} preserves the cancel-current-symbol invariants`, async ({ page }) => { + test(`user preserves scoped cancellation and restores UI for ${entry.id}`, async ({ page }) => { + // Given the covering scenario declares positions, orders, initial UI, host timing, and a native dialog decision. const { scenario, vector } = entry; const hasCurrentOrders = currentSymbolOrders(scenario).length > 0; const { errors } = await openUserscriptScenario(page, scenario); await installInteractionProbe(page, CANCEL_BUTTON_SELECTOR); + // When the user requests cancellation and makes the declared native decision if current orders exist. await page.getByRole('button', { name: '撤单' }).click(); if (!hasCurrentOrders) { await expect(page.getByRole('button', { name: '无挂单' })).toBeVisible(); @@ -64,6 +66,7 @@ for (const entry of CANCEL_COVERING_SCENARIOS) { )).toBeVisible(); } + // Then orders, restored UI, chart saves, geometry, and real-time responsiveness satisfy the scenario invariants. await expectRestoredState(page, scenario); const probe = await finishInteractionProbe(page); assertResponsiveInteraction(expect, probe); diff --git a/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js b/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js index 53c2563..bffde24 100644 --- a/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js +++ b/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js @@ -10,6 +10,7 @@ import { openUserscriptScenario, readFixtureState, } from '../helpers/userscript-page.js'; +import { installScenarioClock, pauseScenarioClock } from '../helpers/scenario-clock.js'; import { assertResponsiveInteraction, assertStableGeometry, @@ -40,12 +41,15 @@ async function expectRestoredState(page, scenario) { }); } -test('no position and no orders returns immediate stable no-order feedback', async ({ page }) => { +test('user receives immediate no-order feedback when the account is empty', async ({ page }) => { + // Given the current-symbol page has no positions or orders and real-time interaction probes are armed. const scenario = createCancelScenario(); const { errors } = await openUserscriptScenario(page, scenario); await installInteractionProbe(page, CANCEL_BUTTON_SELECTOR); + // When the user requests cancellation. await page.getByRole('button', { name: '撤单' }).click(); + // Then the panel reports no orders promptly without opening a dialog or moving other controls. await expect(page.getByRole('button', { name: '无挂单' })).toBeVisible(); const state = await readFixtureState(page); @@ -58,7 +62,8 @@ test('no position and no orders returns immediate stable no-order feedback', asy expect(errors).toEqual([]); }); -test('other-symbol position and orders never open a current-symbol cancel dialog', async ({ page }) => { +test('user cannot cancel orders on another symbol through the current-symbol action', async ({ page }) => { + // Given only another symbol has positions and orders, and the symbol filter starts disabled. const scenario = createCancelScenario({ positions: POSITION_SETS.other, orders: ORDER_SETS.other, @@ -66,7 +71,9 @@ test('other-symbol position and orders never open a current-symbol cancel dialog }); const { errors } = await openUserscriptScenario(page, scenario); + // When the user requests current-symbol cancellation. await page.getByRole('button', { name: '撤单' }).click(); + // Then the panel reports no current orders and preserves every other-symbol order. await expect(page.getByRole('button', { name: '无挂单' })).toBeVisible(); const state = await readFixtureState(page); @@ -76,7 +83,8 @@ test('other-symbol position and orders never open a current-symbol cancel dialog expect(errors).toEqual([]); }); -test('cancelling the native dialog preserves current and other orders and restores UI', async ({ page }) => { +test('user dismisses native cancellation and keeps every order and original UI setting', async ({ page }) => { + // Given both symbols have orders and the initial view uses the conditional sub-tab. const scenario = createCancelScenario({ positions: POSITION_SETS.both, orders: ORDER_SETS.both, @@ -90,10 +98,12 @@ test('cancelling the native dialog preserves current and other orders and restor const { errors } = await openUserscriptScenario(page, scenario); await installInteractionProbe(page, CANCEL_BUTTON_SELECTOR); + // When the user opens the native confirmation and chooses Cancel. await page.getByRole('button', { name: '撤单' }).click(); await expect(page.getByRole('dialog')).toBeVisible(); await expect.poll(async () => (await readFixtureState(page)).showOrders).toBe(true); await page.getByRole('button', { name: '取消' }).click(); + // Then all orders and original tabs, filters, chart visibility, and control geometry are restored. await expect(page.getByText('撤单已取消')).toBeVisible(); const state = await readFixtureState(page); @@ -105,10 +115,12 @@ test('cancelling the native dialog preserves current and other orders and restor expect(errors).toEqual([]); }); -test('a 70-order confirmed cancellation keeps drawings visible and performs one final full save', async ({ page }) => { +test('user cancels seventy orders while chart drawings stay visible and save once at completion', async ({ page }) => { + // Given seventy current-symbol Basic orders are drawn on the chart. const orders = Array.from({ length: 70 }, (_, index) => ({ id: `current-${index + 1}`, symbol: 'HYPEUSDT', + kind: 'basic', side: 'SELL', price: String(90 + (index / 100)), quantity: '0.01', @@ -121,10 +133,12 @@ test('a 70-order confirmed cancellation keeps drawings visible and performs one const { errors } = await openUserscriptScenario(page, scenario); await installInteractionProbe(page, CANCEL_BUTTON_SELECTOR); + // When the user requests cancellation and confirms the native dialog. await page.getByRole('button', { name: '撤单' }).click(); await expect(page.getByRole('dialog')).toBeVisible(); await expect.poll(async () => (await readFixtureState(page)).showOrders).toBe(true); await page.getByRole('button', { name: '确认' }).click(); + // Then all seventy orders disappear and their drawing removals produce one final chart save. await expect(page.getByText('撤单已完成')).toBeVisible(); const state = await readFixtureState(page); @@ -150,7 +164,8 @@ test('a 70-order confirmed cancellation keeps drawings visible and performs one expect(errors).toEqual([]); }); -test('bulk cancel no longer depends on the chart orders popover', async ({ page }) => { +test('user dismisses cancellation even when the unrelated chart orders popover cannot close', async ({ page }) => { + // Given current-symbol orders exist while the chart orders popover has a stuck-close behavior. const scenario = createCancelScenario({ positions: POSITION_SETS.current, orders: ORDER_SETS.current, @@ -159,9 +174,11 @@ test('bulk cancel no longer depends on the chart orders popover', async ({ page }); const { errors } = await openUserscriptScenario(page, scenario); + // When the user opens the native cancellation dialog and cancels it. await page.getByRole('button', { name: '撤单' }).click(); await expect(page.getByRole('dialog')).toBeVisible(); await page.getByRole('button', { name: '取消' }).click(); + // Then orders and drawings remain intact without any chart-popover or chart-save operation. await expect(page.getByText('撤单已取消')).toBeVisible(); const state = await readFixtureState(page); @@ -176,7 +193,8 @@ test('bulk cancel no longer depends on the chart orders popover', async ({ page expect(errors).toEqual([]); }); -test('confirming with mixed-symbol orders clears only the current symbol', async ({ page }) => { +test('user confirms cancellation for the current symbol while other-symbol orders survive', async ({ page }) => { + // Given both symbols have positions and Basic orders, with the filter initially disabled. const scenario = createCancelScenario({ positions: POSITION_SETS.both, orders: ORDER_SETS.both, @@ -184,9 +202,11 @@ test('confirming with mixed-symbol orders clears only the current symbol', async }); const { errors } = await openUserscriptScenario(page, scenario); + // When the user requests and confirms current-symbol cancellation. await page.getByRole('button', { name: '撤单' }).click(); await expect(page.getByRole('dialog')).toBeVisible(); await page.getByRole('button', { name: '确认' }).click(); + // Then only the current-symbol order is removed by one native cancellation request. await expect(page.getByText('撤单已完成')).toBeVisible(); const state = await readFixtureState(page); @@ -196,7 +216,8 @@ test('confirming with mixed-symbol orders clears only the current symbol', async expect(errors).toEqual([]); }); -test('an originally enabled symbol filter remains enabled after confirmation', async ({ page }) => { +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({ positions: POSITION_SETS.current, orders: ORDER_SETS.both, @@ -204,9 +225,11 @@ test('an originally enabled symbol filter remains enabled after confirmation', a }); const { errors } = await openUserscriptScenario(page, scenario); + // When the user confirms the current-symbol cancellation. await page.getByRole('button', { name: '撤单' }).click(); await expect(page.getByRole('dialog')).toBeVisible(); await page.getByRole('button', { name: '确认' }).click(); + // Then other-symbol orders survive and the original filter and chart visibility remain unchanged. await expect(page.getByText('撤单已完成')).toBeVisible(); const state = await readFixtureState(page); @@ -216,7 +239,8 @@ test('an originally enabled symbol filter remains enabled after confirmation', a expect(errors).toEqual([]); }); -test('the cancel workflow remains single-flight during rapid repeated clicks', async ({ page }) => { +test('user can click cancellation twice rapidly without opening duplicate dialogs', async ({ page }) => { + // Given current-symbol orders exist and host UI mutations are delayed. const scenario = createCancelScenario({ positions: POSITION_SETS.current, orders: ORDER_SETS.current, @@ -224,10 +248,12 @@ test('the cancel workflow remains single-flight during rapid repeated clicks', a }); const { errors } = await openUserscriptScenario(page, scenario); + // When the user issues two cancellation clicks before the host commits its state. await page.locator(CANCEL_BUTTON_SELECTOR).evaluate((button) => { button.click(); button.click(); }); + // Then exactly one native dialog is opened and cancellation can restore the original view. await expect(page.getByRole('dialog')).toBeVisible(); const state = await readFixtureState(page); expect(state.events.filter((event) => event.type === 'dialog-opened')).toHaveLength(1); @@ -237,7 +263,8 @@ test('the cancel workflow remains single-flight during rapid repeated clicks', a }); for (const closeMethod of ['Escape', 'backdrop']) { - test(`closing the native dialog with ${closeMethod} is a cancellation and restores UI`, async ({ page }) => { + test(`user dismisses native cancellation with ${closeMethod} and restores the original view`, async ({ page }) => { + // Given both symbols have orders and temporary cancellation filtering must be restored. const scenario = createCancelScenario({ positions: POSITION_SETS.both, orders: ORDER_SETS.both, @@ -250,6 +277,7 @@ for (const closeMethod of ['Escape', 'backdrop']) { }); const { errors } = await openUserscriptScenario(page, scenario); + // When the user opens confirmation and dismisses it using the selected native closing method. await page.getByRole('button', { name: '撤单' }).click(); await expect(page.getByRole('dialog')).toBeVisible(); if (closeMethod === 'Escape') { @@ -257,6 +285,7 @@ for (const closeMethod of ['Escape', 'backdrop']) { } else { await page.locator('.bn-modal-root').click({ position: { x: 4, y: 4 } }); } + // Then all orders remain and no native cancel request is sent. await expect(page.getByText('撤单已取消')).toBeVisible(); const state = await readFixtureState(page); @@ -267,7 +296,8 @@ for (const closeMethod of ['Escape', 'backdrop']) { }); } -test('a BFCache pagehide does not abort the active native dialog', async ({ page }) => { +test('user can resume a cancellation dialog after a BFCache pagehide', async ({ page }) => { + // Given the current symbol has orders and the native cancellation dialog can open. const scenario = createCancelScenario({ positions: POSITION_SETS.current, orders: ORDER_SETS.current, @@ -275,6 +305,7 @@ test('a BFCache pagehide does not abort the active native dialog', async ({ page }); const { errors } = await openUserscriptScenario(page, scenario); + // When the page enters BFCache while confirmation is open, then the user dismisses the dialog. await page.getByRole('button', { name: '撤单' }).click(); await expect(page.getByRole('dialog')).toBeVisible(); await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { @@ -282,12 +313,14 @@ test('a BFCache pagehide does not abort the active native dialog', async ({ page }))); await expect(page.getByRole('dialog')).toBeVisible(); await page.getByRole('button', { name: '取消' }).click(); + // Then the active workflow accepts the decision and restores the original UI. await expect(page.getByText('撤单已取消')).toBeVisible(); await expectRestoredState(page, scenario); expect(errors).toEqual([]); }); -test('a real pagehide aborts dialog tracking without mutating orders', async ({ page }) => { +test('user leaving the page stops cancellation tracking without changing orders', async ({ page }) => { + // Given current-symbol orders exist before the native cancellation dialog opens. const scenario = createCancelScenario({ positions: POSITION_SETS.current, orders: ORDER_SETS.current, @@ -295,11 +328,13 @@ test('a real pagehide aborts dialog tracking without mutating orders', async ({ }); const { errors } = await openUserscriptScenario(page, scenario); + // When the page leaves without BFCache while confirmation is pending. await page.getByRole('button', { name: '撤单' }).click(); await expect(page.getByRole('dialog')).toBeVisible(); await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: false, }))); + // Then tracking ends with the original orders intact and no native cancellation request. await expect(page.getByText('原交易对 HYPE 页面已离开,撤单确认跟踪已停止')).toBeVisible(); const state = await readFixtureState(page); @@ -310,7 +345,8 @@ test('a real pagehide aborts dialog tracking without mutating orders', async ({ expect(errors).toEqual([]); }); -test('a missing native dialog stops cleanly and restores temporary UI state', async ({ page }) => { +test('user sees a clear failure when the native cancellation dialog never appears', async ({ page }) => { + // Given the native host is configured not to render the requested dialog. const scenario = createCancelScenario({ positions: POSITION_SETS.current, orders: ORDER_SETS.current, @@ -319,7 +355,9 @@ test('a missing native dialog stops cleanly and restores temporary UI state', as }); const { errors } = await openUserscriptScenario(page, scenario); + // When the user requests current-symbol cancellation. await page.getByRole('button', { name: '撤单' }).click(); + // Then the panel reports the missing confirmation and restores the original order view. await expect(page.getByText('未识别到撤单确认弹窗,未继续撤单流程')).toBeVisible({ timeout: 3_000, }); @@ -331,7 +369,8 @@ test('a missing native dialog stops cleanly and restores temporary UI state', as }); for (const dialogMode of ['extraAction', 'missingPrimary']) { - test(`an invalid ${dialogMode} dialog contract blocks the action and restores chart orders`, async ({ page }) => { + test(`user cannot continue cancellation through an invalid ${dialogMode} native dialog`, async ({ page }) => { + // Given the host renders the selected malformed native-dialog contract. const scenario = createCancelScenario({ positions: POSITION_SETS.current, orders: ORDER_SETS.current, @@ -340,7 +379,9 @@ for (const dialogMode of ['extraAction', 'missingPrimary']) { }); const { errors } = await openUserscriptScenario(page, scenario); + // When the user requests cancellation. await page.getByRole('button', { name: '撤单' }).click(); + // Then the script reports the structural error without cancel requests or chart mutations. await expect(page.getByText( '撤单确认弹窗结构异常,未执行弹窗操作', )).toBeVisible(); @@ -355,7 +396,8 @@ for (const dialogMode of ['extraAction', 'missingPrimary']) { }); } -test('a delayed confirmation keeps visible progress and clears only the current symbol', async ({ page }) => { +test('user sees cancellation progress while the current-symbol clear is delayed', async ({ page }) => { + // Given both symbols have orders and the native clear is delayed by 250 ms. const scenario = createCancelScenario({ positions: POSITION_SETS.both, orders: ORDER_SETS.both, @@ -364,8 +406,10 @@ test('a delayed confirmation keeps visible progress and clears only the current }); const { errors } = await openUserscriptScenario(page, scenario); + // When the user confirms the native cancellation. await page.getByRole('button', { name: '撤单' }).click(); await page.getByRole('button', { name: '确认' }).click(); + // Then progress stays visible until only the captured current-symbol orders are cleared. await expect(page.getByText('撤单已确认,等待挂单清空')).toBeVisible(); await expect(page.getByText('撤单已完成')).toBeVisible(); @@ -375,7 +419,8 @@ test('a delayed confirmation keeps visible progress and clears only the current expect(errors).toEqual([]); }); -test('dialog tracking survives React replacing the native dialog subtree', async ({ page }) => { +test('user can dismiss cancellation after the host replaces the dialog subtree', async ({ page }) => { + // Given the host replaces the current-symbol confirmation dialog after it opens. const scenario = createCancelScenario({ positions: POSITION_SETS.current, orders: ORDER_SETS.current, @@ -384,11 +429,13 @@ test('dialog tracking survives React replacing the native dialog subtree', async }); const { errors } = await openUserscriptScenario(page, scenario); + // When the user opens confirmation, waits for replacement, and chooses Cancel. 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(); + // Then the replacement decision is observed and all orders and UI settings are restored. await expect(page.getByText('撤单已取消')).toBeVisible(); const state = await readFixtureState(page); @@ -397,7 +444,8 @@ test('dialog tracking survives React replacing the native dialog subtree', async expect(errors).toEqual([]); }); -test('a confirmed dialog that does not clear current orders reports incomplete cancellation', async ({ page }) => { +test('user receives an incomplete-cancellation result when confirmed orders never clear', async ({ page }) => { + // Given the native host accepts confirmation but intentionally leaves the current orders unchanged. test.setTimeout(15_000); const scenario = createCancelScenario({ positions: POSITION_SETS.current, @@ -407,8 +455,10 @@ test('a confirmed dialog that does not clear current orders reports incomplete c }); const { errors } = await openUserscriptScenario(page, scenario); + // When the user requests cancellation and confirms it. await page.getByRole('button', { name: '撤单' }).click(); await page.getByRole('button', { name: '确认' }).click(); + // Then the script reports incomplete clearing and preserves the remaining orders and original UI. await expect(page.getByText('当前交易对挂单仍存在,撤单未完成')).toBeVisible({ timeout: 10_000, }); @@ -419,7 +469,9 @@ test('a confirmed dialog that does not clear current orders reports incomplete c expect(errors).toEqual([]); }); -test('a symbol change before the dialog decision stops the captured-symbol workflow', async ({ page }) => { +test('user stops the original cancellation workflow by changing symbol during confirmation', async ({ page }) => { + // Given both symbols have orders and the page clock controls route-change observation. + await installScenarioClock(page); const scenario = createCancelScenario({ positions: POSITION_SETS.both, orders: ORDER_SETS.both, @@ -427,14 +479,17 @@ test('a symbol change before the dialog decision stops the captured-symbol workf }); const { errors } = await openUserscriptScenario(page, scenario); + // When the user opens confirmation, switches symbol, advances the route timer, and dismisses the dialog. await page.getByRole('button', { name: '撤单' }).click(); await expect(page.getByRole('dialog')).toBeVisible(); + await pauseScenarioClock(page); await page.evaluate((symbol) => { - history.pushState({}, '', `/zh-CN/futures/${symbol}`); + window.__BINANCE_FIXTURE__.switchSymbol(symbol); }, OTHER_SYMBOL); await expect.poll(() => page.evaluate(() => location.pathname)).toContain(OTHER_SYMBOL); - await page.waitForTimeout(600); + await page.clock.runFor(600); await page.getByRole('button', { name: '取消' }).click(); + // Then the original workflow reports the symbol change and neither symbol loses orders. await expect(page.getByText('确认撤单前交易对已变化')).toBeVisible(); const state = await readFixtureState(page); @@ -442,3 +497,36 @@ test('a symbol change before the dialog decision stops the captured-symbol workf expect(state.events.filter((event) => event.type === 'cancel-requested')).toEqual([]); expect(errors).toEqual([]); }); + +test('user cancels only current-symbol basic orders while keeping conditional orders intact', async ({ page }) => { + // Given both symbols have Basic and conditional orders and the initial tab is conditional. + const protectedOrders = ORDER_SETS.both.map((order) => ({ + ...order, id: 'conditional-' + order.id, kind: 'conditional', + })); + const scenario = createCancelScenario({ + orders: [...ORDER_SETS.both, ...protectedOrders], + ui: { accountTab: 'openOrders', openOrdersSubTab: 'conditional', hideOtherSymbols: false }, + }); + const { errors } = await openUserscriptScenario(page, scenario); + + // When the user requests cancellation and accepts the native confirmation. + await page.getByRole('button', { name: '撤单', exact: true }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.getByRole('button', { name: '确认', exact: true }).click(); + + // Then the host receives a filtered Basic scope and every protected order survives. + await expect(page.getByText('撤单已完成')).toBeVisible(); + const state = await readFixtureState(page); + expect(state.orders).toEqual([ORDER_SETS.both[1], ...protectedOrders]); + expect(state.events.filter(({ type }) => type === 'cancel-requested')).toEqual([ + expect.objectContaining({ + symbol: scenario.currentSymbol, + accountTab: 'openOrders', + openOrdersSubTab: 'basic', + hideOtherSymbols: true, + orderIds: ['current-1'], + }), + ]); + await expectRestoredState(page, scenario); + expect(errors).toEqual([]); +}); diff --git a/e2e/binance-orderbook/specs/close-ladder-recovery.pw.js b/e2e/binance-orderbook/specs/close-ladder-recovery.pw.js index 00a58a6..7040809 100644 --- a/e2e/binance-orderbook/specs/close-ladder-recovery.pw.js +++ b/e2e/binance-orderbook/specs/close-ladder-recovery.pw.js @@ -2,7 +2,8 @@ import { test, expect } from '../test.js'; import { CURRENT_SYMBOL, createCancelScenario } from '../scenarios/cancel-current-symbol.js'; import { openUserscriptScenario, readFixtureState } from '../helpers/userscript-page.js'; -test('continuous close preserves partial submissions through repeated reduce-only rejections and confirmed flat', async ({ page }) => { +test('user retains partial close progress through reduce-only rejections until the position is confirmed flat', async ({ page }) => { + // Given two native close submissions can succeed before repeated reduce-only rejections and decreasing authoritative positions. test.setTimeout(30_000); const scenario = createCancelScenario({ positions: [{ symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: '100' }], @@ -36,8 +37,10 @@ test('continuous close preserves partial submissions through repeated reduce-onl }); }); const panel = page.locator('#jh-binance-close-qty-multiplier-panel'); + // When the user starts continuous close-short trading. await panel.getByRole('button', { name: '阶梯平空', exact: true }).click({ modifiers: ['Alt'] }); const status = panel.locator('#jh-binance-ladder-status'); + // Then the runner rechecks position progress, preserves the two confirmed submissions, and ends on confirmed flat without cancellations. await expect(status).toContainText('只减仓冲突,3s 后复核仓位', { timeout: 8_000 }); await expect(status).toContainText('当前方向已无持仓', { timeout: 18_000 }); await expect(status).toContainText('连续阶梯平空'); @@ -51,7 +54,8 @@ test('continuous close preserves partial submissions through repeated reduce-onl expect(errors).toEqual([]); }); -test('a close that disables the native button during recovery still confirms flat', async ({ page }) => { +test('user can finish continuous close on confirmed flat even when the native button becomes disabled', async ({ page }) => { + // Given the first close request is rejected and the position response disables the native submit button. const scenario = createCancelScenario({ positions: [{ symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: '100' }], ui: { tradeMode: 'CLOSE', orderbookPrecision: '0.1' }, @@ -82,14 +86,17 @@ test('a close that disables the native button during recovery still confirms fla }); }); const panel = page.locator('#jh-binance-close-qty-multiplier-panel'); + // When the user starts continuous close-short trading. await panel.getByRole('button', { name: '阶梯平空', exact: true }).click({ modifiers: ['Alt'] }); + // Then the authoritative flat position ends the session after one submit and two position reads. await expect(panel.locator('#jh-binance-ladder-status')).toContainText('当前方向已无持仓', { timeout: 10_000 }); expect(submissions).toBe(1); expect(positionReads).toBe(2); expect(errors).toEqual([]); }); -test('a capacity rejection during reduce-only recovery stops without entering batch cancellation', async ({ page }) => { +test('user stops on a capacity rejection during reduce-only recovery without cancelling orders', async ({ page }) => { + // Given a reduce-only rejection is followed by position progress and then a capacity rejection. const scenario = createCancelScenario({ positions: [{ symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: '100' }], ui: { tradeMode: 'CLOSE', orderbookPrecision: '0.1' }, @@ -118,8 +125,10 @@ test('a capacity rejection during reduce-only recovery stops without entering ba }); }); const panel = page.locator('#jh-binance-close-qty-multiplier-panel'); + // When the user starts continuous close-short trading. await panel.getByRole('button', { name: '阶梯平空', exact: true }).click({ modifiers: ['Alt'] }); const status = panel.locator('#jh-binance-ladder-status'); + // Then the specific native capacity failure stays visible and no batch cancellation begins. await expect(status).toContainText('失败', { timeout: 10_000 }); await expect(status).toContainText('Maximum open orders'); await expect(status).toContainText('90802025'); diff --git a/e2e/binance-orderbook/specs/control-flows.pw.js b/e2e/binance-orderbook/specs/control-flows.pw.js index 41b9e84..eeff648 100644 --- a/e2e/binance-orderbook/specs/control-flows.pw.js +++ b/e2e/binance-orderbook/specs/control-flows.pw.js @@ -9,6 +9,7 @@ import { openUserscriptScenario, readFixtureState, } from '../helpers/userscript-page.js'; +import { installScenarioClock, pauseScenarioClock } from '../helpers/scenario-clock.js'; import { assertResponsiveInteraction, finishInteractionProbe, @@ -31,7 +32,8 @@ async function expectStandardDisabledStyle(locator) { await expect(locator).toHaveCSS('cursor', 'not-allowed'); } -test('multiplier controls show local press feedback without changing operation status', async ({ page }) => { +test('user increases the quantity multiplier with local feedback and unchanged operation status', async ({ page }) => { + // Given the open panel has multiplier 1 and a stable operation status. const scenario = createCancelScenario({ ui: { tradeMode: 'OPEN', orderbookPrecision: '0.1' }, }); @@ -64,8 +66,10 @@ test('multiplier controls show local press feedback without changing operation s }); window.__MULTIPLIER_FEEDBACK_PROBE__ = { events, observer }; }); + // When the user presses the increment control. try { await increment.click(); + // Then the same button flashes once, the multiplier becomes 2, and the operation status stays unchanged. await expect.poll(() => page.evaluate(() => window.__MULTIPLIER_FEEDBACK_PROBE__.events), { timeout: 1_000, }).toEqual([ @@ -98,19 +102,31 @@ test('multiplier controls show local press feedback without changing operation s expect(errors).toEqual([]); }); -test('single order reports success only after the matching Binance API response', async ({ page }) => { +test('user sees one order remain pending until its matching Binance response succeeds', async ({ page }) => { + // Given a single-order response is held independently of the page clock. const scenario = createCancelScenario({ ui: { tradeMode: 'OPEN', orderbookPrecision: '0.1' }, - host: { submitApiResponseDelayMsByOrder: [350, 0, 0, 0, 0] }, + host: { submitApiResponses: [{ outcome: 'success', delivery: 'manual' }] }, }); - const { errors } = await openUserscriptScenario(page, scenario); + const { errors, pendingSubmitSequences, releaseSubmitResponse } = await openUserscriptScenario(page, scenario); const panel = page.locator(PANEL_SELECTOR); const status = panel.locator('#jh-binance-ladder-status'); const ladderGroup = panel.locator('[data-panel-group="ladder"]'); await expect(ladderGroup.locator('#jh-binance-ladder-status')).toHaveCount(0); + // When the user selects a bid price to submit one order. await page.locator('#futuresOrderbook .bid-light.emit-price').first().click(); + // Then confirmation stays pending with no success toast until that exact response is released. await expect(status).toContainText('单击开多确认中'); + expect(pendingSubmitSequences()).toEqual([1]); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submit-api-success')) + .toEqual([]); + await expect(page.getByRole('alert')).toHaveCount(0); + + // When the held response for that exact order succeeds. + await releaseSubmitResponse(1); + + // Then one acknowledged submission is shown without issuing another request. await expect(status).toContainText('单击开多已提交', { timeout: 3_000 }); const events = (await readFixtureState(page)).events; @@ -119,7 +135,8 @@ test('single order reports success only after the matching Binance API response' expect(errors).toEqual([]); }); -test('native open and close tabs drive one stable panel direction selector', async ({ page }) => { +test('user switches between native open and close modes without moving the direction controls', async ({ page }) => { + // Given the current symbol has only a long position and the panel starts in open mode. const scenario = createCancelScenario({ positions: POSITION_SETS.current, ui: { tradeMode: 'OPEN' }, @@ -135,7 +152,9 @@ test('native open and close tabs drive one stable panel direction selector', asy await expect(panel.getByRole('radio', { name: '开空' })).toBeEnabled(); await expect(status).toHaveText(statusBefore); await installInteractionProbe(page, '#position-direction [data-trade-mode="CLOSE"]'); + // When the user selects the native close tab. await page.locator('#position-direction [data-trade-mode="CLOSE"]').click(); + // Then only closing the long position is available and the controls retain their geometry and status. await expect(panel.getByRole('radio', { name: '平多' })).toBeEnabled(); await expect(panel.getByRole('radio', { name: '平空' })).toBeDisabled(); await expect(panel.getByRole('button', { name: '阶梯平多' })).toBeEnabled(); @@ -147,7 +166,10 @@ test('native open and close tabs drive one stable panel direction selector', asy assertResponsiveInteraction(expect, closeProbe); expect(await readRect(directionGroup)).toEqual(initialRect); + // When the user switches back to the native open tab. await page.locator('#position-direction [data-trade-mode="OPEN"]').click(); + + // Then both open directions return in the same position with unchanged status. await expect(panel.getByRole('radio', { name: '开多' })).toBeEnabled(); await expect(panel.getByRole('radio', { name: '开空' })).toBeEnabled(); await expect(status).toHaveText(statusBefore); @@ -162,7 +184,8 @@ test('native open and close tabs drive one stable panel direction selector', asy expect(errors).toEqual([]); }); -test('a precision shortcut selects the exact native orderbook option once', async ({ page }) => { +test('user selects one precision shortcut and updates exactly one native option', async ({ page }) => { + // Given the current native precision is 0.1 and a closed 0.01 shortcut is available. const scenario = createCancelScenario({ ui: { orderbookPrecision: '0.1' }, }); @@ -179,7 +202,9 @@ test('a precision shortcut selects the exact native orderbook option once', asyn const selectionsBefore = (await readFixtureState(page)).events .filter((event) => event.type === 'precision-selected').length; await installInteractionProbe(page, '[data-orderbook-precision-value="0.01"]'); + // When the user selects the 0.01 shortcut. await target.click(); + // Then the native precision becomes 0.01 once without moving the panel or changing operation status. await expect(target).toHaveAttribute('aria-pressed', 'true'); await expect(page.locator('#futuresOrderbook .tick-content')).toHaveText('0.01'); await expect(page.locator('.bn-select-bubble')).toHaveCount(0); @@ -196,7 +221,8 @@ test('a precision shortcut selects the exact native orderbook option once', asyn expect(errors).toEqual([]); }); -test('precision refresh uses the visible latest trades immediately without a sampling wait', async ({ page }) => { +test('user refreshes precision recommendations from the currently visible trades', async ({ page }) => { + // Given visible trades contain repeated 0.01 movements and refresh is enabled. const scenario = createCancelScenario({ ui: { orderbookPrecision: '0.1' }, }); @@ -206,7 +232,9 @@ test('precision refresh uses the visible latest trades immediately without a sam await expect(refresh).toBeEnabled({ timeout: 8_000 }); await installInteractionProbe(page, '[data-orderbook-precision-refresh]'); + // When the user refreshes the precision recommendation. await refresh.click(); + // Then the recommendation updates promptly, saves the visible moves, and returns to idle. await expect(refresh).toBeEnabled(); await expect(refresh).toHaveAttribute('data-orderbook-precision-refresh-state', 'success'); await expect(refresh).toHaveAttribute('aria-label', '精度推荐已更新'); @@ -221,7 +249,8 @@ test('precision refresh uses the visible latest trades immediately without a sam expect(errors).toEqual([]); }); -test('precision refresh explains when the complete visible trade list still lacks movement', async ({ page }) => { +test('user gets an explicit insufficient-movement result from flat visible trades', async ({ page }) => { + // Given every visible latest trade has the same price and operation status is stable. const scenario = createCancelScenario({ ui: { orderbookPrecision: '0.1' }, }); @@ -234,7 +263,9 @@ test('precision refresh explains when the complete visible trade list still lack }); const refresh = panel.locator('[data-orderbook-precision-refresh]'); + // When the user requests a fresh precision recommendation. await refresh.click(); + // Then the panel explains the missing movement and removes the stale recommendation. await expect(refresh).toHaveAttribute('data-orderbook-precision-refresh-state', 'retry'); await expect(refresh).toHaveAttribute('aria-label', '近期价格变化不足,请稍后重试'); await expect(status).toHaveText(statusBefore); @@ -245,7 +276,8 @@ test('precision refresh explains when the complete visible trade list still lack expect(errors).toEqual([]); }); -test('starting a ladder preserves action slots and turns only the active action into stop', async ({ page }) => { +test('user starts and stops a ladder while action positions and response budgets stay stable', async ({ page }) => { + // Given both open directions are enabled and their control rectangles are recorded on the real clock. const scenario = createCancelScenario({ ui: { tradeMode: 'OPEN', orderbookPrecision: '0.1' }, }); @@ -263,7 +295,9 @@ test('starting a ladder preserves action slots and turns only the active action const startShortRect = await readRect(startShort); const cancelRect = await readRect(cancel); await installInteractionProbe(page, '[data-ladder-action="OPEN_LONG"]'); + // When the user starts an open-long ladder. await startLong.click(); + // Then the active action becomes Stop in the same slot while the other direction is disabled. await expect(startLong).toHaveCount(0); await expect(startShort).toBeDisabled(); await expect(stop).toBeEnabled(); @@ -274,7 +308,11 @@ test('starting a ladder preserves action slots and turns only the active action expect(await readRect(cancel)).toEqual(cancelRect); const submissionsBeforeStop = (await readFixtureState(page)).events .filter((event) => event.type === 'order-submitted').length; + + // When the user stops the active ladder. await stop.click(); + + // Then the controls return to their original slots within the real-time response budget. const status = panel.locator('#jh-binance-ladder-status'); await expect(status).toContainText('阶梯开多已停止'); await expect(startLong).toBeEnabled(); @@ -290,13 +328,13 @@ test('starting a ladder preserves action slots and turns only the active action `阶梯开多已停止 · 已挂 ${submissionsAfterStop}/5 笔`, ); expect(submissionsAfterStop).toBeLessThanOrEqual(submissionsBeforeStop + 1); - await page.waitForTimeout(700); expect((await readFixtureState(page)).events .filter((event) => event.type === 'order-submitted')).toHaveLength(submissionsAfterStop); expect(errors).toEqual([]); }); -test('starting close short keeps unavailable close long in the standard disabled style', async ({ page }) => { +test('user starts closing a short position while unavailable close-long controls stay disabled', async ({ page }) => { + // Given the account has only a current-symbol short position. const scenario = createCancelScenario({ positions: [{ symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: '0.1' }], ui: { tradeMode: 'CLOSE', orderbookPrecision: '0.1' }, @@ -309,14 +347,17 @@ test('starting close short keeps unavailable close long in the standard disabled await expect(closeLong).toBeDisabled(); await expectStandardDisabledStyle(closeLong); await expect(closeShort).toBeEnabled(); + // When the user starts the close-short ladder. await closeShort.click(); + // Then the short action becomes Stop and the unavailable long action retains the standard disabled style. await expect(panel.getByRole('button', { name: '停止平空' })).toBeEnabled(); await expect(closeLong).toBeDisabled(); await expectStandardDisabledStyle(closeLong); expect(errors).toEqual([]); }); -test('starting close short disables close long with the standard disabled style', async ({ page }) => { +test('user starts closing a short position and temporarily disables the available opposite direction', async ({ page }) => { + // Given both current-symbol position directions can be closed. const scenario = createCancelScenario({ positions: [ { symbol: CURRENT_SYMBOL, side: 'LONG', quantity: '0.1' }, @@ -331,14 +372,17 @@ test('starting close short disables close long with the standard disabled style' await expect(closeLong).toBeEnabled(); await expect(closeShort).toBeEnabled(); + // When the user starts the close-short ladder. await closeShort.click(); + // Then only the short Stop action remains enabled while the long action uses the standard disabled style. await expect(panel.getByRole('button', { name: '停止平空' })).toBeEnabled(); await expect(closeLong).toBeDisabled(); await expectStandardDisabledStyle(closeLong); expect(errors).toEqual([]); }); -test('a complete ladder submits the planned five native orders and restores controls', async ({ page }) => { +test('user completes a five-level open ladder with valid prices and quantities', async ({ page }) => { + // Given the open-long plan contains five levels and native submissions can succeed. test.setTimeout(15_000); const scenario = createCancelScenario({ ui: { tradeMode: 'OPEN', orderbookPrecision: '0.1' }, @@ -348,7 +392,9 @@ test('a complete ladder submits the planned five native orders and restores cont const startLong = panel.getByRole('button', { name: '阶梯开多' }); const stop = panel.getByRole('button', { name: '停止开多' }); + // When the user starts the complete ladder. await startLong.click(); + // Then five valid orders are acknowledged, the original controls return, and each next input is written promptly. await expect(panel.locator('#jh-binance-ladder-status')).toHaveText('阶梯开多已完成 · 已挂 5/5 笔', { timeout: 12_000, }); @@ -371,18 +417,40 @@ test('a complete ladder submits the planned five native orders and restores cont expect(errors).toEqual([]); }); -test('a late toast from the previous order cannot acknowledge the next order', async ({ page }) => { +test('user keeps the second ladder order pending when the first order toast arrives late', async ({ page }) => { + // Given the first response succeeds, its toast is delayed, and the second response requires explicit release. + await installScenarioClock(page); const scenario = createCancelScenario({ ui: { tradeMode: 'OPEN', orderbookPrecision: '0.1' }, host: { submitFeedbackDelayMs: 500, - submitApiResponseDelayMsByOrder: [20, 700, 20, 20, 20], + submitApiResponses: [ + { outcome: 'success', delivery: 'immediate' }, + { outcome: 'success', delivery: 'manual' }, + ...Array.from({ length: 3 }, () => ({ outcome: 'success', delivery: 'immediate' })), + ], }, }); - const { errors } = await openUserscriptScenario(page, scenario); + const { errors, pendingSubmitSequences, releaseSubmitResponse } = await openUserscriptScenario(page, scenario); const panel = page.locator(PANEL_SELECTOR); + // When the user starts the ladder and the page advances until the earlier toast arrives. await panel.getByRole('button', { name: '阶梯开多' }).click(); + await expect.poll(pendingSubmitSequences).toEqual([2]); + await pauseScenarioClock(page); + await page.clock.runFor(500); + const pending = (await readFixtureState(page)).events; + // Then only two orders exist and the second remains unacknowledged until its own response is released. + expect(pending.filter(({ type }) => type === 'order-submitted')).toHaveLength(2); + expect(pending.filter(({ type }) => type === 'order-submit-feedback')) + .toEqual([expect.objectContaining({ submitSequence: 1, outcome: 'success' })]); + expect(pending.filter(({ type }) => type === 'order-submit-api-success')).toHaveLength(1); + + // When the second order's own response is released and the clock resumes. + await releaseSubmitResponse(2); + await page.clock.resume(); + + // Then all five levels finish only after their own API acknowledgements. await expect(panel.locator('#jh-binance-ladder-status')).toHaveText( '阶梯开多已完成 · 已挂 5/5 笔', { timeout: 12_000 }, @@ -401,7 +469,8 @@ test('a late toast from the previous order cannot acknowledge the next order', a expect(errors).toEqual([]); }); -test('ladder waits for the native submit button to leave its busy state', async ({ page }) => { +test('user completes a ladder only after each native submit control becomes ready again', async ({ page }) => { + // Given each native submit stays busy for 450 ms and clears the inputs when it becomes ready. const scenario = createCancelScenario({ ui: { tradeMode: 'OPEN', orderbookPrecision: '0.1' }, host: { @@ -413,7 +482,9 @@ test('ladder waits for the native submit button to leave its busy state', async const { errors } = await openUserscriptScenario(page, scenario); const panel = page.locator(PANEL_SELECTOR); + // When the user starts the five-level ladder. await panel.getByRole('button', { name: '阶梯开多' }).click(); + // Then every submission uses restored nonempty inputs and no click occurs while the native control is busy. await expect(panel.locator('#jh-binance-ladder-status')).toHaveText( '阶梯开多已完成 · 已挂 5/5 笔', { timeout: 12_000 }, @@ -431,13 +502,16 @@ test('ladder waits for the native submit button to leave its busy state', async expect(errors).toEqual([]); }); -test('auto leverage resets only a flat current symbol and ignores other-symbol positions', async ({ page }) => { +test('user opens a flat symbol and its leverage resets independently of positions on other symbols', async ({ page }) => { + // Given only another symbol has a position and the current symbol starts at 5x. const scenario = createCancelScenario({ positions: POSITION_SETS.other, ui: { tradeMode: 'OPEN', leverage: 5 }, }); + // When the user opens the current-symbol panel. const { errors } = await openUserscriptScenario(page, scenario); + // Then exactly one current-symbol leverage adjustment sets it to 2x. await expect.poll(async () => (await readFixtureState(page)).leverage, { timeout: 3_000, }).toBe(2); @@ -449,16 +523,143 @@ test('auto leverage resets only a flat current symbol and ignores other-symbol p expect(errors).toEqual([]); }); -test('auto leverage preserves the current leverage while the current symbol has a position', async ({ page }) => { +test('user keeps the existing leverage while holding a current-symbol position', async ({ page }) => { + // Given the current symbol has a position and starts at 5x. + await installScenarioClock(page); const scenario = createCancelScenario({ positions: POSITION_SETS.current, ui: { tradeMode: 'OPEN', leverage: 5 }, }); const { errors } = await openUserscriptScenario(page, scenario); - await page.waitForTimeout(500); + // When the page clock advances through five seconds of leverage scheduling. + await pauseScenarioClock(page); + await page.clock.runFor(5_000); + // Then the leverage remains 5x and no adjustment request is made. const state = await readFixtureState(page); expect(state.leverage).toBe(5); expect(state.events.filter((event) => event.type === 'leverage-adjusted')).toEqual([]); expect(errors).toEqual([]); }); + +test('user stops an active ladder and a late response cannot submit another level', async ({ page }) => { + // Given the first native submission remains pending and its busy cleanup is scheduled. + await installScenarioClock(page); + const scenario = createCancelScenario({ + host: { + submitButtonBusyMs: 450, + submitApiResponses: [ + { outcome: 'success', delivery: 'manual' }, + ...Array.from({ length: 4 }, () => ({ outcome: 'success', delivery: 'immediate' })), + ], + }, + }); + const { errors, pendingSubmitSequences, releaseSubmitResponse } = await openUserscriptScenario(page, scenario); + const panel = page.locator(PANEL_SELECTOR); + await panel.getByRole('button', { name: '阶梯开多', exact: true }).click(); + await expect.poll(pendingSubmitSequences).toEqual([1]); + await pauseScenarioClock(page); + + // When the user stops the ladder before that response arrives. + await panel.getByRole('button', { name: '停止开多', exact: true }).click(); + await page.clock.runFor(100); + + // Then the stop is visible and only the original submit has reached the host. + const status = panel.locator('#jh-binance-ladder-status'); + await expect(status).toContainText('阶梯开多已停止'); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted')) + .toHaveLength(1); + + // When the pending response succeeds and all former inter-order timers are advanced. + await releaseSubmitResponse(1); + await expect.poll(async () => (await readFixtureState(page)).events + .filter(({ type }) => type === 'order-submit-api-success').length).toBe(1); + await page.clock.runFor(5_000); + + // Then native readiness and late success cannot restart the stopped ladder. + await expect(status).toContainText('阶梯开多已停止'); + await expect(panel.getByRole('button', { name: '阶梯开多', exact: true })).toBeEnabled(); + const state = await readFixtureState(page); + expect(state.events.filter(({ type }) => type === 'submit-button-ready')).toHaveLength(1); + expect(state.events.filter(({ type }) => type === 'order-submitted')).toHaveLength(1); + expect(errors).toEqual([]); +}); + +test('user sees a confirmed order rejection without a fabricated success or automatic resubmit', async ({ page }) => { + // Given the next native request has one explicit rejection response. + await installScenarioClock(page); + const scenario = createCancelScenario({ + host: { submitApiResponses: [{ + outcome: 'rejected', delivery: 'immediate', code: '90800001', message: 'Fixture rejection', + }] }, + }); + const { errors } = await openUserscriptScenario(page, scenario); + + // When the user submits one order from the orderbook. + await page.locator('#futuresOrderbook .bid-light.emit-price').first().click(); + + // Then the matching rejection is shown and no successful submission is reported. + const status = page.locator('#jh-binance-ladder-status'); + await expect(status).toContainText('90800001'); + await expect(status).not.toContainText('已提交'); + await expect(page.getByRole('alert')).toHaveText('订单提交失败'); + + // When the page clock advances beyond the submit deadline and recovery cooldowns. + await pauseScenarioClock(page); + await page.clock.runFor(15_000); + + // Then exactly one request remains rejected and there is no automatic retry. + const events = (await readFixtureState(page)).events; + expect(events.filter(({ type }) => type === 'order-submitted')).toHaveLength(1); + expect(events.filter(({ type }) => type === 'order-submit-api-rejected')) + .toEqual([expect.objectContaining({ submitSequence: 1, code: '90800001' })]); + expect(events.filter(({ type }) => type === 'order-submit-api-success')).toEqual([]); + expect(errors).toEqual([]); +}); + +test('user ends a single close round on an unknown submission even when a late success arrives', async ({ page }) => { + // Given a single close round can submit once but its response is held by the network fixture. + await installScenarioClock(page); + const scenario = createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: 'LONG', quantity: '100' }], + ui: { tradeMode: 'CLOSE' }, + host: { submitApiResponses: [{ outcome: 'unknown', delivery: 'manual' }] }, + }); + const { errors, pendingSubmitSequences, releaseSubmitResponse } = await openUserscriptScenario(page, scenario); + const panel = page.locator(PANEL_SELECTOR); + const status = panel.locator('#jh-binance-ladder-status'); + + // When the user starts an ordinary close round and the response deadline has not yet elapsed. + await panel.getByRole('button', { name: '阶梯平多', exact: true }).click(); + await expect.poll(pendingSubmitSequences).toEqual([1]); + await pauseScenarioClock(page); + await page.clock.runFor(11_000); + + // Then the order remains unacknowledged and no further level is submitted. + await expect(status).not.toContainText('未确认'); + await expect(page.getByRole('alert')).toHaveCount(0); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted')) + .toHaveLength(1); + + // When the page crosses the twelve-second response deadline. + await page.clock.runFor(2_000); + + // Then the single round ends with an explicit unknown-outcome message. + await expect(status).toContainText('未确认'); + await expect(status).toContainText('下单请求仍未返回'); + await expect(panel.getByRole('button', { name: '停止平多', exact: true })).toHaveCount(0); + const terminalStatus = await status.textContent(); + + // When the held response succeeds late and multiple potential recovery windows pass. + await releaseSubmitResponse(1, { outcome: 'success' }); + await expect.poll(async () => (await readFixtureState(page)).events + .filter(({ type }) => type === 'order-submit-api-success').length).toBe(1); + await page.clock.runFor(10_000); + + // Then the terminal result stays visible without cancellation, recovery, or resubmission. + await expect(status).toHaveText(terminalStatus); + const events = (await readFixtureState(page)).events; + expect(events.filter(({ type }) => type === 'order-submitted')).toHaveLength(1); + expect(events.filter(({ type }) => type.includes('cancel'))).toEqual([]); + expect(errors).toEqual([]); +}); diff --git a/e2e/binance-orderbook/specs/coverage-merge.pw.js b/e2e/binance-orderbook/specs/coverage-merge.pw.js new file mode 100644 index 0000000..8d3c8ab --- /dev/null +++ b/e2e/binance-orderbook/specs/coverage-merge.pw.js @@ -0,0 +1,96 @@ +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { test, expect } from '@playwright/test'; + +import { + collectProofNodeEntry, + composeProofBrowserSource, + createMergeProof, + mapProofEntries, + reportProofEntries, +} from '../../../scripts/test-coverage/merge-proof.mjs'; +import { splitCoverageEntry } from '../../../scripts/test-coverage/split-entries.mjs'; + +const cases = [ + { + name: 'a single main bundle', + copies: [{ artifact: 0, branch: false, calls: 2, executed: true }], + browserCounts: [0, 2], unsplitCounts: [0, 2], rawFunctionCounts: [2], rootCounts: [1], + }, + { + name: 'coexisting bundles that execute the same browser branch', + copies: [{ artifact: 0, branch: false, calls: 2, executed: true }, { artifact: 1, branch: false, calls: 3, executed: true }], + browserCounts: [0, 5], unsplitCounts: [0, 2], rawFunctionCounts: [2, 3], rootCounts: [1, 1], + }, + { + name: 'coexisting bundles that execute opposite branches', + copies: [{ artifact: 0, branch: true, calls: 2, executed: true }, { artifact: 1, branch: false, calls: 3, executed: true }], + browserCounts: [2, 3], unsplitCounts: [2, 0], rawFunctionCounts: [2, 3], rootCounts: [1, 1], + }, + { + name: 'two identical artifact copies that execute opposite branches', + copies: [{ artifact: 0, branch: false, calls: 2, executed: true }, { artifact: 0, branch: true, calls: 3, executed: true }], + browserCounts: [3, 2], unsplitCounts: [0, 2], rawFunctionCounts: [2, 3], rootCounts: [1, 1], + }, + { + name: 'a dormant wrapper beside an executed opposite branch', + copies: [{ artifact: 0, branch: false, calls: 2, executed: false }, { artifact: 1, branch: true, calls: 3, executed: true }], + browserCounts: [3, 0], unsplitCounts: [0, 0], rawFunctionCounts: [3], rootCounts: [0, 1], + }, +]; + +for (const scenario of cases) { + test(`user gets exact branch union and execution counts from Node plus ${scenario.name}`, async ({ page }, testInfo) => { + // Given one complete two-branch ESM source executed in an isolated Node child and compiled into browser artifacts + const proof = await createMergeProof(testInfo.outputPath('merge-proof')); + const nodeEntry = await collectProofNodeEntry(proof); + const source = composeProofBrowserSource(proof, scenario.copies); + await writeFile(resolve(proof.outputDirectory, 'browser-composed.js'), source); + await page.setContent('Coverage merge contract'); + + // When real Chromium executes the configured copies and exposes its precise V8 counters + await page.coverage.startJSCoverage({ resetOnNavigation: false, reportAnonymousScripts: true }); + let browserCapture; + let values; + try { + await page.addScriptTag({ content: source }); + values = await page.evaluate(() => globalThis.__coverageProofResults); + } finally { + browserCapture = await page.coverage.stopJSCoverage(); + } + const browserEntries = browserCapture.filter((entry) => entry.source === source); + expect(browserEntries).toHaveLength(1); + await writeFile(resolve(proof.outputDirectory, 'browser-raw.json'), JSON.stringify(browserEntries, null, 2)); + const splitEntries = splitCoverageEntry(browserEntries[0], proof.registry); + const nodeMapped = mapProofEntries([nodeEntry], proof, { split: true }); + const browserMapped = mapProofEntries(browserEntries, proof, { split: true }); + const browserUnsplit = mapProofEntries(browserEntries, proof, { split: false }); + const nodeReport = await reportProofEntries(proof, 'node-only', nodeMapped); + const unsplitReport = await reportProofEntries(proof, 'browser-unsplit-regression', browserUnsplit); + const browserReport = await reportProofEntries(proof, 'browser-only', browserMapped); + const mergedReport = await reportProofEntries(proof, 'merged', [...nodeMapped, ...browserMapped]); + + // Then the source appears once, real counters sum across entries, and no dormant wrapper invents execution credit + const browserCalls = scenario.browserCounts[0] + scenario.browserCounts[1]; + const mergedCounts = [scenario.browserCounts[0] + 1, scenario.browserCounts[1]]; + expect(values).toEqual(scenario.copies.filter((copy) => copy.executed) + .flatMap((copy) => Array(copy.calls).fill(copy.branch ? 'node' : 'browser'))); + expect(browserEntries[0].functions.filter((fn) => fn.functionName === 'chooseBranch' && fn.ranges[0].count > 0) + .map((fn) => fn.ranges[0].count).sort((left, right) => left - right)).toEqual(scenario.rawFunctionCounts); + expect(splitEntries.map((entry) => entry.functions[0].ranges[0].count)).toEqual(scenario.rootCounts); + expect(nodeReport).toEqual({ sourcePath: proof.sourcePath, branches: { covered: 1, total: 2, counts: [1, 0] }, + functions: [{ name: 'chooseBranch', count: 1 }] }); + // MCR 2.13.0 discards repeated original ranges within one composed entry. + // This real counterexample keeps that loss visible while verifying the split repair. + expect(unsplitReport.branches.counts).toEqual(scenario.unsplitCounts); + expect(browserReport).toEqual({ sourcePath: proof.sourcePath, + branches: { covered: scenario.browserCounts.filter((count) => count > 0).length, total: 2, counts: scenario.browserCounts }, + functions: [{ name: 'chooseBranch', count: browserCalls }] }); + expect(mergedReport).toEqual({ sourcePath: proof.sourcePath, + branches: { covered: mergedCounts.filter((count) => count > 0).length, total: 2, counts: mergedCounts }, + functions: [{ name: 'chooseBranch', count: browserCalls + 1 }] }); + const evidencePath = resolve(proof.outputDirectory, 'merge-evidence.json'); + await writeFile(evidencePath, JSON.stringify({ nodeReport, unsplitReport, browserReport, mergedReport }, null, 2)); + await testInfo.attach('coverage-merge-evidence', { path: evidencePath, contentType: 'application/json' }); + }); +} diff --git a/e2e/binance-orderbook/specs/depth-profile-labels.pw.js b/e2e/binance-orderbook/specs/depth-profile-labels.pw.js index 34b2246..f0549e9 100644 --- a/e2e/binance-orderbook/specs/depth-profile-labels.pw.js +++ b/e2e/binance-orderbook/specs/depth-profile-labels.pw.js @@ -73,8 +73,12 @@ async function attachChart(page, testInfo, name) { }); } -test('compact depth quantities preserve cumulative bars, chart layout and click-through', async ({ page }, testInfo) => { +test('user reads compact depth quantities without changing chart layout or blocking chart clicks', async ({ page }, testInfo) => { + // Given the native depth snapshot is rendered beside a TradingView chart with a latest-price divider. const evidence = await openDepthLabelScenario(page); + // When the user views the visible depth canvas. + await page.locator(`${DEPTH_PROFILE_SELECTOR} canvas`).waitFor({ state: 'visible' }); + // Then labels keep exact quantities, cumulative bar geometry, opaque pixels, and native click-through. await expect.poll(() => labelTexts(page)).toEqual(['1.3 · 620K', '1.8 · 3.8M', '2 · 2.4M']); const { drawing, boxes, canvas } = await expectCompactLabelGeometry(page, { currentPriceY: 182 }); const largeAskBar = drawing.rectangles.find((rectangle) => ( @@ -92,13 +96,18 @@ test('compact depth quantities preserve cumulative bars, chart layout and click- }))).toEqual({ pointerEvents: 'none', canvasPointerEvents: 'none', childTags: ['CANVAS', 'BUTTON', 'DIV'] }); const label = boxes[0]; + + // When the user clicks the chart through a painted depth label. await page.mouse.click(canvas.x + label.x + label.width / 2, canvas.y + label.y + label.height / 2); + + // Then the underlying chart receives the click and the overlay stays read-only. expect(await page.evaluate(() => window.__DEPTH_LABEL_FIXTURE__.chartClicks)).toBe(1); await expectIsolatedReadOnlyFixture(page, evidence); await attachChart(page, testInfo, 'compact-depth-labels.png'); }); -test('pixel-row quantities remain readable with no latest price and a wide price band', async ({ page }, testInfo) => { +test('user reads aggregated depth quantities when a wide price band has no latest-price marker', async ({ page }, testInfo) => { + // Given several real depth levels share one pixel row and no latest trade price is available. const evidence = await openDepthLabelScenario(page, { currentPrice: null, levels: { @@ -106,6 +115,9 @@ test('pixel-row quantities remain readable with no latest price and a wide price bids: [['1.49', '600000'], ['1.4', '700000'], ['1.3', '900000']], }, }); + // When the user views the visible depth canvas. + await page.locator(`${DEPTH_PROFILE_SELECTOR} canvas`).waitFor({ state: 'visible' }); + // Then the label shows the aggregated quantity within its bounds while cumulative bars and chart layout stay correct. await expect.poll(() => labelTexts(page)).toEqual(['1.3 · 900K', '1.4 · 700K', '2 · 2.4M', '3.8M']); const { drawing } = await expectCompactLabelGeometry(page); await expect(page.locator('.tradew-tradelist .price.emit-price').first()).toHaveText('—'); @@ -121,30 +133,44 @@ test('pixel-row quantities remain readable with no latest price and a wide price await attachChart(page, testInfo, 'aggregated-depth-labels.png'); }); -test('native updates, collapse and disconnect remove stale depth label pixels', async ({ page }, testInfo) => { +test('user sees depth labels update and clear across collapse and native disconnection', async ({ page }, testInfo) => { + // Given a native depth snapshot initially includes the 1.8 price-band label. const evidence = await openDepthLabelScenario(page); await expect.poll(() => labelTexts(page)).toContain('1.8 · 3.8M'); + // When the native stream changes the displayed levels. await emitDepthLabelUpdate(page, { asks: [['1.8', '1200'], ['2', '0'], ['2.05', '2600000']], bids: [['1.3', '0'], ['1.35', '900000']], }); + // Then the labels reflect the new levels before collapse, re-expansion, and disconnect are exercised. const updatedTexts = ['1.35 · 900K', '2.05 · 2.6M']; await expect.poll(() => labelTexts(page)).toEqual(updatedTexts); await expectCompactLabelGeometry(page, { currentPriceY: 182 }); await attachChart(page, testInfo, 'updated-depth-labels.png'); const root = page.locator(DEPTH_PROFILE_SELECTOR); + + // When the user collapses the profile. await root.locator('button').click(); + + // Then label pixels disappear and the native chart geometry is unchanged. await expect(root).toHaveAttribute('data-expanded', 'false'); await expect(root.locator('canvas')).toBeHidden(); await expect.poll(() => labelTexts(page)).toEqual([]); expect(await readDepthChartLayout(page)).toEqual(evidence.initialLayout); + + // When the user expands the profile again. await root.locator('button').click(); + + // Then only the current labels return within the same chart geometry. await expect(root).toHaveAttribute('data-expanded', 'true'); await expect.poll(() => labelTexts(page)).toEqual(updatedTexts); expect(await readDepthChartLayout(page)).toEqual(evidence.initialLayout); + // When the native depth connection closes. await page.evaluate(() => window.__DEPTH_LABEL_FIXTURE__.socket.dispatchEvent(new Event('close'))); + + // Then the connection status is visible and every stale canvas pixel is cleared. await expect(root.locator('.jh-depth-profile-status')).toHaveText('重新连接深度'); await expect.poll(() => labelTexts(page)).toEqual([]); expect(await root.locator('canvas').evaluate((canvas) => ( diff --git a/e2e/binance-orderbook/specs/live-performance-probe.pw.js b/e2e/binance-orderbook/specs/live-performance-probe.pw.js index d14408d..6e62fb0 100644 --- a/e2e/binance-orderbook/specs/live-performance-probe.pw.js +++ b/e2e/binance-orderbook/specs/live-performance-probe.pw.js @@ -6,6 +6,7 @@ import { createCancelScenario, } from '../scenarios/cancel-current-symbol.js'; import { openUserscriptScenario } from '../helpers/userscript-page.js'; +import { installScenarioClock, pauseScenarioClock } from '../helpers/scenario-clock.js'; import { armLivePerformanceProbe, createLivePerformanceProbeExpression, @@ -17,7 +18,8 @@ import { validateLivePerformanceProbeSnapshot, } from '../helpers/live-performance-probe.js'; -test('live probe captures a no-order run and destroys every listener', async ({ page }) => { +test('user receives complete no-order evidence and can dispose the performance probe', async ({ page }) => { + // Given an empty account has a live probe armed idempotently for the no-order action. await openUserscriptScenario(page, createCancelScenario()); await installLivePerformanceProbe(page); const firstArm = await armLivePerformanceProbe(page, 'cancel-current-symbol-no-orders'); @@ -25,7 +27,9 @@ test('live probe captures a no-order run and destroys every listener', async ({ expect(secondArm.sessionId).toBe(firstArm.sessionId); await prepareLivePerformanceProbeCompletion(page, 'no-orders'); + // When the user requests cancellation after page-owned completion tracking is prepared. await page.getByRole('button', { name: '撤单' }).click(); + // Then the real-time capture finishes promptly and ignores later mutations and clicks after disposal. const snapshot = await finishLivePerformanceProbeWhenReady(page); await expect(page.getByRole('button', { name: '无挂单' })).toBeEnabled(); expect(() => validateLivePerformanceProbeSnapshot(snapshot)).not.toThrow(); @@ -36,22 +40,32 @@ test('live probe captures a no-order run and destroys every listener', async ({ expect(snapshot.lastSemanticState.statusText).toBe('当前交易对无挂单'); const finishedEventCount = snapshot.events.length; - await page.evaluate(() => { + + // When a mutation and two real rendering frames occur after capture completion. + await page.evaluate(async () => { const panel = document.querySelector('#jh-binance-close-qty-multiplier-panel'); panel?.setAttribute('data-after-finish', 'ignored'); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); }); - await page.waitForTimeout(20); + + // Then disconnected mutation observers cannot append another event. const frozen = await page.evaluate(() => window.__BINANCE_LIVE_PERFORMANCE_PROBE__.snapshot()); expect(frozen.events).toHaveLength(finishedEventCount); const beforeDestroy = snapshot.events.length; + + // When the user destroys the probe and clicks the action again. await destroyLivePerformanceProbe(page); await page.getByRole('button', { name: '撤单' }).click(); + + // Then no probe remains and the completed capture stays immutable. expect(await page.evaluate(() => window.__BINANCE_LIVE_PERFORMANCE_PROBE__)).toBeUndefined(); expect(snapshot.events).toHaveLength(beforeDestroy); }); -test('live probe has no user-decision deadline and follows a replaced portal dialog', async ({ page }) => { +test('user can leave confirmation open for a minute and dismiss the replaced native dialog', async ({ page }) => { + // Given a lifecycle clock controls a native dialog that will be replaced after opening. + await installScenarioClock(page); const scenario = createCancelScenario({ positions: POSITION_SETS.current, orders: ORDER_SETS.current, @@ -61,10 +75,13 @@ test('live probe has no user-decision deadline and follows a replaced portal dia await installLivePerformanceProbe(page); await armLivePerformanceProbe(page, 'cancel-dialog-cancel'); - await prepareLivePerformanceProbeCompletion(page, 'dialog-cancel'); + await prepareLivePerformanceProbeCompletion(page, 'dialog-cancel', { timeoutMs: 120_000 }); + // When the user opens confirmation and a full minute passes without a decision. await page.getByRole('button', { name: '撤单' }).click(); await expect(page.getByRole('dialog')).toBeVisible(); - await page.waitForTimeout(1_000); + await pauseScenarioClock(page); + await page.clock.runFor(60_000); + // Then the probe remains unfinished and continues tracking the dialog until the user cancels. const waiting = await page.evaluate(() => window.__BINANCE_LIVE_PERFORMANCE_PROBE__.snapshot()); expect(waiting.finishedAtMonotonicMs).toBeNull(); expect(waiting.events.map((event) => event.kind)).toContain('dialog-visible'); @@ -73,7 +90,11 @@ test('live probe has no user-decision deadline and follows a replaced portal dia button.parentElement.classList.add('bn-modal-footer'); }); + // When the user decides to cancel through the replacement dialog. + await page.clock.resume(); await page.getByRole('button', { name: '取消' }).click(); + + // Then completion records the decision and final cancellation state. const snapshot = await finishLivePerformanceProbeWhenReady(page); await expect(page.getByText('撤单已取消')).toBeVisible(); expect(() => validateLivePerformanceProbeSnapshot(snapshot)).not.toThrow(); @@ -85,7 +106,8 @@ test('live probe has no user-decision deadline and follows a replaced portal dia await destroyLivePerformanceProbe(page); }); -test('live completion waits for confirmed cancellation cleanup', async ({ page }) => { +test('user receives completed cancellation evidence only after native cleanup finishes', async ({ page }) => { + // Given the current symbol has a Basic order and page-owned completion tracking is prepared. const scenario = createCancelScenario({ positions: POSITION_SETS.current, orders: ORDER_SETS.current, @@ -95,9 +117,11 @@ test('live completion waits for confirmed cancellation cleanup', async ({ page } await armLivePerformanceProbe(page, 'cancel-dialog-confirm'); await prepareLivePerformanceProbeCompletion(page, 'dialog-confirm'); + // When the user opens and confirms the native cancellation. await page.getByRole('button', { name: '撤单' }).click(); await expect(page.getByRole('dialog')).toBeVisible(); await page.getByRole('button', { name: '确认' }).click(); + // Then the completed capture contains a primary decision and the final cancellation status. const snapshot = await finishLivePerformanceProbeWhenReady(page); expect(() => validateLivePerformanceProbeSnapshot(snapshot)).not.toThrow(); @@ -106,7 +130,8 @@ test('live completion waits for confirmed cancellation cleanup', async ({ page } await destroyLivePerformanceProbe(page); }); -test('live completion flushes the final long task before disconnecting observers', async ({ page }) => { +test('user retains evidence of a final long host task in the completed capture', async ({ page }) => { + // Given a real 80 ms host stall is attached to the cancellation click. await openUserscriptScenario(page, createCancelScenario()); await installLivePerformanceProbe(page); await armLivePerformanceProbe(page, 'cancel-current-symbol-no-orders-long-task'); @@ -120,22 +145,27 @@ test('live completion flushes the final long task before disconnecting observers }); await prepareLivePerformanceProbeCompletion(page, 'no-orders'); + // When the user requests cancellation while the real performance observers are active. await page.getByRole('button', { name: '撤单' }).click(); + // Then the final long task is included before the observers disconnect. const snapshot = await finishLivePerformanceProbeWhenReady(page); expect(snapshot.longTasks.some((entry) => entry.duration >= 75)).toBe(true); await destroyLivePerformanceProbe(page); }); -test('live probe rejects a sample while prior no-order feedback is still visible', async ({ page }) => { +test('user cannot rearm a performance sample until prior no-order feedback clears', async ({ page }) => { + // Given an empty-account sample is armed and completion tracking is prepared. await openUserscriptScenario(page, createCancelScenario()); await installLivePerformanceProbe(page); await armLivePerformanceProbe(page, 'cancel-current-symbol-no-orders-first'); await prepareLivePerformanceProbeCompletion(page, 'no-orders'); + // When the user completes one no-order action and immediately tries to arm another sample. await page.getByRole('button', { name: '撤单' }).click(); await finishLivePerformanceProbeWhenReady(page); await expect(page.getByRole('button', { name: '无挂单' })).toBeEnabled(); + // Then rearming fails until the normal cancellation action becomes ready again. await expect(page.evaluate(() => ( window.__BINANCE_LIVE_PERFORMANCE_PROBE__.arm('cancel-current-symbol-no-orders-too-soon') ))).rejects.toThrow(/cannot arm before the cancel UI is fully ready/); @@ -146,11 +176,13 @@ test('live probe rejects a sample while prior no-order feedback is still visible await destroyLivePerformanceProbe(page); }); -test('live probe serializes uncaught errors and unhandled rejections', async ({ page }) => { +test('user receives serializable evidence for uncaught errors and unhandled rejections', async ({ page }) => { + // Given a live probe is armed on an empty-account fixture. await openUserscriptScenario(page, createCancelScenario()); await installLivePerformanceProbe(page); await armLivePerformanceProbe(page, 'serializable-errors'); + // When the user requests cancellation and the host emits an error and an unhandled rejection. await page.getByRole('button', { name: '撤单' }).click(); await page.evaluate(() => { window.dispatchEvent(new ErrorEvent('error', { message: 'probe test error' })); @@ -158,6 +190,7 @@ test('live probe serializes uncaught errors and unhandled rejections', async ({ Object.defineProperty(rejection, 'reason', { value: new Error('probe test rejection') }); window.dispatchEvent(rejection); }); + // Then both error kinds retain their messages in a serializable capture. const snapshot = await finishLivePerformanceProbe(page); expect(snapshot.errors).toEqual([ expect.objectContaining({ type: 'error', message: 'probe test error' }), @@ -167,7 +200,8 @@ test('live probe serializes uncaught errors and unhandled rejections', async ({ await destroyLivePerformanceProbe(page); }); -test('live probe follows a userscript panel replaced after arm and before click', async ({ page }) => { +test('user receives feedback evidence after the host replaces an armed panel', async ({ page }) => { + // Given a custom cancellation panel has an armed live performance probe. await openUserscriptScenario(page, createCancelScenario()); await page.evaluate(() => { const panel = document.createElement('section'); @@ -182,6 +216,7 @@ test('live probe follows a userscript panel replaced after arm and before click' statusSelector: '#probe-status', }); await armLivePerformanceProbe(page, 'replaced-panel-before-click'); + // When the host replaces the panel and the user clicks its new cancellation button. await page.evaluate(() => { const oldPanel = document.querySelector('#probe-panel'); const newPanel = oldPanel.cloneNode(true); @@ -194,6 +229,7 @@ test('live probe follows a userscript panel replaced after arm and before click' }); await page.getByRole('button', { name: 'Probe cancel' }).click(); + // Then the probe reacquires the replacement and records first feedback. await expect(page.getByRole('button', { name: 'Probe processing' })).toBeDisabled(); const snapshot = await finishLivePerformanceProbe(page); expect(() => validateLivePerformanceProbeSnapshot(snapshot)).not.toThrow(); @@ -201,12 +237,15 @@ test('live probe follows a userscript panel replaced after arm and before click' await destroyLivePerformanceProbe(page); }); -test('live probe reports overflow and supports raw Runtime.evaluate injection', async ({ page }) => { +test('user receives an explicit overflow failure from a directly injected performance probe', async ({ page }) => { + // Given a raw evaluation installs a live probe with a one-event limit. await openUserscriptScenario(page, createCancelScenario()); await page.evaluate(createLivePerformanceProbeExpression({ eventLimit: 1 })); await page.evaluate(() => window.__BINANCE_LIVE_PERFORMANCE_PROBE__.arm('overflow')); + // When the user requests cancellation and generates more events than the declared limit. await page.getByRole('button', { name: '撤单' }).click(); + // Then the capture reports discarded events and fails strict validation. await expect(page.getByRole('button', { name: '撤单' })).toBeEnabled(); const snapshot = await page.evaluate(() => window.__BINANCE_LIVE_PERFORMANCE_PROBE__.finish()); expect(snapshot.dropped.events).toBeGreaterThan(0); diff --git a/e2e/binance-orderbook/specs/panel-visual-contract.pw.js b/e2e/binance-orderbook/specs/panel-visual-contract.pw.js index 4c08e67..dcfc125 100644 --- a/e2e/binance-orderbook/specs/panel-visual-contract.pw.js +++ b/e2e/binance-orderbook/specs/panel-visual-contract.pw.js @@ -40,20 +40,28 @@ async function expectNativeDivider(page) { }); } -test('open panel matches the fixed visual contract', async ({ page }) => { - await openUserscriptScenario(page, createCancelScenario({ +test('user sees the fixed panel layout in open mode', async ({ page }) => { + // Given the native trade form starts in open mode. + const scenario = createCancelScenario({ ui: { tradeMode: 'OPEN' }, - })); + }); + // When the user opens the generated userscript panel. + await openUserscriptScenario(page, scenario); + // Then the precision controls, full-width divider, and layout match the checked-in visual contract. await expectPrecisionReady(page); await expectNativeDivider(page); await expectVisualContract(page, 'open-fixed.visual.json'); }); -test('close panel matches the disabled-state visual contract', async ({ page }) => { - await openUserscriptScenario(page, createCancelScenario({ +test('user sees only valid close directions enabled in the fixed panel layout', async ({ page }) => { + // Given the current symbol has only a long position and the trade form starts in close mode. + const scenario = createCancelScenario({ positions: POSITION_SETS.current, ui: { tradeMode: 'CLOSE' }, - })); + }); + // When the user opens the generated userscript panel. + await openUserscriptScenario(page, scenario); + // Then the long direction is enabled, the short direction is disabled, and the visual contract is unchanged. const panel = page.locator(PANEL_SELECTOR); await expect(panel.getByRole('radio', { name: '平多' })).toBeEnabled(); await expect(panel.getByRole('radio', { name: '平空' })).toBeDisabled(); diff --git a/e2e/binance-orderbook/specs/precision-controls.pw.js b/e2e/binance-orderbook/specs/precision-controls.pw.js index b20722d..94d13c5 100644 --- a/e2e/binance-orderbook/specs/precision-controls.pw.js +++ b/e2e/binance-orderbook/specs/precision-controls.pw.js @@ -20,13 +20,16 @@ async function expectPrecisionOptions(page, options, current) { await expect(panel.locator('[data-orderbook-precision-value]').first()).toBeEnabled(); } -test('precision bootstrap shows every native numeric shortcut without selecting an option', async ({ page }) => { +test('user sees every native precision shortcut without an automatic precision selection', async ({ page }) => { + // Given the native precision menu contains three micro-price options and starts at the smallest. const options = ['0.000001', '0.00001', '0.0001']; + // When the user opens the generated panel with its native dropdown closed. const { errors } = await openUserscriptScenario(page, createCancelScenario({ ui: { orderbookPrecision: options[0] }, host: { precisionOptions: options }, })); + // Then bootstrap reads and closes the owned menu without selecting any option. await expectPrecisionOptions(page, options, options[0]); await expect(page.locator(NATIVE_ROOT_SELECTOR + ' .tick-content')).toHaveText(options[0]); await expect(page.locator(NATIVE_ROOT_SELECTOR + ' [aria-controls]')).toHaveCount(0); @@ -39,11 +42,13 @@ test('precision bootstrap shows every native numeric shortcut without selecting expect(errors).toEqual([]); }); -test('selecting a native precision portal option updates the numeric shortcuts', async ({ page }) => { +test('user changes precision through the native portal and sees matching panel shortcuts', async ({ page }) => { + // Given the generated shortcuts match the current native precision options. const scenario = createCancelScenario(); const { errors } = await openUserscriptScenario(page, scenario); await expectPrecisionOptions(page, scenario.host.precisionOptions, scenario.ui.orderbookPrecision); + // When the user opens the native portal and selects 0.001. await page.locator(NATIVE_ROOT_SELECTOR + ' .bn-select-trigger').click(); const listbox = page.getByRole('listbox'); await expect(listbox).toBeVisible(); @@ -51,6 +56,7 @@ test('selecting a native precision portal option updates the numeric shortcuts', await expect(page.locator(NATIVE_ROOT_SELECTOR + ' [role="listbox"]')).toHaveCount(0); await listbox.getByRole('option', { name: '0.001', exact: true }).click(); + // Then the exact selection updates the native field and panel once and closes its portal. await expectPrecisionOptions(page, scenario.host.precisionOptions, '0.001'); await expect(page.locator(NATIVE_ROOT_SELECTOR + ' .tick-content')).toHaveText('0.001'); await expect(listbox).toHaveCount(0); @@ -60,12 +66,14 @@ test('selecting a native precision portal option updates the numeric shortcuts', }); for (const scope of ['select', 'root']) { - test('precision shortcuts reacquire a replaced native ' + scope + ' for the same symbol', async ({ page }) => { + test(`user uses precision shortcuts after replacing the native ${scope} for the same symbol`, async ({ page }) => { + // Given the current symbol has a ready native Select and its original node identities are captured. const scenario = createCancelScenario(); const { errors } = await openUserscriptScenario(page, scenario); await expectPrecisionOptions(page, scenario.host.precisionOptions, scenario.ui.orderbookPrecision); const previousRoot = await page.locator(NATIVE_ROOT_SELECTOR).elementHandle(); const previousSelect = await page.locator(NATIVE_ROOT_SELECTOR + ' .bn-select').elementHandle(); + // When the host replaces the specified native control and the user selects 0.01. const replacement = await page.evaluate((input) => ( window.__BINANCE_FIXTURE__.replacePrecisionControl(input) ), { @@ -75,10 +83,15 @@ for (const scope of ['select', 'root']) { options: scenario.host.precisionOptions, }); + // Then the shortcut uses the new owned listbox and never selects from the detached control. expect(await previousRoot.evaluate((node) => node.isConnected)).toBe(scope === 'select'); expect(await previousSelect.evaluate((node) => node.isConnected)).toBe(false); expect(replacement.listboxId).not.toBe(replacement.previousListboxId); + + // When the user applies the precision shortcut against the replacement control. await page.locator(PANEL_SELECTOR + ' [data-orderbook-precision-value="0.01"]').click(); + + // Then the native selection comes from the replacement's exact owned listbox. await expectPrecisionOptions(page, scenario.host.precisionOptions, '0.01'); await expect(page.locator(NATIVE_ROOT_SELECTOR + ' .tick-content')).toHaveText('0.01'); await expect(page.locator('.bn-select-bubble')).toHaveCount(0); @@ -92,11 +105,13 @@ for (const scope of ['select', 'root']) { }); } -test('precision shortcuts follow each symbol native menu across an A to B to A switch', async ({ page }) => { +test('user restores symbol-specific precision shortcuts after switching from A to B and back', async ({ page }) => { + // Given the first symbol has its own native options and the second symbol uses whole-number options. const scenario = createCancelScenario(); const { errors } = await openUserscriptScenario(page, scenario); await expectPrecisionOptions(page, scenario.host.precisionOptions, scenario.ui.orderbookPrecision); const otherOptions = ['1', '10', '100', '1000']; + // When the user switches to the other symbol and later returns to the original symbol. await page.evaluate((input) => window.__BINANCE_FIXTURE__.replacePrecisionControl(input), { scope: 'root', symbol: OTHER_SYMBOL, @@ -104,21 +119,33 @@ test('precision shortcuts follow each symbol native menu across an A to B to A s options: otherOptions, }); + // Then each selection belongs to that symbol and current native portal without submitting or cancelling orders. await expect(page).toHaveURL('https://www.binance.com/zh-CN/futures/' + OTHER_SYMBOL); await expectPrecisionOptions(page, otherOptions, '10'); + + // When the user selects the other symbol's whole-number precision. await page.locator(PANEL_SELECTOR + ' [data-orderbook-precision-value="100"]').click(); + + // Then that symbol's native field and numeric shortcuts both select 100. await expectPrecisionOptions(page, otherOptions, '100'); await expect(page.locator(NATIVE_ROOT_SELECTOR + ' .tick-content')).toHaveText('100'); + // When the user returns to the original symbol with its remembered native precision. await page.evaluate((input) => window.__BINANCE_FIXTURE__.replacePrecisionControl(input), { scope: 'root', symbol: CURRENT_SYMBOL, value: scenario.ui.orderbookPrecision, options: scenario.host.precisionOptions, }); + + // Then the original symbol's options and selected precision are restored. await expect(page).toHaveURL('https://www.binance.com/zh-CN/futures/' + CURRENT_SYMBOL); await expectPrecisionOptions(page, scenario.host.precisionOptions, scenario.ui.orderbookPrecision); + + // When the user chooses 0.01 from the original symbol's restored shortcuts. await page.locator(PANEL_SELECTOR + ' [data-orderbook-precision-value="0.01"]').click(); + + // Then the two selections remain attached to their own symbols without financial actions. await expectPrecisionOptions(page, scenario.host.precisionOptions, '0.01'); await expect(page.locator(NATIVE_ROOT_SELECTOR + ' .tick-content')).toHaveText('0.01'); await expect(page.locator('.bn-select-bubble')).toHaveCount(0); diff --git a/e2e/binance-orderbook/specs/strategy29-coexistence.pw.js b/e2e/binance-orderbook/specs/strategy29-coexistence.pw.js index 1d73a9e..624b610 100644 --- a/e2e/binance-orderbook/specs/strategy29-coexistence.pw.js +++ b/e2e/binance-orderbook/specs/strategy29-coexistence.pw.js @@ -17,10 +17,12 @@ function strategy29Sandbox(source) { } for (const first of [true, false]) { - test(`independent generated scripts share chart coordination (Strategy29 first=${first})`, async ({ page }) => { + test(`user runs independent Strategy29 and orderbook scripts together (Strategy29 first=${first})`, async ({ page }) => { + // Given both complete generated artifacts are injected in the declared order with the remote summary disabled. const sandboxedStrategy29 = strategy29Sandbox(strategy29); const { errors } = await openUserscriptScenario(page, createCancelScenario(), first ? { beforeOrderbook: sandboxedStrategy29 } : { afterOrderbook: sandboxedStrategy29 }); + // When the host exposes a ready chart with deterministic candles and drawing operations. await page.evaluate(symbol => { const api = document.querySelector('.chart-widget-root iframe').contentWindow.tradingViewApi; const shapes = new Map(); @@ -56,6 +58,7 @@ for (const first of [true, false]) { api.activeChart = () => chart; api.saveChart = callback => callback({ drawings: ['foreign-channel'] }); }, CURRENT_SYMBOL); + // Then Strategy29 draws nine markers and shares the existing orderbook coordination owner without embedding its detector. await expect.poll(() => page.evaluate(() => window.__TM_STRATEGY29_DEBUG__.diagnostics.layerSize)).toBe(9); expect(await page.evaluate(() => ({ embedded: Object.hasOwn(window.__TM_CLOSE_LONG_DEBUG__, 'bollingerAlertState'), @@ -63,10 +66,16 @@ for (const first of [true, false]) { Symbol.for('jh-userscripts.chart-marker-save-controller')].version, owners: [...window[Symbol.for('jh-userscripts.chart-mutation-owners')].predicates.keys()], }))).toEqual({ embedded: false, controller: 1, owners: ['orderbook'] }); - // Reinjecting the complete standalone artifact must reuse its page singleton. + // When the same complete Strategy29 artifact is injected again. await page.addScriptTag({ content: sandboxedStrategy29 }); + + // Then the existing page singleton keeps exactly nine markers. expect(await page.evaluate(() => window.__TM_STRATEGY29_DEBUG__.diagnostics.layerSize)).toBe(9); + + // When the Strategy29 runtime is disposed. await page.evaluate(() => window.__TM_STRATEGY29_DEBUG__.dispose()); + + // Then its chart shapes are all removed without uncaught errors. expect(await page.evaluate(() => document.querySelector('.chart-widget-root iframe').contentWindow.tradingViewApi.activeChart().getAllShapes())).toEqual([]); expect(errors).toEqual([]); }); diff --git a/e2e/binance-orderbook/specs/strategy29-panel-drag.pw.js b/e2e/binance-orderbook/specs/strategy29-panel-drag.pw.js index 2716b8a..411ac22 100644 --- a/e2e/binance-orderbook/specs/strategy29-panel-drag.pw.js +++ b/e2e/binance-orderbook/specs/strategy29-panel-drag.pw.js @@ -1,5 +1,5 @@ import { readFile } from 'node:fs/promises'; -import { test, expect } from '@playwright/test'; +import { test, expect } from '../test.js'; const source = await readFile(new URL('../../../src/binance-strategy29-bollinger/dom/panel-position.js', import.meta.url), 'utf8'); const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`; @@ -19,7 +19,8 @@ async function install(page) { }, moduleUrl); } -test('header drag crosses a chart iframe, releases capture and restores the saved position', async ({ page }) => { +test('user drags a panel across the chart iframe and restores its saved position after reload', async ({ page }) => { + // Given a positioned panel overlays an interactive chart iframe and has persistent position storage. await page.route(fixtureUrl, route => route.fulfill({ contentType: 'text/html', body: ` @@ -28,19 +29,33 @@ test('header drag crosses a chart iframe, releases capture and restores the save ` })); await page.goto(fixtureUrl); await install(page); + // When the user drags the panel header across the iframe and releases the pointer. await page.mouse.move(170, 315); await page.mouse.down(); await page.mouse.move(230, 275); await page.mouse.up(); + // Then the panel stores the exact new position, releases the chart, and restores the saved location after reload. await expect.poll(() => page.evaluate(() => window.savedPositions)).toEqual([{ left: 160, top: 260 }]); await expect(page.locator('section')).toHaveCSS('left', '160px'); await expect(page.locator('section')).toHaveCSS('top', '260px'); + + // When the user clicks the chart after releasing the panel header. await page.frameLocator('iframe').getByRole('button', { name: 'Chart control' }).click(); + + // Then pointer capture no longer blocks the chart control. await expect(page.frameLocator('iframe').locator('body')).toHaveAttribute('data-clicked', '1'); + + // When the user reloads the page and the panel is installed again. await page.reload(); await install(page); + + // Then the panel restores the exact saved position. await expect(page.locator('section')).toHaveCSS('left', '160px'); await expect(page.locator('section')).toHaveCSS('top', '260px'); + + // When the user clicks the header's collapse button without dragging. await page.locator('header button').click(); + + // Then no spurious drag position is saved. expect(await page.evaluate(() => window.savedPositions)).toEqual([]); }); diff --git a/e2e/binance-orderbook/specs/unicode-symbols.pw.js b/e2e/binance-orderbook/specs/unicode-symbols.pw.js index 596e021..4f9375e 100644 --- a/e2e/binance-orderbook/specs/unicode-symbols.pw.js +++ b/e2e/binance-orderbook/specs/unicode-symbols.pw.js @@ -48,7 +48,8 @@ async function mountDepthChart(page, symbol) { } for (const symbol of ['BTCUSDT', '龙虾USDT', '4USDT']) { - test(`generated userscript renders native depth for ${symbol}`, async ({ page }, testInfo) => { + test(`user sees native depth for the complete ${symbol} symbol`, async ({ page }, testInfo) => { + // Given the generated script runs on the declared symbol with one native depth socket and snapshot route. await page.route('**/*', (route) => route.abort('blockedbyclient')); const { errors } = await openUserscriptScenario(page, createCancelScenario({ currentSymbol: symbol }), { beforeOrderbook: nativeSocketFixture, @@ -66,6 +67,7 @@ for (const symbol of ['BTCUSDT', '龙虾USDT', '4USDT']) { }); await mountDepthChart(page, symbol); await expect(page.locator('#jh-binance-depth-profile canvas')).toBeVisible(); + // When the native socket and snapshot deliver depth updates for that exact symbol. await page.evaluate(async (symbol) => { const socket = new WebSocket('wss://depth-fixture.invalid/ws'); const response = fetch(`/fapi/v1/rpiDepth?${new URLSearchParams({ symbol, limit: '1000' })}`); @@ -76,6 +78,7 @@ for (const symbol of ['BTCUSDT', '龙虾USDT', '4USDT']) { }) })); await (await response).json(); }, symbol); + // Then the canvas paints both sides with a ready native book and no trade or cancellation actions. await expect.poll(() => page.evaluate(() => window.__TM_CLOSE_LONG_DEBUG__.nativeDepthState)).toMatchObject({ status: { symbol, status: 'ready' }, bidCount: 2, askCount: 2, }); @@ -100,25 +103,31 @@ for (const symbol of ['BTCUSDT', '龙虾USDT', '4USDT']) { } for (const symbol of ['龙虾USDT', '4USDT']) { - test(`generated cancel flow recognizes ${symbol} without consuming another contract`, async ({ page }) => { + test(`user cancels ${symbol} orders without matching a longer contract name`, async ({ page }) => { + // Given the account has one exact-symbol Basic order and one longer contract containing that symbol. await page.route('**/*', (route) => route.abort('blockedbyclient')); const orders = [ - { id: 'current', symbol, side: 'SELL', price: '90', quantity: '0.01' }, - { id: 'other', symbol: `超级${symbol}`, side: 'SELL', price: '90', quantity: '0.01' }, + { id: 'current', symbol, kind: 'basic', side: 'SELL', price: '90', quantity: '0.01' }, + { id: 'other', symbol: `超级${symbol}`, kind: 'basic', side: 'SELL', price: '90', quantity: '0.01' }, ]; const { errors } = await openUserscriptScenario(page, createCancelScenario({ currentSymbol: symbol, orders })); + // When the user first inspects and dismisses native cancellation. await page.getByRole('button', { name: '撤单', exact: true }).click(); await expect(page.getByRole('dialog')).toBeVisible(); await expect(page.locator('[data-order-id="current"]')).toBeVisible(); await expect(page.locator('[data-order-id="other"]')).toHaveCount(0); expect((await readFixtureState(page)).events.filter((event) => event.type === 'cancel-requested')).toEqual([]); await page.getByRole('button', { name: '取消', exact: true }).click(); + // Then both orders survive dismissal before a second explicitly confirmed cancellation is exercised. await expect(page.getByText('撤单已取消')).toBeVisible(); expect((await readFixtureState(page)).orders).toEqual(orders); + // When the user requests cancellation again and explicitly confirms it. await page.getByRole('button', { name: '撤单', exact: true }).click(); await expect(page.getByRole('dialog')).toBeVisible(); await page.getByRole('button', { name: '确认', exact: true }).click(); + + // Then only the exact-symbol order is cancelled and the original view is restored. await expect(page.getByText('撤单已完成')).toBeVisible(); const state = await readFixtureState(page); expect(state.orders).toEqual([orders[1]]); diff --git a/e2e/binance-orderbook/specs/userscript-performance.pw.js b/e2e/binance-orderbook/specs/userscript-performance.pw.js index c240d9c..d0be14f 100644 --- a/e2e/binance-orderbook/specs/userscript-performance.pw.js +++ b/e2e/binance-orderbook/specs/userscript-performance.pw.js @@ -1,9 +1,10 @@ import { fileURLToPath } from 'node:url'; -import { test, expect } from '@playwright/test'; +import { test, expect } from '../test.js'; const artifact = name => fileURLToPath(new URL(`../../../scripts/${name}.user.js`, import.meta.url)); -test('CMC valuation updates coalesce while real layout reads stay on the metric cards', async ({ page }, testInfo) => { +test('user sees updated valuation labels while price changes avoid unrelated layout reads', async ({ page }, testInfo) => { + // Given the page has two valuation cards, one thousand quote rows, and real layout-read instrumentation. const errors = []; page.on('pageerror', error => errors.push(error.message)); await page.route('**/*', route => route.abort('blockedbyclient')); @@ -35,10 +36,14 @@ test('CMC valuation updates coalesce while real layout reads stay on the metric return query(selector); }; }); + // When the user loads the valuation helper. await page.addScriptTag({ path: artifact('coinmarketcap-valuation-helper') }); + // Then the two cards get correct labels and highlights before burst updates are measured. await expect(page.locator('#cap')).toHaveText('流通市值'); await expect(page.locator('#fdv')).toHaveText('FDV/总估值'); await expect(page.locator('.jh-cmc-valuation-highlight')).toHaveCount(2); + + // When ten quote updates arrive before the next two rendering frames. const result = await page.evaluate(async () => { const stats = window.performanceFixture; const before = { ...stats }; @@ -54,13 +59,16 @@ test('CMC valuation updates coalesce while real layout reads stay on the metric layoutReads: stats.layoutReads - before.layoutReads, }; }); + + // Then one coalesced scan reads only the metric-card geometry. expect(result).toEqual({ initialQuoteLayoutReads: 0, quoteLayoutReads: 0, scans: 1, layoutReads: 8 }); expect(errors).toEqual([]); await testInfo.attach('operation-counts', { body: JSON.stringify(result, null, 2), contentType: 'application/json' }); await page.locator('#stats').screenshot({ path: testInfo.outputPath('valuation-cards.png') }); }); -test('m3u8 discovery inspects one changed video in a real 100-video document', async ({ page }, testInfo) => { +test('user discovers a changed video source without rescanning the other ninety-nine videos', async ({ page }, testInfo) => { + // Given one hundred native video elements are instrumented and the generated downloader is installed. const errors = []; page.on('pageerror', error => errors.push(error.message)); await page.route('**/*', route => route.abort('blockedbyclient')); @@ -88,6 +96,7 @@ test('m3u8 discovery inspects one changed video in a real 100-video document', a }; }); await page.addScriptTag({ path: artifact('m3u8-downloader') }); + // When the source of video 42 changes to a playlist URL. const result = await page.evaluate(async () => { const stats = window.performanceFixture; stats.documentScans = 0; @@ -96,6 +105,7 @@ test('m3u8 discovery inspects one changed video in a real 100-video document', a await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); return stats; }); + // Then only that video is inspected and exactly its playlist URL is requested. expect(result).toEqual({ documentScans: 0, videoIds: ['v42'], requests: ['https://media.example/changed.m3u8'] }); expect(errors).toEqual([]); await testInfo.attach('operation-counts', { body: JSON.stringify(result, null, 2), contentType: 'application/json' }); diff --git a/e2e/binance-orderbook/test.js b/e2e/binance-orderbook/test.js index 1383c60..81982bb 100644 --- a/e2e/binance-orderbook/test.js +++ b/e2e/binance-orderbook/test.js @@ -1,4 +1,8 @@ import { test as base, expect } from '@playwright/test'; +import { + startBrowserCoverage, + finishBrowserCoverage, +} from '../../scripts/test-coverage/collect-browser.mjs'; import { readFixtureState, @@ -13,6 +17,16 @@ function jsonAttachment(value) { } export const test = base.extend({ + sourceCoverage: [async ({ page }, use, testInfo) => { + const directory = process.env.USERSCRIPTS_BROWSER_COVERAGE_DIRECTORY; + if (!directory) { + await use(); + return; + } + await startBrowserCoverage(page); + await use(); + await finishBrowserCoverage(page, directory, testInfo); + }, { auto: true }], // Failure evidence belongs to the test runner boundary so every future scenario // receives the same diagnostics without duplicating cleanup code in each spec. failureEvidence: [async ({ page }, use, testInfo) => { diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..2f052ad --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,37 @@ +import testPolicy from './scripts/test-policy/eslint-plugin.js'; +import { contractCallAllowances, legacyBehaviorFiles, legacyCallAllowances } from './scripts/test-policy/migration-inventory.js'; + +export default [ + { + name: 'historical-install-artifacts', + ignores: ['test/fixtures/**'], + }, + { + name: 'test-policy', + files: ['test/**/*.{js,mjs}', 'e2e/**/*.{js,mjs}'], + languageOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + linterOptions: { noInlineConfig: true }, + plugins: { 'test-policy': testPolicy }, + rules: { + 'test-policy/no-focused-tests': 'error', + 'test-policy/no-uncontracted-mocks': 'error', + 'test-policy/no-fixed-waits': 'error', + 'test-policy/no-vacuous-tests': 'error', + }, + }, + { + name: 'behavioral-test-contracts', + files: ['test/**/*.test.js', 'e2e/**/specs/**/*.pw.js'], + rules: { 'test-policy/behavior-contract': 'error' }, + }, + { + name: 'explicit-legacy-behavior-inventory', + files: legacyBehaviorFiles, + rules: { 'test-policy/behavior-contract': 'off' }, + }, + ...[...legacyCallAllowances, ...contractCallAllowances].map(({ file, rule, allow }) => ({ + name: `bounded-call-inventory:${file}:${rule}`, + files: [file], + rules: { [`test-policy/${rule}`]: ['error', { allow }] }, + })), +]; diff --git a/package-lock.json b/package-lock.json index dc29107..fe1fa11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,9 +5,13 @@ "packages": { "": { "devDependencies": { + "@jridgewell/sourcemap-codec": "1.6.0", "@playwright/test": "^1.62.1", + "acorn": "8.18.0", "esbuild": "^0.25.0", - "jsdom": "^29.1.1" + "eslint": "10.10.0", + "jsdom": "^29.1.1", + "monocart-coverage-reports": "2.13.0" } }, "node_modules/@asamuzakjp/css-color": { @@ -74,6 +78,30 @@ "specificity": "bin/cli.js" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", @@ -639,6 +667,113 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@exodus/bytes": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", @@ -657,6 +792,103 @@ } } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@playwright/test": { "version": "1.62.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", @@ -673,6 +905,103 @@ "node": ">=20" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-loose": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/acorn-loose/-/acorn-loose-8.5.2.tgz", + "integrity": "sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -683,6 +1012,65 @@ "require-from-string": "^2.0.2" } }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/console-grid": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/console-grid/-/console-grid-2.2.4.tgz", + "integrity": "sha512-OLjCRTiHhOpTRo9lQp/2FgJDyq5uQHwkEmVJulEnQ6JVf27oKKzXHZnNOv/e72V4++UdMZCrDWtvXW5sx4lyQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -711,6 +1099,24 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -718,6 +1124,20 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/eight-colors": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/eight-colors/-/eight-colors-1.3.3.tgz", + "integrity": "sha512-4B54S2Qi4pJjeHmCbDIsveQZWQ/TSSQng4ixYJ9/SYHHpeS5nYK0pzcHvWzWUfRsvJQjwoIENhAwqg59thQceg==", + "dev": true, + "license": "MIT" + }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -788,45 +1208,435 @@ "node": ">=18" } }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "node_modules/eslint": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz", + "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { - "@exodus/bytes": "^1.6.0" + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, - "license": "MIT" + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } }, - "node_modules/jsdom": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", - "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-4.0.3.tgz", + "integrity": "sha512-yeXZaNbCBGaT9giTpLPBdtedzjwhlJBUoL/R4BVQU5mn0TQXOHwVIl1Q2DMuBIdNno4ktA1abZ7dQFVxD6uHxw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "dev": true, "license": "MIT", "dependencies": { @@ -864,6 +1674,60 @@ } } }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lru-cache": { "version": "11.5.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", @@ -874,6 +1738,29 @@ "node": "20 || >=22" } }, + "node_modules/lz-utils": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/lz-utils/-/lz-utils-2.1.1.tgz", + "integrity": "sha512-d3Thjos0PSJQAoyMj6vipSSrtrRHS7DImqUNR8x9NW3+zQIftPIbMJAWhi5nPdg5Q9zHz6lxtN8kp/VdMlhi/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -881,6 +1768,117 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/monocart-coverage-reports": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/monocart-coverage-reports/-/monocart-coverage-reports-2.13.0.tgz", + "integrity": "sha512-RJfwzbn+EvR0OhCSU3bX2mKd0tSv4RLvood0weoCZV1L99+kBYH0Yq5SnnBFRik7nRoEoGB4mLuZAvO1Fr3DpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.18.0", + "acorn-loose": "^8.5.2", + "acorn-walk": "^8.3.5", + "commander": "^14.0.3", + "console-grid": "^2.2.4", + "eight-colors": "^1.3.3", + "foreground-child": "^4.0.3", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "lz-utils": "^2.1.1", + "monocart-locator": "^1.0.3" + }, + "bin": { + "mcr": "lib/cli.js" + } + }, + "node_modules/monocart-locator": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/monocart-locator/-/monocart-locator-1.0.3.tgz", + "integrity": "sha512-pe29W2XAoA1WQmZZqxXoP7s06ZEXUhcb81086v68cqjk1HnVL7Q/iU/WJnnetxjPcLqwb4qG8vaSGUOMQU602g==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -894,6 +1892,26 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/playwright": { "version": "1.62.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", @@ -926,6 +1944,16 @@ "node": ">=20" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -936,6 +1964,26 @@ "node": ">=6" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -959,6 +2007,55 @@ "node": ">=v12.22.7" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -969,6 +2066,19 @@ "node": ">=0.10.0" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -1022,6 +2132,19 @@ "node": ">=20" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/undici": { "version": "7.26.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", @@ -1032,6 +2155,16 @@ "node": ">=20.18.1" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -1080,6 +2213,32 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -1096,6 +2255,19 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 3f036cf..c246ae3 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,11 @@ "check:binance-orderbook-trade": "node --check scripts/binance-orderbook-trade.user.js", "check:m3u8-downloader": "node --check scripts/m3u8-downloader.user.js", "test": "node --test 'test/unit/**/*.test.js' 'test/dom/**/*.test.js'", + "lint:tests": "eslint test e2e --max-warnings=0", + "test:test-policy": "node --test test/unit/test-policy.test.js", + "test:affected": "node scripts/test-selection/run.mjs", + "test:coverage": "node scripts/test-coverage/run.mjs all", + "test:coverage:node": "node scripts/test-coverage/run.mjs node", "test:binance-orderbook-trade": "node --test test/unit/binance-orderbook-trade/*.test.js test/dom/binance-orderbook-trade/*.test.js", "test:binance-orderbook-ui-toolchain": "node --test test/unit/binance-orderbook-trade/*.test.js test/dom/binance-orderbook-trade/*.test.js test/unit/binance-strategy29-bollinger/*.test.js test/dom/binance-strategy29-bollinger/*.test.js test/unit/binance-*.test.js", "test:binance-strategy27-events": "node --test test/unit/binance-strategy27-events/*.test.js test/dom/binance-strategy27-events/*.test.js test/unit/userscript-metadata-icons.test.js test/unit/userscript-release-contract.test.js", @@ -33,8 +38,12 @@ "test:ui:debug": "playwright test --debug" }, "devDependencies": { + "@jridgewell/sourcemap-codec": "1.6.0", "@playwright/test": "^1.62.1", + "acorn": "8.18.0", "esbuild": "^0.25.0", - "jsdom": "^29.1.1" + "eslint": "10.10.0", + "jsdom": "^29.1.1", + "monocart-coverage-reports": "2.13.0" } } diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index c0a7f02..6ce8995 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.208 +// @version 2.7.209 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -1028,15 +1028,6 @@ for (let i = 0; i < actualLevels; i += 1) { const isLast = i === actualLevels - 1; const steps = isLast ? remainingSteps : baseSteps; - if (steps < minSteps) { - if (quantities.length === 0) return null; - const previous = decimalToStepCount(quantities.pop(), stepSize, "floor"); - const merged = previous + steps; - if (merged < minSteps) return null; - quantities.push(formatStepCount(merged, stepSize)); - remainingSteps = 0n; - break; - } quantities.push(formatStepCount(steps, stepSize)); remainingSteps -= steps; } @@ -1582,7 +1573,7 @@ `${progress.completedRounds}/${progress.startedRounds} 轮`, `${progress.completedRounds}/${progress.startedRounds} rounds` )); - if (progress.lastRound?.plannedOrders !== null) { + if (progress.lastRound !== null && progress.lastRound.plannedOrders !== null) { parts.push(localizedText( `本轮 ${progress.lastRound.currentPlanSubmittedOrders}/${progress.lastRound.plannedOrders} 笔`, `This round ${progress.lastRound.currentPlanSubmittedOrders}/${progress.lastRound.plannedOrders}` diff --git a/scripts/test-coverage/branch-policy.json b/scripts/test-coverage/branch-policy.json new file mode 100644 index 0000000..1902816 --- /dev/null +++ b/scripts/test-coverage/branch-policy.json @@ -0,0 +1,12 @@ +{ + "minimumBranches": 66.5, + "criticalSources": [ + "src/binance-orderbook-trade/core/cancel-orders.js", + "src/binance-orderbook-trade/core/close-action.js", + "src/binance-orderbook-trade/core/close-ladder-recovery.js", + "src/binance-orderbook-trade/core/continuous-ladder.js", + "src/binance-orderbook-trade/core/order-feedback.js", + "src/binance-orderbook-trade/core/quantity.js", + "src/binance-orderbook-trade/core/chart-save-coalescer.js" + ] +} diff --git a/scripts/test-coverage/browser-reporter.mjs b/scripts/test-coverage/browser-reporter.mjs new file mode 100644 index 0000000..7cbbca3 --- /dev/null +++ b/scripts/test-coverage/browser-reporter.mjs @@ -0,0 +1,26 @@ +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { relativeSourcePath } from './config.mjs'; + +/** Test completion and coverage completion are separate evidence requirements. */ +export default class BrowserCoverageReporter { + onBegin(config, suite) { + this.tests = suite.allTests().map((test) => ({ + id: test.id, + file: relativeSourcePath(test.location.file), + title: test.titlePath().join(' > '), + })); + this.results = new Map(); + } + + onTestEnd(test, result) { + this.results.set(test.id, { status: result.status, retry: result.retry }); + } + + async onEnd(result) { + await writeFile(resolve(process.env.USERSCRIPTS_BROWSER_COVERAGE_DIRECTORY, 'manifest.json'), JSON.stringify({ + status: result.status, + tests: this.tests.map((test) => ({ ...test, result: this.results.get(test.id) })), + }, null, 2) + '\n'); + } +} diff --git a/scripts/test-coverage/capture-contract.mjs b/scripts/test-coverage/capture-contract.mjs new file mode 100644 index 0000000..b5541d0 --- /dev/null +++ b/scripts/test-coverage/capture-contract.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { COVERAGE_SELF_TEST_FILES } from './config.mjs'; + +export function verifyCaptures({ expectedNodeTests, capturedNodeTests, browserManifest, capturedBrowserTests }) { + assert.deepEqual(expectedNodeTests.filter((path) => !capturedNodeTests.has(path)), [], + 'Every selected Node test process must finish its coverage capture'); + if (browserManifest === null) { + assert.equal(capturedBrowserTests.size, 0, 'Node-only reports cannot include browser captures'); + return { nodeFiles: expectedNodeTests.length, browserScenarios: 0, collectorScenarios: 0 }; + } + assert.equal(browserManifest.status, 'passed', 'The complete browser run must pass'); + assert.ok(browserManifest.tests.length > 0, 'A browser run must contain tests'); + const expected = []; + for (const test of browserManifest.tests) { + assert.deepEqual(test.result, { status: 'passed', retry: 0 }, + 'Every selected browser test must pass without skips or retries: ' + test.title); + if (!COVERAGE_SELF_TEST_FILES.includes(test.file)) expected.push(test.id); + } + assert.deepEqual(expected.filter((id) => !capturedBrowserTests.has(id)), [], + 'Every production browser scenario must finish its coverage capture'); + const allIds = new Set(browserManifest.tests.map((test) => test.id)); + assert.deepEqual([...capturedBrowserTests].filter((id) => !allIds.has(id)), [], + 'Coverage captures must belong to the current browser run'); + return { + nodeFiles: expectedNodeTests.length, + browserScenarios: expected.length, + collectorScenarios: browserManifest.tests.length - expected.length, + }; +} diff --git a/scripts/test-coverage/collect-browser.mjs b/scripts/test-coverage/collect-browser.mjs new file mode 100644 index 0000000..34f1313 --- /dev/null +++ b/scripts/test-coverage/collect-browser.mjs @@ -0,0 +1,30 @@ +import { randomUUID } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { ROOT, productionSourceFiles, relativeSourcePath } from './config.mjs'; + +let originals; + +async function originalSources() { + originals ??= productionSourceFiles().then(async (paths) => new Set( + await Promise.all(paths.map((path) => readFile(resolve(ROOT, path), 'utf8'))), + )); + return originals; +} + +export async function startBrowserCoverage(page) { + await page.coverage.startJSCoverage({ resetOnNavigation: false, reportAnonymousScripts: true }); +} + +export async function finishBrowserCoverage(page, outputDirectory, testInfo) { + const sources = await originalSources(); + const entries = (await page.coverage.stopJSCoverage()).filter((entry) => ( + typeof entry.source === 'string' + && (entry.source.includes('// ==UserScript==') || sources.has(entry.source)) + )); + await writeFile(resolve(outputDirectory, randomUUID() + '.json'), JSON.stringify({ + testId: testInfo.testId, + testFile: relativeSourcePath(testInfo.file), + entries, + })); +} diff --git a/scripts/test-coverage/collect-node.mjs b/scripts/test-coverage/collect-node.mjs new file mode 100644 index 0000000..a171d03 --- /dev/null +++ b/scripts/test-coverage/collect-node.mjs @@ -0,0 +1,39 @@ +import { Session } from 'node:inspector/promises'; +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { isProductionSource, relativeSourcePath } from './config.mjs'; + +const outputDirectory = process.env.USERSCRIPTS_NODE_COVERAGE_DIRECTORY; +if (!outputDirectory) throw new Error('Node coverage requires an explicit output directory'); +const session = new Session(); +session.connect(); +await session.post('Debugger.enable'); +await session.post('Profiler.enable'); +await session.post('Profiler.startPreciseCoverage', { callCount: true, detailed: true }); + +async function collect() { + const { result } = await session.post('Profiler.takePreciseCoverage'); + const entries = []; + for (const entry of result) { + const direct = isProductionSource(entry.url); + const candidate = direct || !entry.url || entry.url === 'evalmachine.' + || entry.url.startsWith('https://www.binance.com/') + || /\/scripts\/[^/]+\.user\.js$/.test(entry.url); + if (!candidate) continue; + const { scriptSource: source } = await session.post('Debugger.getScriptSource', { scriptId: entry.scriptId }); + if (direct || source.includes('// ==UserScript==')) entries.push({ ...entry, source }); + } + const testFile = typeof process.argv[1] === 'string' ? relativeSourcePath(process.argv[1]) : null; + await writeFile(resolve(outputDirectory, process.pid + '.json'), JSON.stringify({ testFile, entries })); + await session.post('Profiler.stopPreciseCoverage'); + session.disconnect(); +} + +process.once('beforeExit', () => { + collect().catch((error) => { + // A collector failure invalidates this test run instead of producing a partial green report. + process.exitCode = 1; + process.stderr.write('Coverage collection failed: ' + error.message + '\n'); + session.disconnect(); + }); +}); diff --git a/scripts/test-coverage/config.mjs b/scripts/test-coverage/config.mjs new file mode 100644 index 0000000..4aa546d --- /dev/null +++ b/scripts/test-coverage/config.mjs @@ -0,0 +1,44 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +export const COVERAGE_DIRECTORY = resolve(ROOT, 'test-results/coverage'); +export const BRANCH_TARGET = 90; +// This spec validates the collector with virtual code, not production behavior. +export const COVERAGE_SELF_TEST_FILES = ['e2e/binance-orderbook/specs/coverage-merge.pw.js']; +export const HAND_MAINTAINED_SCRIPTS = [ + 'scripts/auto_refresh.user.js', + 'scripts/coinmarketcap-valuation-helper.user.js', +]; + +export function relativeSourcePath(value) { + const path = value.startsWith('file:') ? fileURLToPath(value) : value; + return (isAbsolute(path) ? relative(ROOT, path) : path).split(sep).join('/'); +} + +export function isProductionSource(value) { + const path = relativeSourcePath(value); + return (path.startsWith('src/') && path.endsWith('.js')) + || HAND_MAINTAINED_SCRIPTS.includes(path); +} + +export async function productionSourceFiles() { + async function walk(directory) { + const entries = await readdir(resolve(ROOT, directory), { withFileTypes: true }); + const files = await Promise.all(entries.map((entry) => { + const path = directory + '/' + entry.name; + return entry.isDirectory() ? walk(path) : [path]; + })); + return files.flat(); + } + return [...await walk('src'), ...HAND_MAINTAINED_SCRIPTS] + .filter(isProductionSource).sort(); +} + +export async function assertProjectNodeVersion() { + const expected = (await readFile(resolve(ROOT, '.nvmrc'), 'utf8')).trim(); + if (process.versions.node !== expected) { + throw new Error('Use the project Node version ' + expected + '; received ' + process.versions.node); + } +} diff --git a/scripts/test-coverage/gates.mjs b/scripts/test-coverage/gates.mjs new file mode 100644 index 0000000..96432ab --- /dev/null +++ b/scripts/test-coverage/gates.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { BRANCH_TARGET } from './config.mjs'; + +function branchRate({ covered, total }) { + assert.ok(Number.isInteger(total) && Number.isInteger(covered) + && total >= 0 && covered >= 0 && covered <= total, 'Invalid branch coverage counts'); + return total === 0 ? 100 : covered / total * 100; +} + +/** Rounded display percentages must never decide whether a threshold passed. */ +export function assessBranchCoverage(coverage, policy, { requireTarget = false } = {}) { + assert.deepEqual(coverage.layers, ['node', 'browser'], 'Coverage gates require both Node and browser execution'); + assert.ok(coverage.summary.branches.total > 0, 'Production coverage needs a nonempty denominator'); + assert.ok(Number.isFinite(policy.minimumBranches) && policy.minimumBranches >= 0 + && policy.minimumBranches <= BRANCH_TARGET, 'Invalid staged branch threshold'); + assert.ok(Array.isArray(policy.criticalSources) && policy.criticalSources.length > 0, + 'Critical coverage sources must be explicit'); + assert.equal(new Set(policy.criticalSources).size, policy.criticalSources.length, 'Duplicate critical coverage source'); + const measured = branchRate(coverage.summary.branches); + const targetMet = measured >= BRANCH_TARGET; + const failures = []; + if (measured < policy.minimumBranches) { + failures.push(`All production sources: ${measured.toFixed(2)}% is below the staged ${policy.minimumBranches}% threshold`); + } + const critical = policy.criticalSources.map((path) => { + const matches = coverage.files.filter((file) => file.path === path); + assert.equal(matches.length, 1, 'Missing or duplicate critical coverage source: ' + path); + const percentage = branchRate(matches[0].summary.branches); + if (percentage < BRANCH_TARGET) failures.push(`${path}: ${percentage.toFixed(2)}% is below ${BRANCH_TARGET}%`); + return { path, percentage, passed: percentage >= BRANCH_TARGET }; + }); + if (requireTarget && !targetMet) { + failures.push(`All production sources: ${measured.toFixed(2)}% is below the final ${BRANCH_TARGET}% target`); + } + return { passed: failures.length === 0, measured, stagedMinimum: policy.minimumBranches, + target: BRANCH_TARGET, targetMet, requireTarget, critical, failures }; +} diff --git a/scripts/test-coverage/merge-proof.mjs b/scripts/test-coverage/merge-proof.mjs new file mode 100644 index 0000000..9144598 --- /dev/null +++ b/scripts/test-coverage/merge-proof.mjs @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { Session } from 'node:inspector/promises'; +import { dirname, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parse } from 'acorn'; +import * as esbuild from 'esbuild'; +import { CoverageReport } from 'monocart-coverage-reports'; + +import { ROOT, assertProjectNodeVersion, relativeSourcePath } from './config.mjs'; +import { composeSourceMap, mapCoverageEntry } from './source-maps.mjs'; +import { findArtifactSegments, splitCoverageEntry } from './split-entries.mjs'; + +const runFile = promisify(execFile); + +export const PROOF_SOURCE = `export function chooseBranch(nodeBranch) { + if (nodeBranch) { + return 'node'; + } else { + return 'browser'; + } +} +`; + +/** All synthetic source, raw captures, and reports stay in this test's output. */ +export async function createMergeProof(outputDirectory) { + await assertProjectNodeVersion(); + await mkdir(outputDirectory, { recursive: true }); + const sourceFile = resolve(outputDirectory, 'branch-source.mjs'); + const sourcePath = relativeSourcePath(sourceFile); + await writeFile(sourceFile, PROOF_SOURCE); + const sources = new Map([[sourcePath, PROOF_SOURCE]]); + const artifacts = []; + for (const globalName of ['CoverageProofMain', 'CoverageProofCompanion']) { + const entryFile = resolve(outputDirectory, globalName + '-entry.mjs'); + const entrySource = "import { chooseBranch } from './branch-source.mjs';\n" + + `globalThis.${globalName} = chooseBranch;\n`; + await writeFile(entryFile, entrySource); + sources.set(relativeSourcePath(entryFile), entrySource); + const outfile = resolve(outputDirectory, globalName + '.js'); + const result = await esbuild.build({ + absWorkingDir: ROOT, + bundle: true, + charset: 'utf8', + format: 'iife', + legalComments: 'none', + minify: false, + platform: 'browser', + sourcemap: 'external', + outfile, + stdin: { contents: entrySource, loader: 'js', resolveDir: outputDirectory, sourcefile: entryFile }, + target: ['es2020'], + write: false, + }); + const code = result.outputFiles.find((file) => file.path === outfile).text; + const map = JSON.parse(result.outputFiles.find((file) => file.path.endsWith('.map')).text); + map.sources = map.sources.map((path, index) => { + const normalized = relativeSourcePath(resolve(dirname(outfile), path)); + assert.equal(map.sourcesContent[index], sources.get(normalized)); + return normalized; + }); + await writeFile(outfile, code); + await writeFile(outfile + '.map', JSON.stringify(map)); + artifacts.push({ globalName, path: relativeSourcePath(outfile), code, map, + body: parse(code, { ecmaVersion: 'latest', sourceType: 'module' }).body }); + } + return { outputDirectory, sourceFile, sourcePath, registry: { sources, artifacts } }; +} + +export function composeProofBrowserSource(proof, copies) { + const chunks = ['globalThis.__coverageProofResults = [];\n']; + for (const [index, copy] of copies.entries()) { + const artifact = proof.registry.artifacts[copy.artifact]; + chunks.push(copy.executed ? '(() => {\n' : `function dormantProofCopy${index}() {\n`); + chunks.push(artifact.code); + for (let call = 0; call < copy.calls; call += 1) { + chunks.push(`globalThis.__coverageProofResults.push(globalThis.${artifact.globalName}(${copy.branch}));\n`); + } + chunks.push(copy.executed ? '})();\n' : '}\n'); + } + const source = chunks.join(''); + const segments = findArtifactSegments(source, proof.registry.artifacts); + assert.equal(segments.length, copies.length); + const map = composeSourceMap(source, segments); + assert.equal(map.sources.filter((path) => path === proof.sourcePath).length, 1); + assert.equal(map.sourcesContent[map.sources.indexOf(proof.sourcePath)], PROOF_SOURCE); + return source; +} + +/** A child inspector cannot reset an outer Node coverage collector's counters. */ +export async function collectProofNodeEntry(proof) { + const outputFile = resolve(proof.outputDirectory, 'node-raw.json'); + await runFile(process.execPath, [fileURLToPath(import.meta.url), 'collect-node', proof.sourceFile, outputFile], { + cwd: ROOT, + timeout: 10_000, + maxBuffer: 64 * 1024, + }); + const capture = JSON.parse(await readFile(outputFile, 'utf8')); + assert.equal(capture.result, 'node'); + assert.equal(capture.entry.source, PROOF_SOURCE); + return capture.entry; +} + +async function captureNode(sourceFile, outputFile) { + const session = new Session(); + session.connect(); + await session.post('Debugger.enable'); + await session.post('Profiler.enable'); + await session.post('Profiler.startPreciseCoverage', { callCount: true, detailed: true }); + try { + const sourceUrl = pathToFileURL(sourceFile).href; + const { chooseBranch } = await import(sourceUrl); + const result = chooseBranch(true); + const coverage = await session.post('Profiler.takePreciseCoverage'); + const entries = coverage.result.filter((entry) => entry.url === sourceUrl); + assert.equal(entries.length, 1, 'The child must capture the complete imported ESM exactly once'); + const { scriptSource: source } = await session.post('Debugger.getScriptSource', { scriptId: entries[0].scriptId }); + assert.equal(source, PROOF_SOURCE); + await writeFile(outputFile, JSON.stringify({ result, entry: { ...entries[0], source } }, null, 2)); + } finally { + await session.post('Profiler.stopPreciseCoverage'); + session.disconnect(); + } +} + +export function mapProofEntries(entries, proof, { split }) { + const selected = split ? entries.flatMap((entry) => splitCoverageEntry(entry, proof.registry)) : entries; + return selected.map((entry) => { + const mapped = mapCoverageEntry(entry, proof.registry); + assert.notEqual(mapped, null, 'Every proof entry must map to its complete known original'); + return mapped; + }); +} + +export async function reportProofEntries(proof, label, entries) { + const outputDir = resolve(proof.outputDirectory, label); + const report = new CoverageReport({ + name: 'Two-engine coverage merge: ' + label, + baseDir: ROOT, + outputDir, + reports: ['v8-json'], + logging: 'error', + sourceFilter: (path) => path === proof.sourcePath, + v8Ignore: false, + }); + // MCR normalizes ranges in place; each independent report needs its own copy. + await report.add(structuredClone(entries)); + const result = await report.generate(); + assert.notEqual(result, undefined, 'A coverage proof must produce a report'); + assert.deepEqual(result.files.map((file) => file.sourcePath), [proof.sourcePath]); + const file = result.files[0]; + assert.equal(file.source, PROOF_SOURCE); + return { + sourcePath: file.sourcePath, + branches: { + covered: file.summary.branches.covered, + total: file.summary.branches.total, + counts: file.data.branches.map((range) => range.count), + }, + functions: file.data.functions.map(({ name, count }) => ({ name, count })), + }; +} + +if (import.meta.main) { + assert.equal(process.argv[2], 'collect-node'); + assert.equal(process.argv.length, 5); + await captureNode(process.argv[3], process.argv[4]); +} diff --git a/scripts/test-coverage/report.mjs b/scripts/test-coverage/report.mjs new file mode 100644 index 0000000..109d7a2 --- /dev/null +++ b/scripts/test-coverage/report.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFile, readdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { CoverageReport } from 'monocart-coverage-reports'; +import { BRANCH_TARGET, ROOT, isProductionSource, productionSourceFiles } from './config.mjs'; +import { createSourceRegistry, mapCoverageEntry } from './source-maps.mjs'; +import { verifyCaptures } from './capture-contract.mjs'; +import { splitCoverageEntry } from './split-entries.mjs'; + +export async function buildCoverageReport({ nodeDirectory, browserDirectory, outputDirectory, expectedNodeTests }) { + const registry = await createSourceRegistry(); + const report = new CoverageReport({ + name: 'Userscripts source coverage', + baseDir: ROOT, + outputDir: outputDirectory, + reports: ['v8', 'console-summary'], + all: { dir: [resolve(ROOT, 'src'), resolve(ROOT, 'scripts')], filter: isProductionSource }, + sourceFilter: isProductionSource, + v8Ignore: false, + }); + const capturedTests = new Set(); + const capturedBrowserTests = new Set(); + const browserManifest = browserDirectory === null ? null + : JSON.parse(await readFile(resolve(browserDirectory, 'manifest.json'), 'utf8')); + const unmapped = []; + const counts = { node: 0, browser: 0 }; + for (const [layer, directory] of [['node', nodeDirectory], ['browser', browserDirectory]]) { + if (directory === null) continue; + const files = (await readdir(directory)).filter((path) => path.endsWith('.json') && path !== 'manifest.json').sort(); + assert.ok(files.length > 0, 'Missing ' + layer + ' coverage captures'); + for (const path of files) { + const capture = JSON.parse(await readFile(resolve(directory, path), 'utf8')); + const entries = capture.entries; + if (layer === 'node') capturedTests.add(capture.testFile); + else { + assert.equal(capturedBrowserTests.has(capture.testId), false, 'Duplicate browser capture'); + capturedBrowserTests.add(capture.testId); + } + const mapped = []; + for (const entry of entries.flatMap((raw) => splitCoverageEntry(raw, registry))) { + const result = mapCoverageEntry(entry, registry); + if (result) mapped.push(result); + else unmapped.push({ layer, url: entry.url, reason: 'Executed source is not an exact production source or artifact segment' }); + } + if (mapped.length) { + await report.add(mapped); + counts[layer] += mapped.length; + } + } + } + const tested = verifyCaptures({ expectedNodeTests, capturedNodeTests: capturedTests, browserManifest, capturedBrowserTests }); + assert.ok(counts.node > 0, 'No Node production coverage was collected'); + if (browserDirectory !== null) assert.ok(counts.browser > 0, 'No browser production coverage was collected'); + const result = await report.generate(); + assert.ok(result, 'Coverage reporting produced no result'); + const scope = await productionSourceFiles(); + assert.deepEqual(result.files.map((file) => file.sourcePath).sort(), scope, + 'Coverage must contain every production source exactly once, including unexecuted files'); + const summary = { + schemaVersion: 1, + nodeVersion: process.versions.node, + layers: browserDirectory === null ? ['node'] : ['node', 'browser'], + targetBranches: BRANCH_TARGET, + meetsBranchTarget: result.summary.branches.covered * 100 >= BRANCH_TARGET * result.summary.branches.total, + summary: result.summary, + scope, + files: result.files.map((file) => ({ + path: file.sourcePath, + sha256: createHash('sha256').update(registry.sources.get(file.sourcePath)).digest('hex'), + summary: file.summary, + })), + capturedEntries: counts, + tests: tested, + unmapped, + }; + await writeFile(resolve(outputDirectory, 'coverage-summary.json'), JSON.stringify(summary, null, 2) + '\n'); + return { summary, reportPath: resolve(outputDirectory, 'index.html') }; +} diff --git a/scripts/test-coverage/run.mjs b/scripts/test-coverage/run.mjs new file mode 100644 index 0000000..8882dfc --- /dev/null +++ b/scripts/test-coverage/run.mjs @@ -0,0 +1,98 @@ +import { spawn } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { ROOT, COVERAGE_DIRECTORY, assertProjectNodeVersion } from './config.mjs'; +import { buildCoverageReport } from './report.mjs'; +import { assessBranchCoverage } from './gates.mjs'; +import { exactNodeArgs } from '../test-selection/node-runner.mjs'; + +export async function nodeTestFiles() { + async function walk(directory) { + const entries = await readdir(resolve(ROOT, directory), { withFileTypes: true }); + const files = await Promise.all(entries.map((entry) => { + const path = directory + '/' + entry.name; + return entry.isDirectory() ? walk(path) : [path]; + })); + return files.flat(); + } + return [...await walk('test/unit'), ...await walk('test/dom')] + .filter((path) => path.endsWith('.test.js')).sort(); +} + +export function runNode(args, environment = {}) { + return new Promise((accept, reject) => { + const child = spawn(process.execPath, args, { + cwd: ROOT, + env: { ...process.env, ...environment }, + stdio: 'inherit', + }); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) accept(); + else reject(new Error('Test subprocess failed: exit=' + code + ', signal=' + signal)); + }); + }); +} + +export async function runCoverage(mode, { reportOnly = false, requireTarget = false } = {}) { + if (!['all', 'node'].includes(mode)) throw new Error('Coverage mode must be all or node'); + if (requireTarget && (reportOnly || mode === 'node')) { + throw new Error('The final coverage target requires a complete gated run'); + } + await assertProjectNodeVersion(); + const policy = mode === 'all' && !reportOnly + ? JSON.parse(await readFile(new URL('./branch-policy.json', import.meta.url), 'utf8')) + : null; + await mkdir(COVERAGE_DIRECTORY, { recursive: true }); + const runDirectory = await mkdtemp(resolve(COVERAGE_DIRECTORY, 'run-')); + process.stdout.write('Coverage run: ' + runDirectory + '\n'); + const nodeDirectory = resolve(runDirectory, 'node'); + const browserDirectory = mode === 'all' ? resolve(runDirectory, 'browser') : null; + await mkdir(nodeDirectory); + if (browserDirectory !== null) await mkdir(browserDirectory); + const expectedNodeTests = await nodeTestFiles(); + await runNode(exactNodeArgs(expectedNodeTests, { + execArgv: ['--import', new URL('./collect-node.mjs', import.meta.url).href], + reportFile: resolve(runDirectory, 'node-results.txt'), + }), { USERSCRIPTS_NODE_COVERAGE_DIRECTORY: nodeDirectory }); + if (browserDirectory !== null) { + await runNode(['./node_modules/@playwright/test/cli.js', 'test', + '--reporter=dot,html,./scripts/test-coverage/browser-reporter.mjs'], { + USERSCRIPTS_BROWSER_COVERAGE_DIRECTORY: browserDirectory, + PLAYWRIGHT_HTML_OPEN: 'never', + PLAYWRIGHT_HTML_OUTPUT_DIR: resolve(ROOT, 'playwright-report'), + }); + } + const outputDirectory = resolve(runDirectory, 'report'); + const result = await buildCoverageReport({ nodeDirectory, browserDirectory, outputDirectory, expectedNodeTests }); + result.summary.gate = policy === null ? null + : assessBranchCoverage(result.summary, policy, { requireTarget }); + await writeFile(resolve(outputDirectory, 'coverage-summary.json'), JSON.stringify(result.summary, null, 2) + '\n'); + await writeFile(resolve(COVERAGE_DIRECTORY, 'latest.json'), JSON.stringify({ + runDirectory, outputDirectory, reportPath: result.reportPath, + layers: result.summary.layers, + completedAt: new Date().toISOString(), + gatePassed: result.summary.gate?.passed ?? null, + meetsBranchTarget: result.summary.meetsBranchTarget, + }, null, 2) + '\n'); + process.stdout.write('Coverage report: ' + result.reportPath + '\n'); + process.stdout.write('Branch target: ' + result.summary.targetBranches + '%; measured: ' + + result.summary.summary.branches.pct + '% (' + result.summary.layers.join(' + ') + ')\n'); + if (result.summary.gate !== null) { + if (!result.summary.gate.passed) { + throw new Error('Coverage gate failed:\n' + result.summary.gate.failures.join('\n')); + } + process.stdout.write('Staged coverage gate passed. Final target met: ' + result.summary.gate.targetMet + '\n'); + } else { + process.stdout.write('Diagnostic report: thresholds were not enforced.\n'); + } + return result; +} + +if (import.meta.main) { + const mode = process.argv[2] ?? 'all'; + const flags = process.argv.slice(3); + if (flags.some((flag) => !['--report-only', '--require-target'].includes(flag)) + || new Set(flags).size !== flags.length) throw new Error('Unknown or duplicate coverage flag'); + await runCoverage(mode, { reportOnly: flags.includes('--report-only'), requireTarget: flags.includes('--require-target') }); +} diff --git a/scripts/test-coverage/source-maps.mjs b/scripts/test-coverage/source-maps.mjs new file mode 100644 index 0000000..eab4dd0 --- /dev/null +++ b/scripts/test-coverage/source-maps.mjs @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { parse } from 'acorn'; +import { decode, encode } from '@jridgewell/sourcemap-codec'; +import * as esbuild from 'esbuild'; +import { TARGETS } from '../build-userscript.mjs'; +import { ROOT, productionSourceFiles, relativeSourcePath } from './config.mjs'; +import { findArtifactSegments } from './split-entries.mjs'; + +const parserOptions = { ecmaVersion: 'latest', sourceType: 'module' }; + +/** A coverage-only compilation must execute exactly the public artifact's bytes. */ +export async function createSourceRegistry() { + const sources = new Map(await Promise.all((await productionSourceFiles()).map(async (path) => ( + [path, await readFile(resolve(ROOT, path), 'utf8')] + )))); + const artifacts = []; + for (const [name, target] of Object.entries(TARGETS)) { + const entry = resolve(ROOT, target.entry); + const source = sources.get(target.entry); + const metadata = source.match(/^\/\/ ==UserScript==[\s\S]*?\/\/ ==\/UserScript==/)?.[0]; + assert.ok(metadata, 'Missing metadata for ' + name); + const outfile = resolve(ROOT, 'test-results/coverage/maps', name + '.js'); + const result = await esbuild.build({ + absWorkingDir: ROOT, + banner: { js: metadata }, + bundle: true, + charset: 'utf8', + format: 'iife', + legalComments: 'none', + logOverride: target.logOverride || {}, + minify: false, + platform: 'browser', + sourcemap: 'external', + outfile, + // Keeping the metadata in stdin preserves the original source positions. + stdin: { contents: source, loader: 'js', resolveDir: dirname(entry), sourcefile: entry }, + target: ['es2020'], + write: false, + }); + const code = result.outputFiles.find((file) => file.path.endsWith('.js')).text; + const map = JSON.parse(result.outputFiles.find((file) => file.path.endsWith('.map')).text); + assert.equal(code, await readFile(resolve(ROOT, target.output), 'utf8'), + 'Coverage build differs from the public artifact; rebuild ' + name); + map.sources = map.sources.map((path, index) => { + const normalized = relativeSourcePath(resolve(dirname(outfile), path)); + assert.equal(map.sourcesContent[index], sources.get(normalized), + 'Coverage source content differs from ' + normalized); + return normalized; + }); + const body = parse(code, parserOptions).body; + artifacts.push({ path: target.output, code, map, body }); + } + for (const [path, code] of sources) { + if (!path.startsWith('scripts/')) continue; + artifacts.push({ + path, + code, + map: { + version: 3, sources: [path], sourcesContent: [code], names: [], + mappings: encode(code.split('\n').map((_, line) => [[0, 0, line, 0]])), + }, + body: parse(code, parserOptions).body, + }); + } + return { sources, artifacts }; +} + +/** Compose exact script segments without moving any executed byte or V8 range. */ +export function composeSourceMap(source, segments) { + const sources = []; + const sourcesContent = []; + const sourceIndexes = new Map(); + const names = []; + const nameIndexes = new Map(); + const mappings = Array.from({ length: source.split('\n').length }, () => []); + for (const { offset, artifact } of segments) { + const prefix = source.slice(0, offset); + const lineOffset = prefix.split('\n').length - 1; + const columnOffset = prefix.length - prefix.lastIndexOf('\n') - 1; + const sourceRemap = artifact.map.sources.map((path, index) => { + if (!sourceIndexes.has(path)) { + sourceIndexes.set(path, sources.length); + sources.push(path); + sourcesContent.push(artifact.map.sourcesContent[index]); + } else { + assert.equal(sourcesContent[sourceIndexes.get(path)], artifact.map.sourcesContent[index]); + } + return sourceIndexes.get(path); + }); + const nameRemap = artifact.map.names.map((name) => { + if (!nameIndexes.has(name)) { + nameIndexes.set(name, names.length); + names.push(name); + } + return nameIndexes.get(name); + }); + decode(artifact.map.mappings).forEach((line, lineIndex) => { + for (const segment of line) { + const mapped = [segment[0] + (lineIndex === 0 ? columnOffset : 0)]; + if (segment.length > 1) mapped.push(sourceRemap[segment[1]], segment[2], segment[3]); + if (segment.length > 4) mapped.push(nameRemap[segment[4]]); + mappings[lineOffset + lineIndex].push(mapped); + } + }); + } + for (const line of mappings) line.sort((left, right) => left[0] - right[0]); + return { version: 3, sources, sourcesContent, names, mappings: encode(mappings) }; +} + +/** Partial source snippets and quoted bundle text are never credited to a file. */ +export function mapCoverageEntry(entry, registry) { + assert.equal(typeof entry.source, 'string', 'Coverage entries require the actual executed source'); + const namedPath = relativeSourcePath(entry.url); + if (registry.sources.get(namedPath) === entry.source) { + return { ...entry, url: pathToFileURL(resolve(ROOT, namedPath)).href }; + } + const exact = [...registry.sources].filter(([, source]) => source === entry.source); + if (exact.length === 1) { + return { ...entry, url: pathToFileURL(resolve(ROOT, exact[0][0])).href }; + } + const segments = findArtifactSegments(entry.source, registry.artifacts); + if (!segments.length) return null; + const hash = createHash('sha256').update(entry.source).digest('hex'); + const sourceMap = composeSourceMap(entry.source, segments); + // Absolute originals keep Node, anonymous VM, and browser bundles on one identity. + sourceMap.sources = sourceMap.sources.map((path) => resolve(ROOT, path)); + return { + ...entry, + url: pathToFileURL(resolve(ROOT, 'test-results/coverage/virtual', hash + '.js')).href, + sourceMap, + }; +} diff --git a/scripts/test-coverage/split-entries.mjs b/scripts/test-coverage/split-entries.mjs new file mode 100644 index 0000000..4feea6e --- /dev/null +++ b/scripts/test-coverage/split-entries.mjs @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import { parse } from 'acorn'; + +function statementRanges(source) { + const ranges = new Set(); + function visit(node) { + if (!node || typeof node !== 'object') return; + if (node.type?.endsWith('Statement') || node.type?.endsWith('Declaration')) { + ranges.add(node.start + ':' + node.end); + } + for (const [key, value] of Object.entries(node)) { + if (key === 'start' || key === 'end') continue; + if (Array.isArray(value)) value.forEach(visit); + else if (value && typeof value === 'object') visit(value); + } + } + visit(parse(source, { ecmaVersion: 'latest', sourceType: 'module' })); + return ranges; +} + +/** Quoted installers and partial declarations cannot become executable segments. */ +export function findArtifactSegments(source, artifacts) { + const candidates = []; + for (const artifact of artifacts) { + assert.ok(artifact.code.length > 0, 'Coverage artifacts must contain executable source'); + let offset = source.indexOf(artifact.code); + while (offset !== -1) { + candidates.push({ offset, artifact }); + offset = source.indexOf(artifact.code, offset + artifact.code.length); + } + } + if (candidates.length === 0) return []; + const ranges = statementRanges(source); + const segments = candidates.filter(({ offset, artifact }) => artifact.body.length > 0 + && artifact.body.every((node) => ranges.has((offset + node.start) + ':' + (offset + node.end)))); + segments.sort((left, right) => left.offset - right.offset); + for (let index = 1; index < segments.length; index += 1) { + assert.ok(segments[index].offset >= segments[index - 1].offset + segments[index - 1].artifact.code.length, + 'Coverage artifact segments must not overlap'); + } + return segments; +} + +function rootRange(fn) { + assert.ok(Array.isArray(fn.ranges) && fn.ranges.length > 0, 'A captured function requires its V8 root range'); + const root = fn.ranges[0]; + assert.ok(Number.isInteger(root.startOffset) && Number.isInteger(root.endOffset) + && root.startOffset >= 0 && root.endOffset > root.startOffset, 'Invalid V8 function root bounds'); + for (const range of fn.ranges) { + assert.ok(range.startOffset >= root.startOffset && range.endOffset <= root.endOffset + && range.endOffset > range.startOffset && Number.isInteger(range.count) && range.count >= 0, + 'A captured V8 block must remain inside its function root with a nonnegative count'); + } + return root; +} + +/** Clipped parent/child ranges retain the most specific count, including zero. */ +function clipContainerRanges(ranges, start, end) { + const clipped = new Map(); + for (const range of ranges) { + const from = Math.max(start, range.startOffset); + const to = Math.min(end, range.endOffset); + if (from >= to) continue; + const key = (from - start) + ':' + (to - start); + const span = range.endOffset - range.startOffset; + const existing = clipped.get(key); + if (existing && existing.span < span) continue; + if (existing && existing.span === span) { + assert.equal(existing.range.count, range.count, 'Identical V8 block bounds have conflicting counts'); + } + clipped.set(key, { + span, + range: { startOffset: from - start, endOffset: to - start, count: range.count }, + }); + } + return [...clipped.values()].map(({ range }) => range) + .sort((left, right) => left.startOffset - right.startOffset || right.endOffset - left.endOffset); +} + +function segmentFunctions(functions, start, end) { + const containers = []; + const internal = []; + for (const fn of functions) { + const root = rootRange(fn); + if (root.endOffset <= start || root.startOffset >= end) continue; + if (root.startOffset <= start && root.endOffset >= end) { + containers.push(fn); + } else if (root.startOffset >= start && root.endOffset <= end) { + internal.push({ ...fn, ranges: fn.ranges.map((range) => ({ + ...range, startOffset: range.startOffset - start, endOffset: range.endOffset - start, + })) }); + } else { + assert.fail('A V8 function partially crosses an exact artifact boundary'); + } + } + assert.ok(containers.length > 0, 'An artifact requires a captured script or wrapper covering its complete bytes'); + containers.sort((left, right) => (rootRange(left).endOffset - rootRange(left).startOffset) + - (rootRange(right).endOffset - rootRange(right).startOffset)); + const container = containers[0]; + if (containers.length > 1) { + const first = rootRange(container); + const second = rootRange(containers[1]); + assert.ok(first.startOffset !== second.startOffset || first.endOffset !== second.endOffset, + 'An artifact has ambiguous enclosing V8 function roots'); + } + // A sandbox wrapper is a script coverage carrier, not another original function. + // Selecting only the innermost container prevents counting its parents again. + const carrier = { + functionName: '', + isBlockCoverage: container.isBlockCoverage, + ranges: clipContainerRanges(container.ranges, start, end), + }; + assert.deepEqual([carrier.ranges[0].startOffset, carrier.ranges[0].endOffset], [0, end - start]); + return [carrier, ...internal]; +} + +/** Split before mapping: MCR merges originals across entries, not within one map. */ +export function splitCoverageEntry(entry, registry) { + assert.equal(typeof entry.source, 'string', 'Coverage entries require their actual executed source'); + assert.equal(entry.sourceMap, undefined, 'Split raw V8 entries before assigning a source map'); + const segments = findArtifactSegments(entry.source, registry.artifacts); + if (segments.length === 0) return [entry]; + return segments.map(({ artifact, offset }) => ({ + ...entry, + source: artifact.code, + functions: segmentFunctions(entry.functions, offset, offset + artifact.code.length), + artifactSegment: { path: artifact.path, offset }, + })); +} diff --git a/scripts/test-policy/ast.js b/scripts/test-policy/ast.js new file mode 100644 index 0000000..6d5ce0b --- /dev/null +++ b/scripts/test-policy/ast.js @@ -0,0 +1,160 @@ +export function isFunction(node) { + return ['ArrowFunctionExpression', 'FunctionExpression', 'FunctionDeclaration'].includes(node.type); +} + +export function walk(sourceCode, node, visit, { enterFunctions = true } = {}) { + visit(node); + for (const key of sourceCode.visitorKeys[node.type] || []) { + const children = Array.isArray(node[key]) ? node[key] : [node[key]]; + for (const child of children) { + if (!child || (!enterFunctions && isFunction(child))) continue; + walk(sourceCode, child, visit, { enterFunctions }); + } + } +} + +export function staticText(node) { + if (node?.type === 'Literal' && typeof node.value === 'string') return node.value; + if (node?.type === 'TemplateLiteral') { + return node.quasis.map((quasi) => quasi.value.cooked).join('${value}'); + } + return null; +} + +export function propertyName(node) { + if (!node.computed && node.property.type === 'Identifier') return node.property.name; + if (node.property.type === 'TemplateLiteral' && node.property.expressions.length > 0) return null; + return staticText(node.property); +} + +/** Resolve the imported runner and ordinary aliases, including destructuring. */ +export function createReferenceResolver(sourceCode) { + function importedPath(definition) { + const specifier = definition.node; + const source = definition.parent.source.value; + const imported = specifier.type === 'ImportDefaultSpecifier' ? 'default' : specifier.imported?.name; + if (source === 'node:test' || source === '@playwright/test' || source.endsWith('/test.js')) { + if (specifier.type === 'ImportNamespaceSpecifier') return ['testModule']; + if (['default', 'test', 'it'].includes(imported)) return ['test']; + if (['describe', 'suite'].includes(imported)) return ['describe']; + if (imported === 'mock') return ['context', 'mock']; + if (imported === 'expect') return ['expect']; + } + if (['node:assert', 'node:assert/strict'].includes(source)) { + return ['default', 'strict'].includes(imported) || specifier.type === 'ImportNamespaceSpecifier' + ? ['assert'] : ['assert', imported]; + } + if (['node:timers/promises', 'timers/promises'].includes(source)) { + return specifier.type === 'ImportNamespaceSpecifier' || imported === 'default' + ? ['promiseTimers'] : ['promiseTimers', imported]; + } + return null; + } + function propertyPath(pattern, name) { + if (pattern.type === 'Identifier') return pattern.name === name ? [] : null; + if (pattern.type === 'AssignmentPattern') return propertyPath(pattern.left, name); + if (pattern.type !== 'ObjectPattern') return null; + for (const property of pattern.properties) { + if (property.type !== 'Property') continue; + const nested = propertyPath(property.value, name); + if (nested !== null) { + const key = property.computed ? staticText(property.key) : property.key.name || property.key.value; + return [key, ...nested]; + } + } + return null; + } + function resolve(node, seen = new Set()) { + if (!node) return []; + if (node.type === 'ChainExpression' || node.type === 'AwaitExpression') return resolve(node.expression || node.argument, seen); + if (node.type === 'Identifier') { + let variable; + for (let scope = sourceCode.getScope(node); scope && !variable; scope = scope.upper) variable = scope.set.get(node.name); + if (!variable || variable.defs.length === 0) return [node.name]; + if (seen.has(variable)) return []; + const nextSeen = new Set([...seen, variable]); + const definition = variable.defs[0]; + if (definition.type === 'ImportBinding') return importedPath(definition) || [node.name]; + if (definition.type === 'Variable' && definition.node.init) { + const base = resolve(definition.node.init, nextSeen); + const property = propertyPath(definition.node.id, node.name); + if (base.length > 0 && property !== null) return [...base, ...property]; + } + if (definition.type === 'Parameter') { + const callback = definition.node; + if (callback.parent.type === 'CallExpression' && isTestCall(callback.parent, (value) => resolve(value, nextSeen))) { + const property = propertyPath(callback.params[0], node.name); + if (property !== null) return ['context', ...property]; + } + } + // A local parameter named "test" is not the imported runner it shadows. + return ['test', 'it', 'describe', 'suite'].includes(node.name) ? ['local', node.name] : [node.name]; + } + if (node.type === 'MemberExpression') { + const object = resolve(node.object, seen); + const property = propertyName(node); + if (object.length === 1 && object[0] === 'testModule') { + if (['test', 'it'].includes(property)) return ['test']; + if (['describe', 'suite'].includes(property)) return ['describe']; + if (property === 'mock') return ['context', 'mock']; + if (property === 'expect') return ['expect']; + } + return object.length > 0 ? [...object, property] : []; + } + if (node.type === 'CallExpression') { + const callee = resolve(node.callee, seen); + if (callee[0] === 'test' && callee.at(-1) === 'extend') return ['test']; + if (callee.at(-1) === 'bind') return callee.slice(0, -1); + } + return []; + } + return resolve; +} + +export function runnerPath(path) { + if (path[0] === 'testModule') return path.slice(1); + if (['t', 'context'].includes(path[0]) && path[1] === 'test') return path.slice(1); + return path; +} + +export function isTestCall(node, resolve) { + const path = runnerPath(resolve(node.callee)); + return ['test', 'it'].includes(path[0]) + && (path.length === 1 || (path.length === 2 && ['only', 'skip', 'todo', 'fixme'].includes(path[1]))); +} + +export function testCallback(node) { + return node.arguments.findLast((argument) => isFunction(argument)); +} + +export function ownerFunction(node) { + for (let parent = node; parent; parent = parent.parent) { + if (isFunction(parent)) return parent; + } + return null; +} + +export function executableStatements(sourceCode, body) { + const statements = []; + walk(sourceCode, body, (node) => { + if (node.type === 'ExpressionStatement' && node.expression.type !== 'Literal') statements.push(node); + if (node.type === 'VariableDeclaration' && node.declarations.some((declaration) => declaration.init)) statements.push(node); + if (['ThrowStatement', 'ReturnStatement'].includes(node.type) && node.argument) statements.push(node); + }, { enterFunctions: false }); + return statements; +} + +export function isConstant(node) { + if (!node) return false; + if (node.type === 'Literal') return true; + if (node.type === 'Identifier') return ['undefined', 'NaN', 'Infinity'].includes(node.name); + if (node.type === 'TemplateLiteral') return node.expressions.every(isConstant); + if (node.type === 'UnaryExpression') return node.operator !== 'delete' && isConstant(node.argument); + if (['BinaryExpression', 'LogicalExpression'].includes(node.type)) return isConstant(node.left) && isConstant(node.right); + if (node.type === 'ArrayExpression') return node.elements.every((element) => element === null || isConstant(element)); + if (node.type === 'ObjectExpression') { + return node.properties.every((property) => property.type === 'Property' + && property.kind === 'init' && (!property.computed || isConstant(property.key)) && isConstant(property.value)); + } + return false; +} diff --git a/scripts/test-policy/eslint-plugin.js b/scripts/test-policy/eslint-plugin.js new file mode 100644 index 0000000..ff54690 --- /dev/null +++ b/scripts/test-policy/eslint-plugin.js @@ -0,0 +1,309 @@ +import { + createReferenceResolver, + executableStatements, + isConstant, + isFunction, + isTestCall, + ownerFunction, + runnerPath, + staticText, + testCallback, + walk, +} from './ast.js'; + +const noOptions = []; +const allowanceSchema = [{ + type: 'object', + properties: { + allow: { + type: 'array', + items: { + type: 'object', + properties: { + target: { type: 'string' }, count: { type: 'integer', minimum: 1 }, + reason: { type: 'string', minLength: 20 }, within: { type: 'string', minLength: 1 }, + }, + required: ['target', 'count', 'reason'], + additionalProperties: false, + }, + }, + }, + additionalProperties: false, +}]; + +/** A shrinking inventory records precise legacy call targets, never directories. */ +function allowanceReporter(context) { + const allowed = context.options[0]?.allow || []; + const seen = new Map(); + return { + check(node, target) { + const entry = allowed.find((item) => item.target === target && (!item.within || withinFunction(node, item.within))); + if (!entry) { context.report({ node, messageId: 'forbidden', data: { target } }); return; } + const count = (seen.get(entry) || 0) + 1; + seen.set(entry, count); + if (count > entry.count) context.report({ node, messageId: 'forbidden', data: { target } }); + }, + finish(node) { + for (const entry of allowed) { + if ((seen.get(entry) || 0) < entry.count) { + context.report({ node, messageId: 'staleAllowance', data: { target: entry.target } }); + } + } + }, + }; +} + +function withinFunction(node, name) { + for (let parent = node.parent; parent; parent = parent.parent) { + if (!isFunction(parent)) continue; + if (parent.id?.name === name) return true; + if (parent.parent.type === 'Property') { + const property = parent.parent; + const key = property.computed ? staticText(property.key) : property.key.name || property.key.value; + if (key === name) return true; + } + } + return false; +} + +function phase(text) { + const match = /^(Given|When|Then)\b\s*(.*)$/s.exec(text.trim()); + if (!match) return null; + const description = match[2].trim(); + const words = description.match(/[\p{L}\p{N}]+/gu) || []; + const placeholder = /^(the )?(setup|context|precondition|action|act|assertions?|results?|expected (result|behavior))\.?$/i; + return { name: match[1], valid: words.length >= 2 && !placeholder.test(description) }; +} + +function unconditionalPhase(node, callback) { + for (let parent = node; parent && parent !== callback; parent = parent.parent) { + if (['IfStatement', 'ConditionalExpression', 'LogicalExpression', 'ForStatement', 'ForOfStatement', 'ForInStatement', + 'WhileStatement', 'DoWhileStatement', 'SwitchCase', 'CatchClause'].includes(parent.type)) return false; + } + return true; +} + +const behaviorContract = { + meta: { + type: 'problem', schema: noOptions, + messages: { + title: 'Name the observable behavior with a static "user " prefix.', + callback: 'Use an inline test callback so its behavior stages can be checked.', + stages: 'Provide ordered, concrete Given, When, and Then stages for this test.', + description: '{{stage}} needs a concrete scenario description, not a bare keyword or placeholder.', + emptyStage: '{{stage}} must contain an executable setup, action, or assertion.', + awaitedStep: 'Await or return this test.step so the behavior stages execute in order.', + }, + }, + create(context) { + const sourceCode = context.sourceCode; + const resolve = createReferenceResolver(sourceCode); + return { + CallExpression(node) { + if (!isTestCall(node, resolve)) return; + const title = staticText(node.arguments[0]); + if (!title?.startsWith('user ') || title.trim() === 'user') context.report({ node, messageId: 'title' }); + const callback = testCallback(node); + if (!callback) { context.report({ node, messageId: 'callback' }); return; } + const stages = []; + for (const comment of sourceCode.getAllComments()) { + if (comment.range[0] <= callback.body.range[0] || comment.range[1] >= callback.body.range[1]) continue; + const containingNode = sourceCode.getNodeByRangeIndex(comment.range[0]); + if (ownerFunction(containingNode) !== callback || !unconditionalPhase(containingNode, callback)) continue; + const parsed = phase(comment.value); + if (parsed) stages.push({ ...parsed, node: comment, start: comment.range[1], position: comment.range[0] }); + } + walk(sourceCode, callback.body, (child) => { + if (child.type !== 'CallExpression' || runnerPath(resolve(child.callee)).join('.') !== 'test.step') return; + if (!unconditionalPhase(child, callback)) return; + const text = staticText(child.arguments[0]); + const parsed = text === null ? null : phase(text); + if (!parsed) return; + const step = testCallback(child); + const awaited = ['AwaitExpression', 'ReturnStatement'].includes(child.parent.type); + if (!awaited) context.report({ node: child, messageId: 'awaitedStep' }); + stages.push({ ...parsed, node: child, step, position: child.range[0], start: child.range[0] }); + }, { enterFunctions: false }); + stages.sort((left, right) => left.position - right.position); + let previous = null; + let ordered = stages.length > 0; + const statements = executableStatements(sourceCode, callback.body); + for (const [index, stage] of stages.entries()) { + if (!stage.valid) context.report({ node: stage.node, messageId: 'description', data: { stage: stage.name } }); + if (stage.name === 'Given') ordered &&= previous === null || previous === 'Given' || previous === 'Then'; + if (stage.name === 'When') ordered &&= previous === 'Given' || previous === 'When' || previous === 'Then'; + if (stage.name === 'Then') ordered &&= previous === 'When' || previous === 'Then'; + previous = stage.name; + const end = stages[index + 1]?.position ?? callback.body.range[1]; + const nonempty = stage.step + ? executableStatements(sourceCode, stage.step.body).length > 0 + : statements.some((statement) => statement.range[0] >= stage.start && statement.range[1] <= end); + if (!nonempty) context.report({ node: stage.node, messageId: 'emptyStage', data: { stage: stage.name } }); + } + if (!ordered || stages[0]?.name !== 'Given' || previous !== 'Then' || !stages.some((stage) => stage.name === 'When')) { + context.report({ node, messageId: 'stages' }); + } + }, + }; + }, +}; + +const noFocusedTests = { + meta: { + type: 'problem', schema: noOptions, + messages: { forbidden: 'Do not focus, skip, defer, or dynamically select a test runner method ({{method}}).' }, + }, + create(context) { + const resolve = createReferenceResolver(context.sourceCode); + return { + CallExpression(node) { + const rawPath = resolve(node.callee); + if (rawPath[0] === 'context' && ['skip', 'todo'].includes(rawPath.at(-1))) { + context.report({ node, messageId: 'forbidden', data: { method: rawPath.at(-1) } }); + return; + } + const path = runnerPath(rawPath); + if (!['test', 'it', 'describe', 'suite'].includes(path[0])) return; + const restricted = path.find((part) => ['only', 'skip', 'todo', 'fixme', null].includes(part)); + if (restricted !== undefined) context.report({ node, messageId: 'forbidden', data: { method: restricted ?? 'computed method' } }); + if (path.length !== 1 && !isTestCall(node, resolve)) return; + for (const argument of node.arguments) { + if (argument.type !== 'ObjectExpression') continue; + for (const property of argument.properties) { + if (property.type !== 'Property') continue; + const key = property.computed ? staticText(property.key) : property.key.name || property.key.value; + if (['only', 'skip', 'todo'].includes(key) && !(property.value.type === 'Literal' && property.value.value === false)) { + context.report({ node: property, messageId: 'forbidden', data: { method: key } }); + } + } + } + }, + }; + }, +}; + +const noUncontractedMocks = { + meta: { + type: 'problem', schema: allowanceSchema, + messages: { + forbidden: 'Replace ad hoc mock {{target}} with an explicit boundary fake and contract tests, or use mock.timers for time.', + staleAllowance: 'Remove or shrink the migrated mock allowance for {{target}}.', + }, + }, + create(context) { + const sourceCode = context.sourceCode; + const resolve = createReferenceResolver(sourceCode); + const reporter = allowanceReporter(context); + return { + CallExpression(node) { + const path = resolve(node.callee); + if (path.at(-2) !== 'mock' || !['method', 'fn', null].includes(path.at(-1))) return; + const target = path.at(-1) === 'method' + ? `method:${sourceCode.getText(node.arguments[0])}:${staticText(node.arguments[1])}` + : path.at(-1) === 'fn' ? 'fn' : 'dynamic mock member'; + reporter.check(node, target); + }, + 'Program:exit': reporter.finish, + }; + }, +}; + +function timerResolvesPromise(node, sourceCode, resolve) { + let executor = node.parent; + while (executor && !(isFunction(executor) && executor.parent.type === 'NewExpression' && executor.parent.callee.name === 'Promise')) { + executor = executor.parent; + } + if (!executor || executor.params[0]?.type !== 'Identifier') return false; + const resolver = executor.params[0].name; + const callback = node.arguments[0]; + if (!callback) return false; + if (resolve(callback).join('.') === resolver) return true; + if (!isFunction(callback)) return false; + let resolves = false; + walk(sourceCode, callback.body, (child) => { + if (child.type === 'CallExpression' && resolve(child.callee).join('.') === resolver) resolves = true; + }); + return resolves; +} + +const noFixedWaits = { + meta: { + type: 'problem', schema: allowanceSchema, + messages: { + forbidden: 'Replace fixed wait {{target}} with an observable condition, response gate, or explicit virtual-clock advance.', + staleAllowance: 'Remove or shrink the migrated fixed-wait allowance for {{target}}.', + }, + }, + create(context) { + const sourceCode = context.sourceCode; + const resolve = createReferenceResolver(sourceCode); + const reporter = allowanceReporter(context); + return { + CallExpression(node) { + const path = resolve(node.callee); + const method = path.at(-1); + const promiseTimer = path[0] === 'promiseTimers' && (method === 'setTimeout' || method === 'wait'); + const timerSleep = method === 'setTimeout' && timerResolvesPromise(node, sourceCode, resolve); + if (!promiseTimer && !timerSleep && !['waitForTimeout', 'sleep', 'delay'].includes(method)) return; + const delay = node.arguments[timerSleep ? 1 : 0]; + reporter.check(node, `${path.join('.')}(${delay ? sourceCode.getText(delay) : ''})`); + }, + 'Program:exit': reporter.finish, + }; + }, +}; + +function assertionArguments(node, resolve) { + const path = resolve(node.callee); + if (path[0] === 'assert') { + const count = ['assert', 'ok', 'fail'].includes(path.at(-1)) ? 1 : 2; + return node.arguments.slice(0, count); + } + if (node.callee.type !== 'MemberExpression') return null; + let base = node.callee.object; + while (base.type === 'MemberExpression') base = base.object; + if (base.type === 'CallExpression' && resolve(base.callee)[0] === 'expect') return [...base.arguments, ...node.arguments]; + return null; +} + +const noVacuousTests = { + meta: { + type: 'problem', schema: noOptions, + messages: { empty: 'A test must execute behavior and check an observable result.', constant: 'This test only asserts constants or a value against itself; assert an observable result.' }, + }, + create(context) { + const sourceCode = context.sourceCode; + const resolve = createReferenceResolver(sourceCode); + return { + CallExpression(node) { + if (!isTestCall(node, resolve)) return; + const callback = testCallback(node); + if (!callback) { context.report({ node, messageId: 'empty' }); return; } + if (callback.body.type === 'BlockStatement' && executableStatements(sourceCode, callback.body).length === 0) { + context.report({ node, messageId: 'empty' }); + return; + } + const assertions = []; + walk(sourceCode, callback.body, (child) => { + if (child.type !== 'CallExpression') return; + const arguments_ = assertionArguments(child, resolve); + if (arguments_ !== null) assertions.push(arguments_); + }); + if (assertions.length > 0 && assertions.every((arguments_) => arguments_.every(isConstant) + || (arguments_.length === 2 && arguments_.every((argument) => argument.type === 'Identifier') && arguments_[0].name === arguments_[1].name))) { + context.report({ node, messageId: 'constant' }); + } + }, + }; + }, +}; + +export default { + rules: { + 'behavior-contract': behaviorContract, + 'no-focused-tests': noFocusedTests, + 'no-uncontracted-mocks': noUncontractedMocks, + 'no-fixed-waits': noFixedWaits, + 'no-vacuous-tests': noVacuousTests, + }, +}; diff --git a/scripts/test-policy/migration-inventory.js b/scripts/test-policy/migration-inventory.js new file mode 100644 index 0000000..5436eda --- /dev/null +++ b/scripts/test-policy/migration-inventory.js @@ -0,0 +1,176 @@ +/** Existing suites awaiting behavioral migration; new files are strict by default. */ +export const legacyBehaviorGroups = [ + { + reason: 'Orderbook parsers, DOM adapters, options, and rendering still need scenario names and Given/When/Then organization.', + files: [ + 'test/dom/binance-orderbook-trade/account-orders.test.js', + 'test/dom/binance-orderbook-trade/chart-orders.test.js', + 'test/dom/binance-orderbook-trade/depth-profile.test.js', + 'test/dom/binance-orderbook-trade/orderbook-precision.test.js', + 'test/dom/binance-orderbook-trade/trade-form.test.js', + 'test/dom/binance-orderbook-trade/usdt-rebalance-dialog.test.js', + 'test/unit/binance-orderbook-trade/auto-open-leverage.test.js', + 'test/unit/binance-orderbook-trade/binance-native-depth-source.test.js', + 'test/unit/binance-orderbook-trade/binance-page-text.test.js', + 'test/unit/binance-orderbook-trade/cancel-all-dialog.test.js', + 'test/unit/binance-orderbook-trade/cancel-dialog-decision.test.js', + 'test/unit/binance-orderbook-trade/chart-marker-save-controller.test.js', + 'test/unit/binance-orderbook-trade/chart-marker-save-entrypoints.test.js', + 'test/unit/binance-orderbook-trade/chart-orders-recovery.test.js', + 'test/unit/binance-orderbook-trade/decimal.test.js', + 'test/unit/binance-orderbook-trade/depth-profile-book.test.js', + 'test/unit/binance-orderbook-trade/depth-profile-render-cycle.test.js', + 'test/unit/binance-orderbook-trade/depth-profile-session.test.js', + 'test/unit/binance-orderbook-trade/interaction-feedback.test.js', + 'test/unit/binance-orderbook-trade/ladder-options.test.js', + 'test/unit/binance-orderbook-trade/ladder-progress.test.js', + 'test/unit/binance-orderbook-trade/ladder.test.js', + 'test/unit/binance-orderbook-trade/open-order-capacity.test.js', + 'test/unit/binance-orderbook-trade/open-order-rows.test.js', + 'test/unit/binance-orderbook-trade/orderbook.test.js', + 'test/unit/binance-orderbook-trade/panel-copy.test.js', + 'test/unit/binance-orderbook-trade/panel-options.test.js', + 'test/unit/binance-orderbook-trade/precision.test.js', + 'test/unit/binance-orderbook-trade/route.test.js', + 'test/unit/binance-orderbook-trade/status-symbol.test.js', + 'test/unit/binance-orderbook-trade/trade-form.test.js', + 'test/unit/binance-orderbook-trade/tradingview-target.test.js', + 'test/unit/binance-orderbook-trade/ui-covering-array.test.js', + 'test/unit/binance-orderbook-trade/usdt-rebalance.test.js', + ], + }, + { + reason: 'Strategy 27/29 clients, lifecycle, transport, and chart integration retain existing assertions until each behavior is migrated.', + files: [ + 'test/dom/binance-strategy27-events/compound-candidate-controller.test.js', + 'test/dom/binance-strategy27-events/strategy27-entrypoint.test.js', + 'test/dom/binance-strategy27-events/strategy27-event-panel.test.js', + 'test/dom/binance-strategy27-events/tradingview-compound-layer.test.js', + 'test/dom/binance-strategy27-events/tradingview-event-layer.test.js', + 'test/dom/binance-strategy29-bollinger/runtime.test.js', + 'test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js', + 'test/dom/binance-strategy29-bollinger/summary-locale-position.test.js', + 'test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js', + 'test/unit/binance-strategy27-events/compound-candidate-annotation.test.js', + 'test/unit/binance-strategy27-events/compound-candidate-client.test.js', + 'test/unit/binance-strategy27-events/compound-candidate-contract.test.js', + 'test/unit/binance-strategy27-events/compound-candidate-lifecycle.test.js', + 'test/unit/binance-strategy27-events/event-annotation.test.js', + 'test/unit/binance-strategy27-events/live-event-client.test.js', + 'test/unit/binance-strategy27-events/live-event-contract.test.js', + 'test/unit/binance-strategy29-bollinger/bearish-bollinger-pattern.test.js', + 'test/unit/binance-strategy29-bollinger/coordination.test.js', + 'test/unit/binance-strategy29-bollinger/entry-sandbox.test.js', + 'test/unit/binance-strategy29-bollinger/remote-summary-client.test.js', + 'test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js', + 'test/unit/binance-strategy29-bollinger/remote-summary-controller.test.js', + ], + }, + { + reason: 'Shared data, media export, and offline evidence suites have not yet received complete behavioral organization.', + files: [ + 'test/dom/binance-trading-data-footer.test.js', + 'test/dom/coinmarketcap-valuation-helper.test.js', + 'test/dom/m3u8-media-scan.test.js', + 'test/unit/auto-refresh.test.js', + 'test/unit/binance-data-panel-lifecycle-regressions.test.js', + 'test/unit/binance-data-panel-route-regressions.test.js', + 'test/unit/binance-data-panel-symbols.test.js', + 'test/unit/binance-live-capture-builder.test.js', + 'test/unit/binance-live-capture-cli.test.js', + 'test/unit/binance-live-order-scale-config.test.js', + 'test/unit/binance-live-performance-probe.test.js', + 'test/unit/binance-live-performance.test.js', + 'test/unit/binance-signal-client-settings.test.js', + 'test/unit/binance-stage3-evidence.test.js', + 'test/unit/binance-symbol.test.js', + 'test/unit/binance-ui-workflow.test.js', + 'test/unit/brooks-media-audit.test.js', + 'test/unit/brooks-media-download.test.js', + 'test/unit/brooks-media-import-index.test.js', + 'test/unit/m3u8-downloader-course-export.test.js', + 'test/unit/signal-gateway-bridge.test.js', + 'test/unit/spa-route-change.test.js', + ], + }, + { + reason: 'Metadata/build assertions remain useful. The two source-regressions files also contain behavioral checks that still need migration; they are not wholly architectural tests.', + files: [ + 'test/unit/binance-orderbook-trade/source-regressions.test.js', + 'test/unit/binance-strategy29-bollinger/source-regressions.test.js', + 'test/unit/binance-shared-route-architecture.test.js', + 'test/unit/userscript-metadata-icons.test.js', + 'test/unit/userscript-release-contract.test.js', + ], + }, +]; + +export const legacyBehaviorFiles = legacyBehaviorGroups.flatMap((group) => group.files); + +/** Counts must shrink with migrations; moving or adding a target fails lint. */ +export const legacyCallAllowances = [ + { + file: 'test/dom/binance-trading-data-footer.test.js', + rule: 'no-uncontracted-mocks', + allow: [{ target: 'method:Date:now', count: 1, reason: 'The extracted footer harness owns Date.now; migrate its elapsed-time behavior to mock.timers with the complete panel harness.' }], + }, + { + file: 'test/dom/binance-strategy27-events/compound-candidate-controller.test.js', + rule: 'no-uncontracted-mocks', + allow: [{ target: 'method:globalThis.crypto.subtle:digest', count: 1, reason: 'One lifecycle-hash race pauses the real digest; move this pause into a contract-tested crypto boundary fixture.' }], + }, + { + file: 'test/dom/binance-strategy27-events/strategy27-entrypoint.test.js', + rule: 'no-uncontracted-mocks', + allow: [ + { target: 'method:Date:now', count: 1, reason: 'Entrypoint lifecycle aging still uses an old manual clock; migrate to the shared deterministic clock boundary.' }, + { target: 'method:page:setInterval', count: 1, reason: 'The entrypoint harness captures its owned interval; replace the paired timer overrides with a tested clock fixture.' }, + { target: 'method:page:clearInterval', count: 1, reason: 'The entrypoint harness removes its owned interval; migrate together with its setInterval boundary.' }, + { target: 'method:h.page.document:querySelectorAll', count: 1, reason: 'The existing DOM-query budget probe counts real querySelectorAll calls; move instrumentation to the chart fixture contract.' }, + { target: 'method:h.page:prompt', count: 1, reason: 'The current sandbox test rejects page-realm prompts; migrate to a dedicated prompt boundary fixture with rejection assertions.' }, + ], + }, + { + file: 'test/unit/m3u8-downloader-course-export.test.js', + rule: 'no-fixed-waits', + allow: [ + { target: 'setTimeout(20)', count: 19, reason: 'The legacy userscript VM export harness settles network and DOM turns by elapsed time; migrate to explicit export/download completion events.' }, + { target: 'setTimeout(650)', count: 1, reason: 'One legacy reset/export pacing scenario uses real elapsed time; migrate its scheduling contract to a virtual clock.' }, + { target: 'setTimeout(1100)', count: 1, reason: 'One legacy active-runtime scenario crosses a real second; migrate runtime accounting to a virtual clock.' }, + ], + }, + { + file: 'test/dom/binance-strategy29-bollinger/runtime.test.js', + rule: 'no-fixed-waits', + allow: [{ target: 'f.view.setTimeout(0)', count: 5, reason: 'Legacy remote-client/DOM integration waits for browser turns; expose request and render completion gates before migrating these scenarios.' }], + }, + { + file: 'test/unit/binance-orderbook-trade/trade-form.test.js', + rule: 'no-fixed-waits', + allow: [{ target: 'dom.window.setTimeout(0)', count: 3, reason: 'Legacy form request and MutationObserver scenarios settle through JSDOM turns; replace each with its observed completion signal.' }], + }, + { + file: 'test/unit/binance-orderbook-trade/cancel-all-dialog.test.js', + rule: 'no-fixed-waits', + allow: [{ target: 'dom.window.setTimeout(0)', count: 1, reason: 'One legacy negative MutationObserver scenario flushes unrelated DOM churn; migrate to an explicit delivered-mutation signal.' }], + }, + { + file: 'test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js', + rule: 'no-fixed-waits', + allow: [{ target: 'setTimeout(0)', count: 1, reason: 'One legacy render-batch test verifies a real browser-task yield; migrate its scheduling boundary without replacing the yield with a microtask.' }], + }, +]; + +/** Host measurement needs one real task boundary after the observed interaction. */ +export const contractCallAllowances = [ + { + file: 'e2e/binance-orderbook/helpers/live-performance-probe.js', + rule: 'no-fixed-waits', + allow: [{ + target: 'window.setTimeout(0)', + within: 'finishAfterPerformanceTail', + count: 1, + reason: 'PerformanceObserver entries arrive after the real host task. This single zero-delay boundary drains that performance tail before observer teardown; it is not a business wait or a virtual-clock measurement.', + }], + }, +]; diff --git a/scripts/test-selection/graph.mjs b/scripts/test-selection/graph.mjs new file mode 100644 index 0000000..0fbea84 --- /dev/null +++ b/scripts/test-selection/graph.mjs @@ -0,0 +1,283 @@ +import { posix } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parse } from 'acorn'; + +const VIRTUAL_ROOT = '/__test_selection_repository__/'; +const BUILD_FILE = 'scripts/build-userscript.mjs'; +const PLAYWRIGHT_FILE = 'playwright.config.js'; +const SNAPSHOT_TEMPLATE = '{testDir}/{testFilePath}-snapshots/{arg}{ext}'; +const CODE = /\.(?:[cm]?js)$/; +const NODE_TEST = /^test\/(?:unit|dom)\/(?:.*\/)?[^/]+\.test\.js$/; +const BROWSER_TEST = /^e2e\/(?:.*\/)?specs\/(?:.*\/)?[^/]+\.pw\.js$/; +const RUNTIME_MODULES = new Set(['fs', 'fs/promises', 'child_process', 'module']); + +export function isProtectedPath(path) { + return /(?:^|\/)(?:\.codex|\.git|\.ssh|\.aws)(?:\/|$)/.test(path) + || /(?:^|\/)(?:\.env(?:\.[^/]*)?|\.npmrc|\.netrc|credentials(?:\.[^/]*)?|id_rsa|id_ed25519)$/.test(path) + || /\.(?:pem|key|log)$/.test(path); +} + +function isDocumentation(path) { + return /^(?:docs\/.*\.(?:md|mdx)|(?:README(?:\.[^/]*)?|AGENTS|CHANGELOG|CONTRIBUTING|LICENSE)\.md)$/.test(path); +} + +function infrastructure(path) { + return /^(?:package(?:-lock)?\.json|\.nvmrc|playwright\.config\.[^/]+|eslint\.config\.[^/]+)$/.test(path) + || path.startsWith('.github/workflows/') || path.startsWith('scripts/test-') || path === BUILD_FILE; +} + +function walk(node, visit) { + if (!node || typeof node.type !== 'string') return; + visit(node); + for (const value of Object.values(node)) { + if (Array.isArray(value)) value.forEach((child) => walk(child, visit)); + else if (value && typeof value === 'object') walk(value, visit); + } +} + +function propertyName(node) { + return node.computed ? node.property?.value : node.property?.name; +} + +function metaUrl(node) { + return node?.type === 'MemberExpression' && node.object?.type === 'MetaProperty' + && node.object.meta.name === 'import' && propertyName(node) === 'url'; +} + +/** Only syntax-level constants are resolved; identifiers and runtime value flow stay unknown. */ +function literalString(node) { + if (node?.type === 'Literal' && typeof node.value === 'string') return node.value; + if (node?.type === 'TemplateLiteral' && node.expressions.length === 0) return node.quasis[0].value.cooked; + if (node?.type === 'BinaryExpression' && node.operator === '+') { + const left = literalString(node.left); + const right = literalString(node.right); + if (left !== null && right !== null) return left + right; + } + return null; +} + +function objectProperties(node) { + if (node?.type !== 'ObjectExpression') return null; + const result = new Map(); + for (const entry of node.properties) { + if (entry.type !== 'Property' || entry.computed || entry.kind !== 'init' || entry.method) return null; + const name = entry.key.name ?? entry.key.value; + if (name === '__proto__' || result.has(name)) return null; + result.set(name, entry.value); + } + return result; +} + +function safeRepositoryPath(path) { + return typeof path === 'string' && path.length > 0 && !path.includes('\0') && !path.includes('\\') + && !posix.isAbsolute(path) && posix.normalize(path) === path && path !== '.' + && !path.split('/').includes('..') && !isProtectedPath(path); +} + +/** Generated artifacts inherit exported build entries; uncertain readers retain their entire test closures. */ +export async function buildTestGraph({ files, readText }) { + const inventory = new Set(files); + const nodeTests = [...inventory].filter((file) => NODE_TEST.test(file)).sort(); + const browserTests = [...inventory].filter((file) => BROWSER_TEST.test(file)).sort(); + if (!nodeTests.length && !browserTests.length) throw new Error('No test roots were found in the current repository inventory'); + const dependencies = new Map(); + const uncertainties = new Map(); + const artifacts = new Map(); + const queue = [...nodeTests, ...browserTests]; + const visited = new Set(); + const add = (consumer, dependency) => { + if (!dependencies.has(consumer)) dependencies.set(consumer, new Set()); + dependencies.get(consumer).add(dependency); + if (CODE.test(dependency)) queue.push(dependency); + }; + const uncertain = (file, detail, global = false) => { + const entry = { file, reason: detail, scope: global ? 'global' : 'consumer' }; + uncertainties.set(JSON.stringify(entry), entry); + }; + async function sourceAst(file, global = false) { + if (isProtectedPath(file)) { uncertain(file, 'Protected dependency was not read', global); return null; } + if (!inventory.has(file)) { uncertain(file, 'Referenced file is missing', global); return null; } + const source = await readText(file); + try { return parse(source, { ecmaVersion: 'latest', sourceType: 'module', locations: true }); } + catch (error) { + if (!(error instanceof SyntaxError)) throw error; + uncertain(file, 'Unsupported or invalid JavaScript syntax at line ' + error.loc?.line, global); + return null; + } + } + const buildAst = await sourceAst(BUILD_FILE, true); + if (buildAst) { + const exported = buildAst.body.filter((node) => node.type === 'ExportNamedDeclaration' && node.declaration?.type === 'VariableDeclaration') + .flatMap((node) => node.declaration.kind === 'const' ? node.declaration.declarations : []); + const declaration = exported.find((node) => node.id.type === 'Identifier' && node.id.name === 'TARGETS'); + const targets = objectProperties(declaration?.init); + if (!targets || !targets.size) uncertain(BUILD_FILE, 'Build TARGETS cannot be resolved from an exported object', true); + else { + for (const target of targets.values()) { + const fields = objectProperties(target); + const entry = literalString(fields?.get('entry')); + const output = literalString(fields?.get('output')); + if (!safeRepositoryPath(entry) || !safeRepositoryPath(output) || !CODE.test(entry) || !CODE.test(output)) { + uncertain(BUILD_FILE, 'Build target lacks a safe supported entry/output pair', true); + } else if (artifacts.has(output)) { + uncertain(BUILD_FILE, 'Build output has multiple source entries: ' + output, true); + } else if (!inventory.has(entry) || !inventory.has(output)) { + uncertain(BUILD_FILE, 'Build entry or output is unavailable: ' + entry + ' -> ' + output, true); + } else artifacts.set(output, entry); + } + } + } + if (browserTests.length) { + const ast = await sourceAst(PLAYWRIGHT_FILE, true); + if (ast) { + const declaration = ast.body.find((node) => node.type === 'ExportDefaultDeclaration')?.declaration; + let config = declaration; + if (declaration?.type === 'CallExpression') { + const defineConfig = ast.body.filter((node) => node.type === 'ImportDeclaration' && node.source.value === '@playwright/test') + .flatMap((node) => node.specifiers) + .find((node) => node.type === 'ImportSpecifier' && node.imported.name === 'defineConfig')?.local.name; + config = declaration.callee.type === 'Identifier' && declaration.callee.name === defineConfig && declaration.arguments.length === 1 + ? declaration.arguments[0] : null; + } + const properties = objectProperties(config); + const testDir = literalString(properties?.get('testDir'))?.replace(/^\.\//, ''); + const rootsSupported = testDir && /^e2e\/(?:.*\/)?specs$/.test(testDir) + && literalString(properties?.get('testMatch')) === '**/*.pw.js' + && browserTests.every((file) => file.startsWith(testDir + '/')); + if (!rootsSupported) uncertain(PLAYWRIGHT_FILE, 'Playwright test root configuration cannot be verified', true); + if (literalString(properties?.get('snapshotPathTemplate')) !== SNAPSHOT_TEMPLATE) { + uncertain(PLAYWRIGHT_FILE, 'Playwright snapshot layout cannot be verified', true); + } else if (rootsSupported) { + for (const file of inventory) { + const marker = '.pw.js-snapshots/'; + const index = file.indexOf(marker); + const owner = index < 0 ? null : file.slice(0, index) + '.pw.js'; + if (owner && browserTests.includes(owner)) add(owner, file); + } + } + } + } + while (queue.length) { + const file = queue.shift(); + if (visited.has(file)) continue; + visited.add(file); + if (artifacts.has(file)) { add(file, artifacts.get(file)); continue; } + const ast = await sourceAst(file); + if (!ast) continue; + function reference(path, strict = true, moduleSpecifier = false) { + if (/^[a-z][a-z\d+.-]*:/i.test(path)) { + if (moduleSpecifier && RUNTIME_MODULES.has(path.replace(/^node:/, ''))) uncertain(file, 'Unresolved runtime dependencies from ' + path); + else if (moduleSpecifier && !path.startsWith('node:')) uncertain(file, 'Unresolved URL module dependency: ' + path.split(':', 1)[0]); + return; + } + if (moduleSpecifier && RUNTIME_MODULES.has(path)) uncertain(file, 'Unresolved runtime dependencies from ' + path); + if (moduleSpecifier && path.startsWith('#')) { uncertain(file, 'Unresolved package import alias: ' + path); return; } + if (moduleSpecifier && !path.startsWith('.') && !path.startsWith('/')) return; + if (moduleSpecifier) { + const absolute = fileURLToPath(new URL(path, pathToFileURL(VIRTUAL_ROOT + file))); + if (!absolute.startsWith(VIRTUAL_ROOT)) { uncertain(file, 'Dependency escapes the repository'); return; } + path = absolute.slice(VIRTUAL_ROOT.length); + } else if (path.startsWith('.')) path = posix.join(posix.dirname(file), path); + path = posix.normalize(path).replace(/\/$/, ''); + if (path === '.' || [...inventory].some((entry) => entry.startsWith(path + '/'))) { + if (strict) uncertain(file, 'Directory input cannot prove a complete dependency set: ' + path); + return; + } + if (isProtectedPath(path)) { if (strict) uncertain(file, 'Protected dependency was not read'); return; } + if (!safeRepositoryPath(path)) { if (strict) uncertain(file, 'Unsafe dependency was not read'); return; } + if (inventory.has(path)) { + add(file, path); + if (moduleSpecifier && !CODE.test(path) && !path.endsWith('.json')) uncertain(file, 'Unsupported module dependency: ' + path); + } else if (strict) { add(file, path); uncertain(file, 'Referenced file is missing: ' + path); } + } + function moduleReference(node, description) { + if (node?.type !== 'Literal' || typeof node.value !== 'string') uncertain(file, description + ' at line ' + node?.loc?.start.line); + else reference(node.value, true, true); + } + walk(ast, (node) => { + if (['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration'].includes(node.type) && node.source) { + moduleReference(node.source, 'Unresolved module dependency'); + } else if (node.type === 'ImportExpression') { + moduleReference(node.source, 'Unresolved dynamic import'); + } else if (node.type === 'CallExpression' && node.callee.name === 'require') { + uncertain(file, 'Unresolved runtime dependencies from a CommonJS loader'); + moduleReference(node.arguments[0], 'Unresolved require dependency'); + } + if (node.type === 'NewExpression' && node.callee.name === 'URL') { + const path = literalString(node.arguments[0]); + if (path !== null && /^[a-z][a-z\d+.-]*:/i.test(path) && !path.startsWith('file:')) return; + if (path === null || !metaUrl(node.arguments[1])) { + uncertain(file, 'Unresolved URL dependency at line ' + node.loc.start.line); + } else { + const url = new URL(path, pathToFileURL(VIRTUAL_ROOT + file)); + const absolute = fileURLToPath(url); + if (absolute.startsWith(VIRTUAL_ROOT)) reference(absolute.slice(VIRTUAL_ROOT.length)); + else uncertain(file, 'Dependency escapes the repository'); + } + } + // Literal path tables retain useful edges without claiming completeness for dynamic readers. + if (node.type === 'Literal' && typeof node.value === 'string') reference(node.value, false); + }); + } + return { + files: inventory, nodeTests, browserTests, dependencies, + uncertainties: [...uncertainties.values()].sort((left, right) => left.file.localeCompare(right.file) || left.reason.localeCompare(right.reason)), + }; +} + +export function selectTests(graph, changedFiles, { full = false } = {}) { + const changed = [...new Set(changedFiles)].sort(); + const fullReasons = []; + const selected = new Set(); + const roots = new Set([...graph.nodeTests, ...graph.browserTests]); + const reverse = new Map(); + for (const [consumer, dependencies] of graph.dependencies) { + for (const dependency of dependencies) { + if (!reverse.has(dependency)) reverse.set(dependency, new Set()); + reverse.get(dependency).add(consumer); + } + } + function consumers(path) { + const seen = new Set(); + const found = new Set(); + const pending = [path]; + while (pending.length) { + const item = pending.pop(); + if (seen.has(item)) continue; + seen.add(item); + if (roots.has(item)) found.add(item); + pending.push(...reverse.get(item) || []); + } + return found; + } + if (full) fullReasons.push('Full test run explicitly requested'); + const globalUncertainties = graph.uncertainties.filter((entry) => entry.scope === 'global'); + const describe = (entry) => entry.file + ': ' + entry.reason; + if (globalUncertainties.length) fullReasons.push('Global dependency graph error: ' + globalUncertainties.map(describe).join('; ')); + if (changed.length) { + for (const file of new Set(graph.uncertainties.filter((entry) => entry.scope === 'consumer').map((entry) => entry.file))) { + const affected = consumers(file); + if (!affected.size) fullReasons.push('An unresolved dependency has no provable test owner: ' + file); + for (const test of affected) selected.add(test); + } + } + for (const path of changed) { + if (infrastructure(path)) { fullReasons.push('Infrastructure changed: ' + path); continue; } + if (!graph.files.has(path)) { fullReasons.push('Deleted or unavailable changed path: ' + path); continue; } + const affected = consumers(path); + for (const test of affected) selected.add(test); + if (!affected.size && !isDocumentation(path)) fullReasons.push('Unmapped runtime change: ' + path); + } + const mode = fullReasons.length ? 'full' : selected.size ? 'affected' : 'none'; + const reasons = [...fullReasons]; + if (mode === 'none') reasons.push(changed.length ? 'Only documentation without runtime consumers changed' : 'No changed files'); + if (mode === 'affected') { + reasons.push('Selected complete test files through dependency edges and all unresolved consumers'); + reasons.push(...graph.uncertainties.map(describe)); + } + return { + schemaVersion: 1, mode, reasons, changedFiles: changed, + nodeTests: graph.nodeTests.filter((test) => mode === 'full' || selected.has(test)), + browserTests: graph.browserTests.filter((test) => mode === 'full' || selected.has(test)), + }; +} diff --git a/scripts/test-selection/node-runner.mjs b/scripts/test-selection/node-runner.mjs new file mode 100644 index 0000000..738371d --- /dev/null +++ b/scripts/test-selection/node-runner.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { createWriteStream } from 'node:fs'; +import { resolve } from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { run } from 'node:test'; +import { spec } from 'node:test/reporters'; +import { fileURLToPath } from 'node:url'; + +/** Node's CLI expands filenames as globs; the programmatic files option is literal. */ +export function exactNodeArgs(files, options = {}) { + return [fileURLToPath(import.meta.url), JSON.stringify({ files, ...options })]; +} + +async function runExactNodeFiles({ files, execArgv = [], reportFile }) { + assert.equal(process.env.NODE_TEST_CONTEXT, undefined, + 'The exact-file runner must start outside an existing Node test process'); + assert.ok(Array.isArray(files) && files.length > 0, 'Exact-file execution needs selected test files'); + const paths = files.map(file => resolve(file)); + assert.equal(new Set(paths).size, paths.length, 'Selected Node files must be unique'); + const events = run({ files: paths, execArgv, concurrency: true }); + events.on('test:summary', summary => { + if (!summary.success) process.exitCode = 1; + }); + await pipeline(events.compose(spec), + reportFile === undefined ? process.stdout : createWriteStream(reportFile), + { end: reportFile !== undefined }); + if (reportFile !== undefined) process.stdout.write('Node test results: ' + reportFile + '\n'); +} + +if (import.meta.main) await runExactNodeFiles(JSON.parse(process.argv[2])); diff --git a/scripts/test-selection/run.mjs b/scripts/test-selection/run.mjs new file mode 100644 index 0000000..3214e06 --- /dev/null +++ b/scripts/test-selection/run.mjs @@ -0,0 +1,139 @@ +import { constants } from 'node:fs'; +import { lstat, open } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { createRequire } from 'node:module'; +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; +import { buildTestGraph, isProtectedPath, selectTests } from './graph.mjs'; +import { exactNodeArgs } from './node-runner.mjs'; + +const execute = promisify(execFile); +const MAX_GIT_OUTPUT = 16 * 1024 * 1024; + +function validateBase(base) { + if (typeof base !== 'string' || !base.trim() || base.startsWith('-') || /^0+$/.test(base)) { + throw new Error('A nonempty, nonzero comparison base is required'); + } +} + +export function parseArgs(argv) { + const options = { base: 'HEAD', full: false, list: false }; + const seen = new Set(); + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (!['--base', '--full', '--list'].includes(argument) || seen.has(argument)) { + throw new Error('Unknown or repeated test-selection argument: ' + argument); + } + seen.add(argument); + if (argument === '--base') { + options.base = argv[++index]; + validateBase(options.base); + } else options[argument.slice(2)] = true; + } + return options; +} + +async function git(root, args) { + const { stdout } = await execute('git', args, { cwd: root, encoding: 'utf8', maxBuffer: MAX_GIT_OUTPUT }); + return stdout; +} + +function nulPaths(output) { + if (output && !output.endsWith('\0')) throw new Error('Git returned an incomplete NUL-delimited path list'); + return output ? output.slice(0, -1).split('\0') : []; +} + +/** A rename is deliberately represented as deletion plus addition so old consumers cannot disappear. */ +export async function collectRepositoryState(root, { base = 'HEAD' } = {}) { + validateBase(base); + let commit; + try { commit = (await git(root, ['rev-parse', '--verify', '--end-of-options', base + '^{commit}'])).trim(); } + catch { throw new Error('Comparison base does not resolve to an available commit: ' + JSON.stringify(base)); } + const [diff, untracked, listed] = await Promise.all([ + git(root, ['diff', '--name-only', '--no-renames', '-z', commit, '--']), + git(root, ['ls-files', '--others', '--exclude-standard', '-z']), + git(root, ['ls-files', '--cached', '--others', '--exclude-standard', '-z']), + ]); + const files = []; + for (const path of new Set(nulPaths(listed))) { + try { + const stat = await lstat(resolve(root, path)); + if (stat.isFile() || stat.isSymbolicLink()) files.push(path); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + } + return { files: files.sort(), changedFiles: [...new Set([...nulPaths(diff), ...nulPaths(untracked)])].sort() }; +} + +async function readRepositoryText(root, path) { + if (isProtectedPath(path) || path.startsWith('/') || path.split('/').includes('..')) { + throw new Error('Refusing to read an unsafe selection input: ' + JSON.stringify(path)); + } + const handle = await open(resolve(root, path), constants.O_RDONLY | constants.O_NOFOLLOW); + try { return await handle.readFile('utf8'); } + finally { await handle.close(); } +} + +export async function createRepositoryPlan({ root, base = 'HEAD', full = false }) { + const state = await collectRepositoryState(root, { base }); + const graph = await buildTestGraph({ files: state.files, readText: (path) => readRepositoryText(root, path) }); + return selectTests(graph, state.changedFiles, { full }); +} + +function literalBrowserFilter(path) { + return '(?:^|/)' + path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$'; +} + +export function runnerCommands(plan, { nodeExecutable = process.execPath, playwrightCli } = {}) { + const commands = []; + if (plan.nodeTests.length) commands.push({ command: nodeExecutable, args: exactNodeArgs(plan.nodeTests) }); + if (plan.browserTests.length) { + if (!playwrightCli) throw new Error('The Playwright CLI path is required for selected browser tests'); + commands.push({ command: nodeExecutable, args: [playwrightCli, 'test', ...plan.browserTests.map(literalBrowserFilter)] }); + } + return commands; +} + +/** Runner failures end the sequence; selected test paths are never interpolated into a shell command. */ +export async function runCommands(commands, { root }) { + for (const { command, args } of commands) { + await new Promise((complete, reject) => { + const child = spawn(command, args, { cwd: root, stdio: 'inherit', shell: false }); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) complete(); + else reject(new Error('Selected test runner failed with ' + (signal ? 'signal ' + signal : 'exit code ' + code))); + }); + }); + } +} + +export async function main(argv, { root = process.cwd() } = {}) { + const options = parseArgs(argv); + const expectedNode = (await readRepositoryText(root, '.nvmrc')).trim().replace(/^v/, ''); + if (expectedNode !== process.versions.node) throw new Error('Use project Node ' + expectedNode + '; received ' + process.versions.node); + const plan = await createRepositoryPlan({ root, base: options.base, full: options.full }); + if (options.list) { + process.stdout.write(JSON.stringify(plan, null, 2) + '\n'); + return plan; + } + process.stdout.write('[test-selection] ' + JSON.stringify({ + mode: plan.mode, nodeTests: plan.nodeTests.length, browserTests: plan.browserTests.length, reasons: plan.reasons, + }) + '\n'); + let playwrightCli; + if (plan.browserTests.length) { + const require = createRequire(pathToFileURL(resolve(root, 'package.json'))); + playwrightCli = require.resolve('@playwright/test/cli'); + } + await runCommands(runnerCommands(plan, { playwrightCli }), { root }); + return plan; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main(process.argv.slice(2)).catch((error) => { + process.stderr.write('[test-selection] ' + error.message + '\n'); + process.exitCode = 1; + }); +} diff --git a/src/binance-orderbook-trade/core/continuous-ladder.js b/src/binance-orderbook-trade/core/continuous-ladder.js index b6606d5..03d2d10 100644 --- a/src/binance-orderbook-trade/core/continuous-ladder.js +++ b/src/binance-orderbook-trade/core/continuous-ladder.js @@ -171,7 +171,7 @@ function buildContinuousLadderProgressParts(label, phase, progress) { `${progress.completedRounds}/${progress.startedRounds} 轮`, `${progress.completedRounds}/${progress.startedRounds} rounds`, )); - if (progress.lastRound?.plannedOrders !== null) { + if (progress.lastRound !== null && progress.lastRound.plannedOrders !== null) { parts.push(localizedText( `本轮 ${progress.lastRound.currentPlanSubmittedOrders}/${progress.lastRound.plannedOrders} 笔`, `This round ${progress.lastRound.currentPlanSubmittedOrders}/${progress.lastRound.plannedOrders}`, diff --git a/src/binance-orderbook-trade/core/quantity.js b/src/binance-orderbook-trade/core/quantity.js index c480ea3..0b3fef4 100644 --- a/src/binance-orderbook-trade/core/quantity.js +++ b/src/binance-orderbook-trade/core/quantity.js @@ -46,18 +46,10 @@ export function allocateLadderQuantities(totalQty, desiredLevels, stepSize, minR const quantities = []; let remainingSteps = totalSteps; + /** The final order adds the division remainder, so it cannot fall below baseSteps. */ for (let i = 0; i < actualLevels; i += 1) { const isLast = i === actualLevels - 1; const steps = isLast ? remainingSteps : baseSteps; - if (steps < minSteps) { - if (quantities.length === 0) return null; - const previous = decimalToStepCount(quantities.pop(), stepSize, 'floor'); - const merged = previous + steps; - if (merged < minSteps) return null; - quantities.push(formatStepCount(merged, stepSize)); - remainingSteps = 0n; - break; - } quantities.push(formatStepCount(steps, stepSize)); remainingSteps -= steps; } diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index 989cc75..d686db7 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.208 +// @version 2.7.209 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* diff --git a/test/unit/binance-fixture-contract.test.js b/test/unit/binance-fixture-contract.test.js new file mode 100644 index 0000000..1775895 --- /dev/null +++ b/test/unit/binance-fixture-contract.test.js @@ -0,0 +1,198 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { setImmediate } from 'node:timers/promises'; +import { JSDOM } from 'jsdom'; + +import { renderBinanceFuturesFixture } from '../../e2e/binance-orderbook/fixtures/binance-futures.js'; +import { + CURRENT_SYMBOL, + OTHER_SYMBOL, + ORDER_SETS, + createCancelScenario, +} from '../../e2e/binance-orderbook/scenarios/cancel-current-symbol.js'; + +const protectedOrders = [ + { ...ORDER_SETS.current[0], id: 'conditional-current', kind: 'conditional' }, + { ...ORDER_SETS.other[0], id: 'conditional-other', kind: 'conditional' }, +]; +const mixedOrders = [...ORDER_SETS.both, ...protectedOrders]; + +function openNativeHost(t, scenario) { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const submission = Promise.withResolvers(); + const dom = new JSDOM(renderBinanceFuturesFixture(scenario), { + url: 'https://www.binance.com/zh-CN/futures/' + CURRENT_SYMBOL, + runScripts: 'dangerously', + beforeParse(window) { + window.fetch = (path) => { + if (path === '/bapi/futures/v1/private/future/order/place-order') { + return submission.promise; + } + assert.equal(path, '/bapi/fixture-bootstrap'); + return Promise.resolve(new Response(JSON.stringify({ success: true }))); + }; + }, + }); + t.after(async () => { + await setImmediate(); + dom.window.close(); + }); + return { + document: dom.window.document, + fixture: { + snapshot: () => structuredClone(dom.window.__BINANCE_FIXTURE__.snapshot()), + switchSymbol: (symbol) => dom.window.__BINANCE_FIXTURE__.switchSymbol(symbol), + }, + async respond(payload) { + submission.resolve(new Response(JSON.stringify(payload), { + headers: { 'content-type': 'application/json' }, + })); + // Let the response body stream and its registered promise handlers settle. + await setImmediate(); + }, + }; +} + +test('user can detect an unfiltered cancellation because the fake removes other-symbol basic orders', (t) => { + // Given mixed basic and conditional orders with the symbol filter disabled. + const host = openNativeHost(t, createCancelScenario({ + orders: mixedOrders, + ui: { accountTab: 'openOrders', hideOtherSymbols: false }, + })); + + // When a caller uses native cancel without first restricting the symbol. + host.document.querySelector('[data-cancel-all]').click(); + host.document.querySelector('[data-dialog-action="confirm"]').click(); + t.mock.timers.tick(0); + + // Then the fake exposes the unsafe result instead of correcting the caller's scope. + const state = host.fixture.snapshot(); + assert.deepEqual(state.orders, protectedOrders); + assert.equal(state.orders.some((order) => order.id === 'other-1'), false); + const request = state.events.find(({ type }) => type === 'cancel-requested'); + assert.equal(request.hideOtherSymbols, false); + assert.equal(request.openOrdersSubTab, 'basic'); + assert.deepEqual(request.orderIds, ['current-1', 'other-1']); +}); + +test('user can detect cancellation from the wrong order sub-tab', (t) => { + // Given conditional orders are selected while current-symbol basic orders also exist. + const host = openNativeHost(t, createCancelScenario({ + orders: mixedOrders, + ui: { accountTab: 'openOrders', openOrdersSubTab: 'conditional', hideOtherSymbols: true }, + })); + + // When a caller confirms that native scope without selecting Basic orders. + host.document.querySelector('[data-cancel-all]').click(); + host.document.querySelector('[data-dialog-action="confirm"]').click(); + t.mock.timers.tick(0); + + // Then the current conditional order is removed and the untouched basic order proves the mistake. + const state = host.fixture.snapshot(); + assert.deepEqual(state.orders, [...ORDER_SETS.both, protectedOrders[1]]); + const request = state.events.find(({ type }) => type === 'cancel-requested'); + assert.equal(request.openOrdersSubTab, 'conditional'); + assert.deepEqual(request.orderIds, ['conditional-current']); +}); + +for (const switchBeforeConfirmation of [true, false]) { + test(`user keeps the initiating cancellation symbol when the page changes ${switchBeforeConfirmation ? 'before confirmation' : 'during delayed clearing'}`, (t) => { + // Given one captured current-symbol scope and a delayed native cancellation. + const host = openNativeHost(t, createCancelScenario({ + orders: mixedOrders, + ui: { accountTab: 'openOrders', hideOtherSymbols: true }, + host: { clearDelayMs: 500 }, + })); + host.document.querySelector('[data-cancel-all]').click(); + + // When the page changes symbol around confirmation and its pending host timer expires. + if (switchBeforeConfirmation) host.fixture.switchSymbol(OTHER_SYMBOL); + host.document.querySelector('[data-dialog-action="confirm"]').click(); + if (!switchBeforeConfirmation) host.fixture.switchSymbol(OTHER_SYMBOL); + t.mock.timers.tick(499); + assert.deepEqual(host.fixture.snapshot().orders, mixedOrders); + t.mock.timers.tick(1); + + // Then only the originally captured basic order disappears. + const state = host.fixture.snapshot(); + assert.equal(state.currentSymbol, OTHER_SYMBOL); + assert.deepEqual(state.orders, [ORDER_SETS.both[1], ...protectedOrders]); + const request = state.events.find(({ type }) => type === 'cancel-requested'); + assert.equal(request.symbol, CURRENT_SYMBOL); + assert.equal(request.hideOtherSymbols, true); + assert.deepEqual(request.orderIds, ['current-1']); + }); +} + +for (const [outcome, payload, text] of [ + ['success', { success: true }, '订单已提交成功'], + ['rejected', { success: false, code: '90800001', message: 'Fixture rejection' }, '订单提交失败'], +]) { + test(`user receives native feedback for the actual ${outcome} response`, async (t) => { + // Given the native request has no response yet. + const host = openNativeHost(t, createCancelScenario()); + host.document.querySelector('.order-entry button').click(); + assert.equal(host.document.querySelector('[role="alert"]'), null); + assert.deepEqual(host.fixture.snapshot().events.map(({ type }) => type), ['order-submitted']); + + // When the declared API response reaches the native caller. + await host.respond(payload); + + // Then both the ledger and toast reflect that response, with no fabricated success. + assert.equal(host.document.querySelector('[role="alert"]').textContent, text); + const events = host.fixture.snapshot().events; + assert.deepEqual(events.map(({ type }) => type), [ + 'order-submitted', 'order-submit-api-' + outcome, 'order-submit-feedback', + ]); + assert.equal(events[2].outcome, outcome); + }); +} + +test('user receives no fabricated success while a native submit remains unanswered', (t) => { + // Given a native order request whose response remains pending. + const host = openNativeHost(t, createCancelScenario()); + host.document.querySelector('.order-entry button').click(); + + // When the response deadline and a further cooldown elapse on the host clock. + t.mock.timers.tick(20_000); + + // Then no response or toast is invented merely because time passed. + assert.equal(host.document.querySelector('[role="alert"]'), null); + assert.deepEqual(host.fixture.snapshot().events.map(({ type }) => type), ['order-submitted']); +}); + +test('user sees an unrecognized native response remain unknown', async (t) => { + // Given a native form has no recognized response for its next submit. + const host = openNativeHost(t, createCancelScenario()); + + // When an unrecognized API payload reaches the submitted order. + host.document.querySelector('.order-entry button').click(); + await host.respond({ result: 'unrecognized' }); + + // Then the ledger retains unknown and the host does not invent a success toast. + assert.deepEqual(host.fixture.snapshot().events.map(({ type }) => type), [ + 'order-submitted', 'order-submit-api-unknown', + ]); + assert.equal(host.document.querySelector('[role="alert"]'), null); +}); + +test('user cannot configure undeclared order kinds or invalid submit outcomes', () => { + // Given order-kind, pending-response, and rejection-reason declarations can be invalid. + const invalidOrders = { orders: [{ id: 'missing-kind' }] }; + const invalidUnknown = { + host: { submitApiResponses: [{ outcome: 'unknown', delivery: 'immediate' }] }, + }; + const invalidRejection = { + host: { submitApiResponses: [{ outcome: 'rejected', delivery: 'immediate' }] }, + }; + + // When a caller attempts to build a scenario from each invalid declaration. + const constructOrders = () => createCancelScenario(invalidOrders); + const constructUnknown = () => createCancelScenario(invalidUnknown); + const constructRejection = () => createCancelScenario(invalidRejection); + + // Then each contract violation is rejected with its specific reason. + assert.throws(constructOrders, /declare.*kind/); + assert.throws(constructUnknown, /unknown.*pending/); + assert.throws(constructRejection, /code and message/); +}); diff --git a/test/unit/binance-orderbook-trade/cancel.test.js b/test/unit/binance-orderbook-trade/cancel.test.js index 83f122c..7ebb2b4 100644 --- a/test/unit/binance-orderbook-trade/cancel.test.js +++ b/test/unit/binance-orderbook-trade/cancel.test.js @@ -29,395 +29,587 @@ import { UI_LOCALE_ZH_CN, } from '../../../src/binance-orderbook-trade/contracts/panel-copy.js'; -test('cancel button exposes no-order completion feedback without disabling new actions', () => { - const idle = resolveCancelSymbolButtonPresentation({ - ladderRunning: false, - cancelRunning: false, - noOrdersFeedback: false, - }); +test('user sees cancellation availability and completion in the action button', () => { + // Given idle, running, completed-empty, and ladder-blocked cancellation states. + const states = [ + { ladderRunning: false, cancelRunning: false, noOrdersFeedback: false }, + { ladderRunning: false, cancelRunning: true, noOrdersFeedback: false }, + { ladderRunning: false, cancelRunning: false, noOrdersFeedback: true }, + { ladderRunning: true, cancelRunning: false, noOrdersFeedback: true }, + ]; + + // When the cancel button derives availability and localized labels. + const [idle, running, noOrders, blocked] = states.map(resolveCancelSymbolButtonPresentation); + + // Then no-orders feedback leaves the action enabled and active work prevents competition. assert.equal(idle.disabled, false); assert.equal(formatLocalizedText(idle.label, UI_LOCALE_ZH_CN), '撤单'); assert.equal(formatLocalizedText(idle.label, UI_LOCALE_EN), 'Cancel'); - const running = resolveCancelSymbolButtonPresentation({ - ladderRunning: false, - cancelRunning: true, - noOrdersFeedback: false, - }); assert.equal(running.disabled, true); assert.equal(formatLocalizedText(running.label, UI_LOCALE_ZH_CN), '撤单处理中'); - const noOrders = resolveCancelSymbolButtonPresentation({ - ladderRunning: false, - cancelRunning: false, - noOrdersFeedback: true, - }); assert.equal(noOrders.disabled, false); assert.equal(formatLocalizedText(noOrders.label, UI_LOCALE_EN), 'No Orders'); - const blocked = resolveCancelSymbolButtonPresentation({ - ladderRunning: true, - cancelRunning: false, - noOrdersFeedback: true, - }); assert.equal(blocked.disabled, true); assert.equal(formatLocalizedText(blocked.label, UI_LOCALE_ZH_CN), '撤单'); }); -test('normalizes text and recognizes open-orders tab labels', () => { - assert.equal(normalizeText(' 当前\n委托 (2) '), '当前 委托 (2)'); - assert.equal(isOpenOrdersTabText('当前委托(2)'), true); - assert.equal(isOpenOrdersTabText('Open Orders (3)'), true); - assert.equal(isOpenOrdersTabText('历史委托'), false); +test('user recognizes the current-orders tab despite whitespace and localization', () => { + // Given Chinese and English account-tab labels, including an unrelated history tab. + const cases = [ + { read: normalizeText, args: [' 当前\n委托 (2) '], expected: '当前 委托 (2)' }, + { read: isOpenOrdersTabText, args: ['当前委托(2)'], expected: true }, + { read: isOpenOrdersTabText, args: ['Open Orders (3)'], expected: true }, + { read: isOpenOrdersTabText, args: ['历史委托'], expected: false }, + ]; + + // When the labels are normalized and classified. + const results = cases.map(({ read, args }) => read(...args)); + + // Then only the supported current-orders labels match. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('parses open-orders count from localized tab text', () => { - assert.equal(parseOpenOrdersTabCount('当前委托(2)'), 2); - assert.equal(parseOpenOrdersTabCount('Open Orders (12)'), 12); - assert.equal(parseOpenOrdersTabCount('当前委托'), null); +test('user reads the count from localized current-orders tabs', () => { + // Given localized tabs with counts and a tab whose count is missing. + const cases = [ + { read: parseOpenOrdersTabCount, args: ['当前委托(2)'], expected: 2 }, + { read: parseOpenOrdersTabCount, args: ['Open Orders (12)'], expected: 12 }, + { read: parseOpenOrdersTabCount, args: ['当前委托'], expected: null }, + ]; + + // When the displayed account-order counts are parsed. + const results = cases.map(({ read, args }) => read(...args)); + + // Then the exact counts are returned and an unread count stays unknown. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('visible current-symbol rows are direct open-order evidence', () => { - assert.deepEqual(readVisibleOpenOrderSymbolsText('HYPEUSDT 永续 价格 数量 BTCUSDT 永续'), ['HYPEUSDT', 'BTCUSDT']); - assert.equal(hasCurrentSymbolOpenOrdersEvidence({ - scopeText: '价格 HYPEUSDT 永续 数量', - symbol: 'HYPEUSDT', - symbolFilterOk: false, - openOrdersCount: 0, - }), true); +test('user can cancel when a current-symbol order row is visible', () => { + // Given visible current-symbol rows despite an unchecked filter and stale account count. + const cases = [ + { read: readVisibleOpenOrderSymbolsText, args: ['HYPEUSDT 永续 价格 数量 BTCUSDT 永续'], expected: ['HYPEUSDT', 'BTCUSDT'] }, + { read: hasCurrentSymbolOpenOrdersEvidence, args: [{ + scopeText: '价格 HYPEUSDT 永续 数量', + symbol: 'HYPEUSDT', + symbolFilterOk: false, + openOrdersCount: 0, + }], expected: true }, + ]; + + // When the order symbols and cancellation evidence are evaluated. + const results = cases.map(({ read, args }) => read(...args)); + + // Then the visible row supplies current-symbol evidence. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('parses symbol when Binance joins time text and contract text', () => { - assert.deepEqual(readVisibleOpenOrderSymbolsText('2026-05-30 10:27HYPEUSDT永续 限价'), ['HYPEUSDT']); - assert.deepEqual(readVisibleOpenOrderSymbolsText('2026-08-23 09:07BTCUSDC永续 限价'), ['BTCUSDC']); - assert.deepEqual(readVisibleOpenOrderSymbolsText('2026-08-25 17:08:51HYPEUSDTPerp Limit'), ['HYPEUSDT']); - assert.equal(hasCurrentSymbolOpenOrdersEvidence({ - scopeText: '2026-05-30 10:27HYPEUSDT永续 限价', - symbol: 'HYPEUSDT', - symbolFilterOk: true, - openOrdersCount: 5, - cancelAllAvailable: true, - }), true); +test('user keeps the full contract identity beside a joined timestamp', () => { + // Given Binance rows joining minute or second timestamps to USDT and USDC contracts. + const cases = [ + { read: readVisibleOpenOrderSymbolsText, args: ['2026-05-30 10:27HYPEUSDT永续 限价'], expected: ['HYPEUSDT'] }, + { read: readVisibleOpenOrderSymbolsText, args: ['2026-08-23 09:07BTCUSDC永续 限价'], expected: ['BTCUSDC'] }, + { read: readVisibleOpenOrderSymbolsText, args: ['2026-08-25 17:08:51HYPEUSDTPerp Limit'], expected: ['HYPEUSDT'] }, + { read: hasCurrentSymbolOpenOrdersEvidence, args: [{ + scopeText: '2026-05-30 10:27HYPEUSDT永续 限价', + symbol: 'HYPEUSDT', + symbolFilterOk: true, + openOrdersCount: 5, + cancelAllAvailable: true, + }], expected: true }, + ]; + + // When visible contract symbols and cancellation evidence are read. + const results = cases.map(({ read, args }) => read(...args)); + + // Then timestamps are removed without removing any contract prefix. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); for (const symbol of ['龙虾USDT', '币安人生USDC', '4USDT', 'WUSDT', '1INCHUSDT', '1000PEPEUSDT', '1000000龙虾USDT', 'A_BTCUSDT']) { - test(`keeps the complete ${symbol} contract in order evidence`, () => { - for (const label of ['永续', 'Perp']) { - assert.deepEqual(readVisibleOpenOrderSymbolsText(`${symbol}${label}`), [symbol]); - assert.deepEqual(readVisibleOpenOrderSymbolsText(`2026-09-12 10:27${symbol}${label} Limit`), [symbol]); - assert.deepEqual(readVisibleOpenOrderSymbolsText(`2026-09-12 10:27:51${symbol}${label} Limit`), [symbol]); - } - assert.equal(isOpenOrdersScopeLimitedToSymbolText(`${symbol}永续`, symbol), true); - assert.equal(isCurrentSymbolOpenOrdersFilterReady({ - scopeText: `${symbol}永续`, symbol, filterChecked: true, cancelAllAvailable: true, - }), true); - assert.equal(isCurrentSymbolOpenOrdersClearCandidate({ - scopeText: `${symbol}永续`, symbol, openOrdersCount: 1, - }), false); + test(`user keeps the complete ${symbol} contract in cancellation evidence`, () => { + // Given joined and separated timestamp rows in both supported perpetual labels. + const rows = ['永续', 'Perp'].flatMap((label) => [ + symbol + label, + '2026-09-12 10:27' + symbol + label + ' Limit', + '2026-09-12 10:27:51' + symbol + label + ' Limit', + ]); + const scopeText = symbol + '永续'; + + // When the current-symbol scope and its visible contracts are evaluated. + const parsed = rows.map(readVisibleOpenOrderSymbolsText); + const limited = isOpenOrdersScopeLimitedToSymbolText(scopeText, symbol); + const ready = isCurrentSymbolOpenOrdersFilterReady({ scopeText, symbol, filterChecked: true, cancelAllAvailable: true }); + const clear = isCurrentSymbolOpenOrdersClearCandidate({ scopeText, symbol, openOrdersCount: 1 }); + + // Then the full contract authorizes only its own nonempty order scope. + parsed.forEach((result) => assert.deepEqual(result, [symbol])); + assert.equal(limited, true); + assert.equal(ready, true); + assert.equal(clear, false); }); } -test('other Unicode and numeric-prefix contracts cannot become current-symbol evidence', () => { - for (const other of ['龙虾USDT', '龙虾BTCUSDT', 'A_BTCUSDT', '27BTCUSDT', '4USDT']) { - const scopeText = `BTCUSDT 永续 ${other} 永续`; - assert.deepEqual(readVisibleOpenOrderSymbolsText(scopeText), ['BTCUSDT', other]); - assert.equal(isOpenOrdersScopeLimitedToSymbolText(scopeText, 'BTCUSDT'), false); - assert.equal(isOpenOrdersScopeConfirmedForSymbolText(scopeText, 'BTCUSDT', true), false); - assert.equal(isCurrentSymbolOpenOrdersFilterReady({ - scopeText, symbol: 'BTCUSDT', filterChecked: true, cancelAllAvailable: true, - }), false); - assert.equal(isCurrentSymbolOpenOrdersClearCandidate({ - scopeText, symbol: 'BTCUSDT', openOrdersCount: 0, - }), false); - assert.equal(hasCurrentSymbolOpenOrdersEvidence({ - scopeText: `${other}永续`, symbol: 'BTCUSDT', symbolFilterOk: true, cancelAllAvailable: true, - }), false); - } - assert.equal(isOpenOrdersScopeLimitedToSymbolText('龙虾USDT永续', '虾USDT'), false); - assert.equal(isOpenOrdersScopeLimitedToSymbolText('超级龙虾USDT永续', '龙虾USDT'), false); +test('user cannot treat Unicode or numeric-prefix contracts as a shorter current symbol', () => { + // Given BTCUSDT rows mixed with distinct Unicode and numeric-prefix contracts. + const others = ['龙虾USDT', '龙虾BTCUSDT', 'A_BTCUSDT', '27BTCUSDT', '4USDT']; + const cases = others.map((other) => ({ other, scopeText: 'BTCUSDT 永续 ' + other + ' 永续' })); + + // When symbol parsing and every cancellation-scope gate read those rows. + const results = cases.map(({ other, scopeText }) => ({ + symbols: readVisibleOpenOrderSymbolsText(scopeText), + limited: isOpenOrdersScopeLimitedToSymbolText(scopeText, 'BTCUSDT'), + confirmed: isOpenOrdersScopeConfirmedForSymbolText(scopeText, 'BTCUSDT', true), + ready: isCurrentSymbolOpenOrdersFilterReady({ scopeText, symbol: 'BTCUSDT', filterChecked: true, cancelAllAvailable: true }), + clear: isCurrentSymbolOpenOrdersClearCandidate({ scopeText, symbol: 'BTCUSDT', openOrdersCount: 0 }), + evidence: hasCurrentSymbolOpenOrdersEvidence({ scopeText: other + '永续', symbol: 'BTCUSDT', symbolFilterOk: true, cancelAllAvailable: true }), + })); + const suffixMatches = [ + isOpenOrdersScopeLimitedToSymbolText('龙虾USDT永续', '虾USDT'), + isOpenOrdersScopeLimitedToSymbolText('超级龙虾USDT永续', '龙虾USDT'), + ]; + + // Then every complete contract remains distinct and mixed-symbol cancellation stays blocked. + results.forEach((result, index) => assert.deepEqual(result, { + symbols: ['BTCUSDT', others[index]], limited: false, confirmed: false, + ready: false, clear: false, evidence: false, + })); + assert.deepEqual(suffixMatches, [false, false]); }); -test('bare contracts are evidence only on their own complete lines', () => { - assert.deepEqual(readVisibleOpenOrderSymbolsText('\n龙虾USDT\nBTCUSDT永续\n4USDT\n'), ['龙虾USDT', 'BTCUSDT', '4USDT']); - assert.deepEqual(readVisibleOpenOrderSymbolsText('Account 龙虾USDT total'), []); - assert.equal(isOpenOrdersScopeLimitedToSymbolText('\n龙虾USDT\nBTCUSDT永续\n', 'BTCUSDT'), false); - assert.equal(isCurrentSymbolOpenOrdersClearCandidate({ - scopeText: '\n龙虾USDT\n', symbol: '龙虾USDT', openOrdersCount: 1, - }), false); +test('user cannot mistake a contract mention inside account text for an order row', () => { + // Given standalone contract lines and an embedded account summary mention. + const cases = [ + { read: readVisibleOpenOrderSymbolsText, args: ['\n龙虾USDT\nBTCUSDT永续\n4USDT\n'], expected: ['龙虾USDT', 'BTCUSDT', '4USDT'] }, + { read: readVisibleOpenOrderSymbolsText, args: ['Account 龙虾USDT total'], expected: [] }, + { read: isOpenOrdersScopeLimitedToSymbolText, args: ['\n龙虾USDT\nBTCUSDT永续\n', 'BTCUSDT'], expected: false }, + { read: isCurrentSymbolOpenOrdersClearCandidate, args: [{ + scopeText: '\n龙虾USDT\n', symbol: '龙虾USDT', openOrdersCount: 1, + }], expected: false }, + ]; + + // When the panel determines visible order symbols and clear candidates. + const results = cases.map(({ read, args }) => read(...args)); + + // Then only complete contract lines count as order evidence. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('order symbol cells match the complete contract and only supported labels', () => { - for (const symbol of ['龙虾USDT', '币安人生USDC', '4USDT', 'WUSDT', '1INCHUSDT', '1000PEPEUSDT', 'A_BTCUSDT']) { - for (const text of [symbol, `${symbol}永续`, `${symbol} Perp`, ` ${symbol}\n永续 `]) { - assert.equal(parseOpenOrderContractSymbol(text), symbol); - assert.equal(isOpenOrderRowCurrentSymbol(text, symbol), true); - } - } - for (const [text, symbol] of [ +test('user matches an order cell only to its full contract with a supported perpetual label', () => { + // Given supported contract cells and suffix, mixed-row, malformed, or empty lookalikes. + const symbols = ['龙虾USDT', '币安人生USDC', '4USDT', 'WUSDT', '1INCHUSDT', '1000PEPEUSDT', 'A_BTCUSDT']; + const valid = symbols.flatMap((symbol) => [symbol, symbol + '永续', symbol + ' Perp', ' ' + symbol + '\n永续 '] + .map((text) => ({ text, symbol }))); + const invalid = [ ['龙虾USDT永续', '虾USDT'], ['超级龙虾USDT永续', '龙虾USDT'], ['龙虾BTCUSDT永续', 'BTCUSDT'], ['A_BTCUSDT永续', 'BTCUSDT'], ['27BTCUSDT永续', 'BTCUSDT'], ['BTCUSDT 永续 龙虾USDT 永续', 'BTCUSDT'], ['BTCUSDT永续限价', 'BTCUSDT'], ['', ''], - ]) { - assert.equal(isOpenOrderRowCurrentSymbol(text, symbol), false, `${text} / ${symbol}`); - } - assert.equal(parseOpenOrderContractSymbol('BTCUSDT?'), null); - assert.equal(parseOpenOrderContractSymbol('USDT'), null); + ]; + + // When each cell is parsed and compared with the selected symbol. + const parsed = valid.map(({ text, symbol }) => [parseOpenOrderContractSymbol(text), isOpenOrderRowCurrentSymbol(text, symbol)]); + const rejected = invalid.map(([text, symbol]) => isOpenOrderRowCurrentSymbol(text, symbol)); + const invalidContracts = ['BTCUSDT?', 'USDT'].map(parseOpenOrderContractSymbol); + + // Then supported cells match completely and every lookalike is refused. + parsed.forEach((result, index) => assert.deepEqual(result, [valid[index].symbol, true])); + rejected.forEach((result, index) => assert.equal(result, false, invalid[index].join(' / '))); + assert.deepEqual(invalidContracts, [null, null]); }); -test('visible open-order symbols include USDC perpetual contracts', () => { - assert.deepEqual( - readVisibleOpenOrderSymbolsText('BTCUSDC 永续 价格 数量 HYPEUSDT 永续'), - ['BTCUSDC', 'HYPEUSDT'], - ); - assert.equal(isOpenOrdersScopeLimitedToSymbolText('BTCUSDC 永续 BTCUSDC 永续', 'BTCUSDC'), true); +test('user can scope cancellations to a USDC perpetual contract', () => { + // Given mixed USDC and USDT rows plus repeated rows for one USDC symbol. + const cases = [ + { read: readVisibleOpenOrderSymbolsText, args: ['BTCUSDC 永续 价格 数量 HYPEUSDT 永续'], expected: ['BTCUSDC', 'HYPEUSDT'] }, + { read: isOpenOrdersScopeLimitedToSymbolText, args: ['BTCUSDC 永续 BTCUSDC 永续', 'BTCUSDC'], expected: true }, + ]; + + // When visible order symbols and scope are resolved. + const results = cases.map(({ read, args }) => read(...args)); + + // Then both quote currencies retain their exact contract identity. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('account open-order count never proves that the current symbol has orders', () => { - assert.equal(hasCurrentSymbolOpenOrdersEvidence({ - scopeText: '隐藏其他合约 当前委托', - symbol: 'HYPEUSDT', - symbolFilterOk: true, - openOrdersCount: 2, - }), false); - assert.equal(hasCurrentSymbolOpenOrdersEvidence({ - scopeText: '隐藏其他合约 当前委托', - symbol: 'HYPEUSDT', - symbolFilterOk: false, - openOrdersCount: 2, - }), false); +test('user cannot cancel from an account-wide order count alone', () => { + // Given a nonzero account count without visible current-symbol rows or an available cancel control. + const cases = [ + { read: hasCurrentSymbolOpenOrdersEvidence, args: [{ + scopeText: '隐藏其他合约 当前委托', + symbol: 'HYPEUSDT', + symbolFilterOk: true, + openOrdersCount: 2, + }], expected: false }, + { read: hasCurrentSymbolOpenOrdersEvidence, args: [{ + scopeText: '隐藏其他合约 当前委托', + symbol: 'HYPEUSDT', + symbolFilterOk: false, + openOrdersCount: 2, + }], expected: false }, + ]; + + // When current-symbol cancellation evidence is evaluated. + const results = cases.map(({ read, args }) => read(...args)); + + // Then the account count does not authorize cancellation. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('confirmed filtered empty state proves only the current symbol has no orders', () => { - assert.equal(isFilteredCurrentSymbolOpenOrdersEmpty({ - scopeText: '基础单(1) 隐藏其他合约 全撤 暂无当前委托。', - symbol: 'HYPEUSDT', - filterChecked: true, - cancelAllAvailable: false, - }), true); - assert.equal(isFilteredCurrentSymbolOpenOrdersEmpty({ - scopeText: 'Basic(1) Hide Other Symbols Cancel All You have no open orders.', - symbol: 'HYPEUSDT', - filterChecked: true, - cancelAllAvailable: false, - }), true); - assert.equal(isFilteredCurrentSymbolOpenOrdersEmpty({ - scopeText: '基础单(1) 隐藏其他合约 全撤 暂无当前委托。', - symbol: 'HYPEUSDT', - filterChecked: false, - cancelAllAvailable: false, - }), false); - assert.equal(isFilteredCurrentSymbolOpenOrdersEmpty({ - scopeText: '基础单(1) 隐藏其他合约 HYPEUSDT 永续 暂无当前委托。', - symbol: 'HYPEUSDT', - filterChecked: true, - cancelAllAvailable: false, - }), false); - assert.equal(isFilteredCurrentSymbolOpenOrdersEmpty({ - scopeText: '基础单(1) 隐藏其他合约 全撤 暂无当前委托。', - symbol: 'HYPEUSDT', - filterChecked: true, - cancelAllAvailable: true, - }), false); - assert.equal(isFilteredCurrentSymbolOpenOrdersEmpty({ - scopeText: '基础单(1) 隐藏其他合约', - symbol: 'HYPEUSDT', - filterChecked: true, - cancelAllAvailable: false, - }), false); +test('user sees no current-symbol orders only after the filtered empty state settles', () => { + // Given localized empty states, stale rows, and unresolved filter or cancel-control states. + const cases = [ + { read: isFilteredCurrentSymbolOpenOrdersEmpty, args: [{ + scopeText: '基础单(1) 隐藏其他合约 全撤 暂无当前委托。', + symbol: 'HYPEUSDT', + filterChecked: true, + cancelAllAvailable: false, + }], expected: true }, + { read: isFilteredCurrentSymbolOpenOrdersEmpty, args: [{ + scopeText: 'Basic(1) Hide Other Symbols Cancel All You have no open orders.', + symbol: 'HYPEUSDT', + filterChecked: true, + cancelAllAvailable: false, + }], expected: true }, + { read: isFilteredCurrentSymbolOpenOrdersEmpty, args: [{ + scopeText: '基础单(1) 隐藏其他合约 全撤 暂无当前委托。', + symbol: 'HYPEUSDT', + filterChecked: false, + cancelAllAvailable: false, + }], expected: false }, + { read: isFilteredCurrentSymbolOpenOrdersEmpty, args: [{ + scopeText: '基础单(1) 隐藏其他合约 HYPEUSDT 永续 暂无当前委托。', + symbol: 'HYPEUSDT', + filterChecked: true, + cancelAllAvailable: false, + }], expected: false }, + { read: isFilteredCurrentSymbolOpenOrdersEmpty, args: [{ + scopeText: '基础单(1) 隐藏其他合约 全撤 暂无当前委托。', + symbol: 'HYPEUSDT', + filterChecked: true, + cancelAllAvailable: true, + }], expected: false }, + { read: isFilteredCurrentSymbolOpenOrdersEmpty, args: [{ + scopeText: '基础单(1) 隐藏其他合约', + symbol: 'HYPEUSDT', + filterChecked: true, + cancelAllAvailable: false, + }], expected: false }, + ]; + + // When the filtered empty-state evidence is evaluated. + const results = cases.map(({ read, args }) => read(...args)); + + // Then only a checked filter with explicit empty text and no rows confirms emptiness. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('centralizes verified Binance account-order page texts', () => { - assert.equal(hasBinanceCurrentSymbolOpenOrdersEmptyText('暂无当前委托。'), true); - assert.equal(hasBinanceCurrentSymbolOpenOrdersEmptyText('You have no open orders.'), true); - assert.equal(hasBinanceCurrentSymbolOpenOrdersEmptyText('当前没有订单'), false); - assert.equal(isBinanceCancelAllText('全撤'), true); - assert.equal(isBinanceCancelAllText('Cancel All'), true); - assert.equal(isBinanceCancelAllText('撤本币挂单'), false); +test('user recognizes only the verified empty-state and cancel labels', () => { + // Given the supported Chinese and English Binance labels plus similar unsupported text. + const cases = [ + { read: hasBinanceCurrentSymbolOpenOrdersEmptyText, args: ['暂无当前委托。'], expected: true }, + { read: hasBinanceCurrentSymbolOpenOrdersEmptyText, args: ['You have no open orders.'], expected: true }, + { read: hasBinanceCurrentSymbolOpenOrdersEmptyText, args: ['当前没有订单'], expected: false }, + { read: isBinanceCancelAllText, args: ['全撤'], expected: true }, + { read: isBinanceCancelAllText, args: ['Cancel All'], expected: true }, + { read: isBinanceCancelAllText, args: ['撤本币挂单'], expected: false }, + ]; + + // When empty-state and cancel-all labels are classified. + const results = cases.map(({ read, args }) => read(...args)); + + // Then only verified native page text matches. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('current-symbol filter readiness rejects stale and transient React states', () => { - assert.equal(isCurrentSymbolOpenOrdersFilterReady({ - scopeText: 'BTCUSDT 永续 隐藏其他合约', - symbol: 'HYPEUSDT', - filterChecked: true, - cancelAllAvailable: true, - }), false); - assert.equal(isCurrentSymbolOpenOrdersFilterReady({ - scopeText: '隐藏其他合约', - symbol: 'HYPEUSDT', - filterChecked: true, - cancelAllAvailable: false, - }), false); - assert.equal(isCurrentSymbolOpenOrdersFilterReady({ - scopeText: 'HYPEUSDT 永续 隐藏其他合约 全撤', - symbol: 'HYPEUSDT', - filterChecked: true, - cancelAllAvailable: true, - }), true); - assert.equal(isCurrentSymbolOpenOrdersFilterReady({ - scopeText: '隐藏其他合约 暂无当前委托。', - symbol: 'HYPEUSDT', - filterChecked: true, - cancelAllAvailable: false, - }), true); - assert.equal(isCurrentSymbolOpenOrdersFilterReady({ - scopeText: 'Hide Other Symbols You have no open orders.', - symbol: 'HYPEUSDT', - filterChecked: true, - cancelAllAvailable: false, - }), true); - assert.equal(isCurrentSymbolOpenOrdersFilterReady({ - scopeText: 'HYPEUSDT 永续', - symbol: 'HYPEUSDT', - filterChecked: false, - cancelAllAvailable: true, - }), false); +test('user waits until the current-symbol filter has replaced stale order rows', () => { + // Given checked and unchecked filters with stale rows, current rows, or a localized empty state. + const cases = [ + { read: isCurrentSymbolOpenOrdersFilterReady, args: [{ + scopeText: 'BTCUSDT 永续 隐藏其他合约', + symbol: 'HYPEUSDT', + filterChecked: true, + cancelAllAvailable: true, + }], expected: false }, + { read: isCurrentSymbolOpenOrdersFilterReady, args: [{ + scopeText: '隐藏其他合约', + symbol: 'HYPEUSDT', + filterChecked: true, + cancelAllAvailable: false, + }], expected: false }, + { read: isCurrentSymbolOpenOrdersFilterReady, args: [{ + scopeText: 'HYPEUSDT 永续 隐藏其他合约 全撤', + symbol: 'HYPEUSDT', + filterChecked: true, + cancelAllAvailable: true, + }], expected: true }, + { read: isCurrentSymbolOpenOrdersFilterReady, args: [{ + scopeText: '隐藏其他合约 暂无当前委托。', + symbol: 'HYPEUSDT', + filterChecked: true, + cancelAllAvailable: false, + }], expected: true }, + { read: isCurrentSymbolOpenOrdersFilterReady, args: [{ + scopeText: 'Hide Other Symbols You have no open orders.', + symbol: 'HYPEUSDT', + filterChecked: true, + cancelAllAvailable: false, + }], expected: true }, + { read: isCurrentSymbolOpenOrdersFilterReady, args: [{ + scopeText: 'HYPEUSDT 永续', + symbol: 'HYPEUSDT', + filterChecked: false, + cancelAllAvailable: true, + }], expected: false }, + ]; + + // When the filtered pane is checked for readiness. + const results = cases.map(({ read, args }) => read(...args)); + + // Then transient and other-symbol rows remain unready. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('enabled cancel-all is evidence after current-symbol filter is confirmed', () => { - assert.equal(hasCurrentSymbolOpenOrdersEvidence({ - scopeText: '隐藏其他合约 当前委托 价格 数量', - symbol: 'HYPEUSDT', - symbolFilterOk: true, - openOrdersCount: null, - cancelAllAvailable: true, - }), true); - assert.equal(hasCurrentSymbolOpenOrdersEvidence({ - scopeText: '隐藏其他合约 当前委托 价格 数量', - symbol: 'HYPEUSDT', - symbolFilterOk: false, - openOrdersCount: null, - cancelAllAvailable: true, - }), false); +test('user needs a confirmed symbol filter before an available cancel control proves orders exist', () => { + // Given the same enabled cancel control with confirmed and unconfirmed filters. + const cases = [ + { read: hasCurrentSymbolOpenOrdersEvidence, args: [{ + scopeText: '隐藏其他合约 当前委托 价格 数量', + symbol: 'HYPEUSDT', + symbolFilterOk: true, + openOrdersCount: null, + cancelAllAvailable: true, + }], expected: true }, + { read: hasCurrentSymbolOpenOrdersEvidence, args: [{ + scopeText: '隐藏其他合约 当前委托 价格 数量', + symbol: 'HYPEUSDT', + symbolFilterOk: false, + openOrdersCount: null, + cancelAllAvailable: true, + }], expected: false }, + ]; + + // When current-symbol order evidence is evaluated. + const results = cases.map(({ read, args }) => read(...args)); + + // Then only the confirmed filter authorizes the control as evidence. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('zero tab count or other visible symbols do not authorize current-symbol cancel', () => { - assert.equal(hasCurrentSymbolOpenOrdersEvidence({ - scopeText: '隐藏其他合约 当前委托', - symbol: 'HYPEUSDT', - symbolFilterOk: true, - openOrdersCount: 0, - }), false); - assert.equal(hasCurrentSymbolOpenOrdersEvidence({ - scopeText: 'BTCUSDT 永续', - symbol: 'HYPEUSDT', - symbolFilterOk: true, - openOrdersCount: 2, - }), false); - assert.equal(hasCurrentSymbolOpenOrdersEvidence({ - scopeText: 'BTCUSDT 永续', - symbol: 'HYPEUSDT', - symbolFilterOk: true, - openOrdersCount: null, - cancelAllAvailable: true, - }), false); +test('user cannot cancel another symbol from a zero or unrelated account count', () => { + // Given empty current-symbol text or rows belonging only to another symbol. + const cases = [ + { read: hasCurrentSymbolOpenOrdersEvidence, args: [{ + scopeText: '隐藏其他合约 当前委托', + symbol: 'HYPEUSDT', + symbolFilterOk: true, + openOrdersCount: 0, + }], expected: false }, + { read: hasCurrentSymbolOpenOrdersEvidence, args: [{ + scopeText: 'BTCUSDT 永续', + symbol: 'HYPEUSDT', + symbolFilterOk: true, + openOrdersCount: 2, + }], expected: false }, + { read: hasCurrentSymbolOpenOrdersEvidence, args: [{ + scopeText: 'BTCUSDT 永续', + symbol: 'HYPEUSDT', + symbolFilterOk: true, + openOrdersCount: null, + cancelAllAvailable: true, + }], expected: false }, + ]; + + // When cancellation evidence is evaluated against the active symbol. + const results = cases.map(({ read, args }) => read(...args)); + + // Then unrelated rows and account counts do not authorize the action. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('scope is limited only when all visible symbols match current symbol', () => { - assert.equal(isOpenOrdersScopeLimitedToSymbolText('HYPEUSDT 永续 HYPEUSDT 永续', 'HYPEUSDT'), true); - assert.equal(isOpenOrdersScopeLimitedToSymbolText('HYPEUSDT 永续 BTCUSDT 永续', 'HYPEUSDT'), false); - assert.equal(isOpenOrdersScopeLimitedToSymbolText('隐藏其他合约', 'HYPEUSDT'), false); +test('user limits a cancellation scope to rows of exactly one current symbol', () => { + // Given matching rows, mixed-symbol rows, and a pane with no readable rows. + const cases = [ + { read: isOpenOrdersScopeLimitedToSymbolText, args: ['HYPEUSDT 永续 HYPEUSDT 永续', 'HYPEUSDT'], expected: true }, + { read: isOpenOrdersScopeLimitedToSymbolText, args: ['HYPEUSDT 永续 BTCUSDT 永续', 'HYPEUSDT'], expected: false }, + { read: isOpenOrdersScopeLimitedToSymbolText, args: ['隐藏其他合约', 'HYPEUSDT'], expected: false }, + ]; + + // When the visible scope is compared with the current symbol. + const results = cases.map(({ read, args }) => read(...args)); + + // Then only a nonempty set of matching symbols confirms the scope. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('checked current-symbol filter rejects transient rows from another symbol', () => { - assert.equal(isOpenOrdersScopeConfirmedForSymbolText('BTCUSDT 永续', 'HYPEUSDT', true), false); - assert.equal(isOpenOrdersScopeConfirmedForSymbolText('HYPEUSDT 永续', 'HYPEUSDT', false), false); - assert.equal(isOpenOrdersScopeConfirmedForSymbolText('HYPEUSDT 永续', 'HYPEUSDT', true), true); - assert.equal(isOpenOrdersScopeConfirmedForSymbolText('隐藏其他合约', 'HYPEUSDT', true), true); - assert.equal(isOpenOrdersScopeConfirmedForSymbolText('隐藏其他合约', 'HYPEUSDT', false), false); +test('user cannot trust a checked filter while another symbol remains visible', () => { + // Given current and stale rows under both checked and unchecked filter states. + const cases = [ + { read: isOpenOrdersScopeConfirmedForSymbolText, args: ['BTCUSDT 永续', 'HYPEUSDT', true], expected: false }, + { read: isOpenOrdersScopeConfirmedForSymbolText, args: ['HYPEUSDT 永续', 'HYPEUSDT', false], expected: false }, + { read: isOpenOrdersScopeConfirmedForSymbolText, args: ['HYPEUSDT 永续', 'HYPEUSDT', true], expected: true }, + { read: isOpenOrdersScopeConfirmedForSymbolText, args: ['隐藏其他合约', 'HYPEUSDT', true], expected: true }, + { read: isOpenOrdersScopeConfirmedForSymbolText, args: ['隐藏其他合约', 'HYPEUSDT', false], expected: false }, + ]; + + // When the active symbol scope is confirmed. + const results = cases.map(({ read, args }) => read(...args)); + + // Then stale other-symbol rows prevent confirmation even with a checked filter. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('clear candidate accepts account zero despite stale current rows', () => { - assert.equal(isCurrentSymbolOpenOrdersClearCandidate({ - scopeText: 'HYPEUSDT 永续 全撤', - symbol: 'HYPEUSDT', - openOrdersCount: 0, - }), true); - assert.equal(isCurrentSymbolOpenOrdersClearCandidate({ - scopeText: 'HYPEUSDT 永续 全撤', - symbol: 'HYPEUSDT', - openOrdersCount: 1, - }), false); +test('user can observe cancellation progress after the account count reaches zero', () => { + // Given the same stale current-symbol row with account counts of zero and one. + const cases = [ + { read: isCurrentSymbolOpenOrdersClearCandidate, args: [{ + scopeText: 'HYPEUSDT 永续 全撤', + symbol: 'HYPEUSDT', + openOrdersCount: 0, + }], expected: true }, + { read: isCurrentSymbolOpenOrdersClearCandidate, args: [{ + scopeText: 'HYPEUSDT 永续 全撤', + symbol: 'HYPEUSDT', + openOrdersCount: 1, + }], expected: false }, + ]; + + // When the cleared-order candidate is evaluated. + const results = cases.map(({ read, args }) => read(...args)); + + // Then the authoritative zero count permits clearing despite the stale row. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('account zero is definitive while filtered empty state still settles', () => { - assert.equal(isCurrentSymbolOpenOrdersDefinitivelyClear({ - scopeText: 'HYPEUSDT 永续 全撤', - symbol: 'HYPEUSDT', - openOrdersCount: 0, - }), true); - assert.equal(isCurrentSymbolOpenOrdersDefinitivelyClear({ - scopeText: '隐藏其他合约 当前委托', - symbol: 'HYPEUSDT', - openOrdersCount: 3, - }), false); - assert.equal(isCurrentSymbolOpenOrdersDefinitivelyClear({ - scopeText: 'BTCUSDT 永续', - symbol: 'HYPEUSDT', - openOrdersCount: 0, - }), false); +test('user distinguishes definitive account zero from a filtered empty candidate', () => { + // Given account zero with stale current rows, a nonzero count, and another-symbol rows. + const cases = [ + { read: isCurrentSymbolOpenOrdersDefinitivelyClear, args: [{ + scopeText: 'HYPEUSDT 永续 全撤', + symbol: 'HYPEUSDT', + openOrdersCount: 0, + }], expected: true }, + { read: isCurrentSymbolOpenOrdersDefinitivelyClear, args: [{ + scopeText: '隐藏其他合约 当前委托', + symbol: 'HYPEUSDT', + openOrdersCount: 3, + }], expected: false }, + { read: isCurrentSymbolOpenOrdersDefinitivelyClear, args: [{ + scopeText: 'BTCUSDT 永续', + symbol: 'HYPEUSDT', + openOrdersCount: 0, + }], expected: false }, + ]; + + // When definitive current-symbol clearing is evaluated. + const results = cases.map(({ read, args }) => read(...args)); + + // Then only account zero in a valid scope is definitive. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('clear candidate isolates current symbol when other account orders remain', () => { - assert.equal(isCurrentSymbolOpenOrdersClearCandidate({ - scopeText: '隐藏其他合约 当前委托', - symbol: 'HYPEUSDT', - openOrdersCount: 3, - }), true); - assert.equal(isCurrentSymbolOpenOrdersClearCandidate({ - scopeText: 'BTCUSDT 永续', - symbol: 'HYPEUSDT', - openOrdersCount: 0, - }), false); +test('user can finish current-symbol cancellation while other account orders remain', () => { + // Given an empty current-symbol pane and an invalid pane showing another symbol. + const cases = [ + { read: isCurrentSymbolOpenOrdersClearCandidate, args: [{ + scopeText: '隐藏其他合约 当前委托', + symbol: 'HYPEUSDT', + openOrdersCount: 3, + }], expected: true }, + { read: isCurrentSymbolOpenOrdersClearCandidate, args: [{ + scopeText: 'BTCUSDT 永续', + symbol: 'HYPEUSDT', + openOrdersCount: 0, + }], expected: false }, + ]; + + // When current-symbol clear candidates are evaluated. + const results = cases.map(({ read, args }) => read(...args)); + + // Then other account orders are allowed only while the filtered scope is valid. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('clear candidate must remain stable and resets when orders reappear', () => { - let state = updateOpenOrdersClearStability({ - clearCandidate: true, - clearCandidateSince: null, - nowMs: 1_000, - settleMs: 1_200, - }); +test('user waits for a fresh stability window after an order reappears', () => { + // Given a 1200 ms settle window and no earlier clear candidate. + const settleMs = 1_200; + let state; + + // When the first clear observation occurs at 1000 ms. + state = updateOpenOrdersClearStability({ clearCandidate: true, clearCandidateSince: null, nowMs: 1_000, settleMs }); + + // Then the observation starts a new window without claiming success. assert.deepEqual(state, { clearCandidateSince: 1_000, cleared: false }); - state = updateOpenOrdersClearStability({ - clearCandidate: true, - clearCandidateSince: state.clearCandidateSince, - nowMs: 2_199, - settleMs: 1_200, - }); + // When the same candidate remains one millisecond short of the window. + state = updateOpenOrdersClearStability({ clearCandidate: true, clearCandidateSince: state.clearCandidateSince, nowMs: 2_199, settleMs }); + + // Then clearing remains pending. assert.deepEqual(state, { clearCandidateSince: 1_000, cleared: false }); - state = updateOpenOrdersClearStability({ - clearCandidate: false, - clearCandidateSince: state.clearCandidateSince, - nowMs: 2_200, - settleMs: 1_200, - }); + // When an order reappears at the original deadline. + state = updateOpenOrdersClearStability({ clearCandidate: false, clearCandidateSince: state.clearCandidateSince, nowMs: 2_200, settleMs }); + + // Then the old candidate and its elapsed time are discarded. assert.deepEqual(state, { clearCandidateSince: null, cleared: false }); - state = updateOpenOrdersClearStability({ - clearCandidate: true, - clearCandidateSince: state.clearCandidateSince, - nowMs: 2_300, - settleMs: 1_200, - }); - state = updateOpenOrdersClearStability({ - clearCandidate: true, - clearCandidateSince: state.clearCandidateSince, - nowMs: 3_500, - settleMs: 1_200, - }); + // When a new candidate survives its own full window from 2300 to 3500 ms. + state = updateOpenOrdersClearStability({ clearCandidate: true, clearCandidateSince: state.clearCandidateSince, nowMs: 2_300, settleMs }); + state = updateOpenOrdersClearStability({ clearCandidate: true, clearCandidateSince: state.clearCandidateSince, nowMs: 3_500, settleMs }); + + // Then clearing completes using the new candidate's start time. assert.deepEqual(state, { clearCandidateSince: 2_300, cleared: true }); }); -test('post-stall clear candidate completes validation after wall-clock deadline', () => { - assert.equal(shouldContinueOpenOrdersClearObservation({ - nowMs: 8_500, - deadlineMs: 6_500, - clearCandidate: true, - }), true); - assert.equal(shouldContinueOpenOrdersClearObservation({ - nowMs: 8_500, - deadlineMs: 6_500, - clearCandidate: false, - }), false); +test('user can finish a stable clear observation after the main thread stalls', () => { + // Given a resumed clock beyond the deadline with and without a clear candidate. + const cases = [ + { read: shouldContinueOpenOrdersClearObservation, args: [{ + nowMs: 8_500, + deadlineMs: 6_500, + clearCandidate: true, + }], expected: true }, + { read: shouldContinueOpenOrdersClearObservation, args: [{ + nowMs: 8_500, + deadlineMs: 6_500, + clearCandidate: false, + }], expected: false }, + ]; + + // When the observation decides whether it still needs to settle. + const results = cases.map(({ read, args }) => read(...args)); + + // Then only an existing clear candidate may continue beyond the deadline. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); +}); + + +test('user cannot authorize cancellation before the current symbol is known', () => { + // Given an unavailable symbol while a filtered pane appears empty or contains orders. + const missingSymbols = [null, '']; + + // When all symbol-dependent cancellation evidence is evaluated. + const results = missingSymbols.map((symbol) => ({ + scope: isOpenOrdersScopeLimitedToSymbolText('BTCUSDT 永续', symbol), + empty: isFilteredCurrentSymbolOpenOrdersEmpty({ scopeText: '暂无当前委托。', symbol, filterChecked: true, cancelAllAvailable: false }), + orders: hasCurrentSymbolOpenOrdersEvidence({ scopeText: 'BTCUSDT 永续', symbol, symbolFilterOk: true, cancelAllAvailable: true }), + row: isOpenOrderRowCurrentSymbol('BTCUSDT 永续', symbol), + })); + const unreadText = [normalizeText(null), readVisibleOpenOrderSymbolsText(null)]; + + // Then no scope or order is authorized and absent text remains empty evidence. + results.forEach((result) => assert.deepEqual(result, { scope: false, empty: false, orders: false, row: false })); + assert.deepEqual(unreadText, ['', []]); +}); + +test('user keeps observing before the deadline even without a clear candidate', () => { + // Given the final millisecond before a 6500 ms observation deadline. + const observation = { nowMs: 6_499, deadlineMs: 6_500, clearCandidate: false }; + + // When observation permission is checked before and exactly at the deadline. + const before = shouldContinueOpenOrdersClearObservation(observation); + const atDeadline = shouldContinueOpenOrdersClearObservation({ ...observation, nowMs: 6_500 }); + + // Then a missing candidate times out exactly at the deadline. + assert.equal(before, true); + assert.equal(atDeadline, false); }); diff --git a/test/unit/binance-orderbook-trade/chart-save-coalescer.test.js b/test/unit/binance-orderbook-trade/chart-save-coalescer.test.js index 1a1f865..e495b3c 100644 --- a/test/unit/binance-orderbook-trade/chart-save-coalescer.test.js +++ b/test/unit/binance-orderbook-trade/chart-save-coalescer.test.js @@ -84,27 +84,36 @@ function createManualTimers() { return { advance, clearTimeoutFn, setTimeoutFn, timers }; } -test('bulk removal controller keeps unrelated saves synchronous before the first remove', async () => { +test('user keeps unrelated chart saves synchronous before a removal starts', async () => { + // Given a native chart API and a removal-save lifecycle with recorded saves. const { api, saved, listeners } = createTradingViewApi(); const originalSaveChart = api.saveChart; const controller = createTradingViewRemovalSaveController(api, { eventDiscoveryMs: 0 }); - assert.notEqual(api.saveChart, originalSaveChart); + + // When the native chart requests its next snapshot. api.saveChart('unrelated'); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.deepEqual(saved.map((entry) => entry.args), [['unrelated']]); - assert.deepEqual(await controller.finish(), { + + // When the removal lifecycle requests its final snapshot. + const lifecycleResult1 = await controller.finish(); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. + assert.deepEqual(lifecycleResult1, { fullSaveCount: 0, removeEventCount: 0, saveRequestCount: 0, synchronousSaveCount: 1, }); - assert.equal(api.saveChart, originalSaveChart); assert.deepEqual(saved.map((entry) => entry.args), [['unrelated']]); assert.equal(listeners.get('drawing_event')?.size, 0); }); -test('bulk removal controller persists one final snapshot across separate remove bursts', async () => { +test('user persists the latest chart snapshot across separate removal bursts', async () => { + // Given a native chart API and a removal-save lifecycle with recorded saves. const { api, saved, listeners } = createTradingViewApi(); const originalSaveChart = api.saveChart; const timers = createManualTimers(); @@ -116,12 +125,16 @@ test('bulk removal controller persists one final snapshot across separate remove clearTimeoutFn: timers.clearTimeoutFn, }); + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'remove'); api.saveChart('snapshot-1'); timers.advance(20); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.notEqual(api.saveChart, originalSaveChart); assert.deepEqual(saved, []); + // When the native chart requests its next snapshot. api.saveChart('unrelated-between-bursts'); api.emit('drawing_event', 'order-2', 'remove'); api.saveChart('snapshot-2'); @@ -129,6 +142,7 @@ test('bulk removal controller persists one final snapshot across separate remove timers.advance(20); const result = await completion; + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.deepEqual(result, { fullSaveCount: 1, removeEventCount: 2, @@ -143,7 +157,8 @@ test('bulk removal controller persists one final snapshot across separate remove assert.equal(listeners.get('drawing_event')?.size, 0); }); -test('bulk removal controller waits briefly for delayed remove events before finishing', async () => { +test('user includes delayed removal events before the chart lifecycle finishes', async () => { + // Given a native chart API and a removal-save lifecycle with recorded saves. const { api, saved } = createTradingViewApi(); const timers = createManualTimers(); const controller = createTradingViewRemovalSaveController(api, { @@ -153,13 +168,15 @@ test('bulk removal controller waits briefly for delayed remove events before fin setTimeoutFn: timers.setTimeoutFn, clearTimeoutFn: timers.clearTimeoutFn, }); - const completion = controller.finish(); + + // When virtual time reaches the next capture or settle deadline. timers.advance(10); api.emit('drawing_event', 'order-1', 'remove'); api.saveChart('snapshot-1'); timers.advance(20); + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.deepEqual(await completion, { fullSaveCount: 1, removeEventCount: 1, @@ -169,14 +186,18 @@ test('bulk removal controller waits briefly for delayed remove events before fin assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-1']]); }); -test('bulk removal controller ignores non-remove drawing events', async () => { +test('user saves unrelated chart changes immediately during a removal lifecycle', async () => { + // Given a native chart API and a removal-save lifecycle with recorded saves. const { api, saved } = createTradingViewApi(); const controller = createTradingViewRemovalSaveController(api, { eventDiscoveryMs: 0 }); + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'properties_changed'); api.saveChart('unrelated'); + const lifecycleResult1 = await controller.finish(); - assert.deepEqual(await controller.finish(), { + // Then saves, capture results, and chart ownership match the observed lifecycle state. + assert.deepEqual(lifecycleResult1, { fullSaveCount: 0, removeEventCount: 0, saveRequestCount: 0, @@ -185,7 +206,8 @@ test('bulk removal controller ignores non-remove drawing events', async () => { assert.deepEqual(saved.map((entry) => entry.args), [['unrelated']]); }); -test('bulk removal controller discards an older removal snapshot after a later synchronous save', async () => { +test('user keeps the newer synchronous chart snapshot over an older deferred removal', async () => { + // Given a native chart API and a removal-save lifecycle with recorded saves. const { api, saved } = createTradingViewApi(); const timers = createManualTimers(); const controller = createTradingViewRemovalSaveController(api, { @@ -196,12 +218,15 @@ test('bulk removal controller discards an older removal snapshot after a later s clearTimeoutFn: timers.clearTimeoutFn, }); + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'remove'); api.saveChart('removal-snapshot'); timers.advance(20); api.saveChart('newer-unrelated-snapshot'); + const lifecycleResult1 = await controller.finish(); - assert.deepEqual(await controller.finish(), { + // Then saves, capture results, and chart ownership match the observed lifecycle state. + assert.deepEqual(lifecycleResult1, { fullSaveCount: 0, removeEventCount: 1, saveRequestCount: 1, @@ -210,7 +235,8 @@ test('bulk removal controller discards an older removal snapshot after a later s assert.deepEqual(saved.map((entry) => entry.args), [['newer-unrelated-snapshot']]); }); -test('bulk removal controller preserves an externally replaced chart save method', async () => { +test('user keeps a chart save method replaced by another operation during removals', async () => { + // Given a native chart API and a removal-save lifecycle with recorded saves. const { api, saved, listeners } = createTradingViewApi(); const timers = createManualTimers(); const controller = createTradingViewRemovalSaveController(api, { @@ -221,6 +247,7 @@ test('bulk removal controller preserves an externally replaced chart save method clearTimeoutFn: timers.clearTimeoutFn, }); + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'remove'); api.saveChart('old-removal-snapshot'); const foreignSaves = []; @@ -228,15 +255,18 @@ test('bulk removal controller preserves an externally replaced chart save method api.saveChart = foreignSaveChart; api.saveChart('newer-foreign-snapshot'); timers.advance(20); + const lifecycleResult1 = controller.finish(); - await assert.rejects(controller.finish(), /图表保存接口在删除事件合并期间发生变化/); + // Then saves, capture results, and chart ownership match the observed lifecycle state. + await assert.rejects(lifecycleResult1, /图表保存接口在删除事件合并期间发生变化/); assert.equal(api.saveChart, foreignSaveChart); assert.deepEqual(saved, []); assert.deepEqual(foreignSaves, [['newer-foreign-snapshot']]); assert.equal(listeners.get('drawing_event')?.size, 0); }); -test('bulk removal controller drops a settled snapshot after external save ownership changes', async () => { +test('user avoids replaying a settled removal snapshot after save ownership changes', async () => { + // Given a native chart API and a removal-save lifecycle with recorded saves. const { api, saved } = createTradingViewApi(); const timers = createManualTimers(); const controller = createTradingViewRemovalSaveController(api, { @@ -247,6 +277,7 @@ test('bulk removal controller drops a settled snapshot after external save owner clearTimeoutFn: timers.clearTimeoutFn, }); + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'remove'); api.saveChart('old-removal-snapshot'); timers.advance(20); @@ -254,9 +285,11 @@ test('bulk removal controller drops a settled snapshot after external save owner const foreignSaveChart = (...args) => foreignSaves.push(args); api.saveChart = foreignSaveChart; api.saveChart('newer-foreign-snapshot'); + const lifecycleResult1 = controller.finish(); + // Then saves, capture results, and chart ownership match the observed lifecycle state. await assert.rejects( - controller.finish(), + lifecycleResult1, /图表保存接口在删除事件监视期间发生变化/, ); assert.equal(api.saveChart, foreignSaveChart); @@ -264,7 +297,8 @@ test('bulk removal controller drops a settled snapshot after external save owner assert.deepEqual(foreignSaves, [['newer-foreign-snapshot']]); }); -test('bulk removal controller restores the chart API before a final save error', async () => { +test('user regains chart ownership before a final removal-save failure is reported', async () => { + // Given a native chart API and a removal-save lifecycle with recorded saves. const { api, listeners } = createTradingViewApi(); const originalSaveChart = function saveChart() { throw new Error('final save failed'); @@ -279,17 +313,20 @@ test('bulk removal controller restores the chart API before a final save error', clearTimeoutFn: timers.clearTimeoutFn, }); + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'remove'); api.saveChart('snapshot-1'); const completion = controller.finish(); timers.advance(20); + // Then saves, capture results, and chart ownership match the observed lifecycle state. await assert.rejects(completion, /final save failed/); assert.equal(api.saveChart, originalSaveChart); assert.equal(listeners.get('drawing_event')?.size, 0); }); -test('bulk removal controller settles after an external replacement when the original save throws', async () => { +test('user gets the ownership error without invoking an obsolete failing save method', async () => { + // Given a native chart API and a removal-save lifecycle with recorded saves. const { api, listeners } = createTradingViewApi(); api.saveChart = function saveChart() { throw new Error('original save failed'); @@ -303,21 +340,25 @@ test('bulk removal controller settles after an external replacement when the ori clearTimeoutFn: timers.clearTimeoutFn, }); + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'remove'); api.saveChart('pending-snapshot'); const foreignSaveChart = () => {}; api.saveChart = foreignSaveChart; timers.advance(20); + const lifecycleResult1 = controller.finish(); + // Then saves, capture results, and chart ownership match the observed lifecycle state. await assert.rejects( - controller.finish(), + lifecycleResult1, /图表保存接口在删除事件合并期间发生变化/, ); assert.equal(api.saveChart, foreignSaveChart); assert.equal(listeners.get('drawing_event')?.size, 0); }); -test('continuous remove-save controller leaves unrelated chart saves synchronous', () => { +test('user keeps unrelated saves synchronous throughout continuous closing', () => { + // Given a native chart API and a continuous-save controller with recorded saves. const { api, saved } = createTradingViewApi(); const originalSaveChart = api.saveChart; const timers = createManualTimers(); @@ -328,14 +369,22 @@ test('continuous remove-save controller leaves unrelated chart saves synchronous clearTimeoutFn: timers.clearTimeoutFn, }); + // When the native chart requests its next snapshot. api.saveChart('unrelated'); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.deepEqual(saved.map((entry) => entry.args), [['unrelated']]); - assert.deepEqual(coalescer.stop(), expectedContinuousStats()); + // When the chart-save controller stops and releases its lifecycle. + const lifecycleResult1 = coalescer.stop(); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. + assert.deepEqual(lifecycleResult1, expectedContinuousStats()); assert.equal(api.saveChart, originalSaveChart); }); -test('continuous remove-save controller persists only the final save in one remove burst', () => { +test('user persists only the final snapshot from a continuous-close removal burst', () => { + // Given a native chart API and a continuous-save controller with recorded saves. const { api, saved } = createTradingViewApi(); const timers = createManualTimers(); const coalescer = createTradingViewContinuousSaveController(api, { @@ -345,6 +394,7 @@ test('continuous remove-save controller persists only the final save in one remo clearTimeoutFn: timers.clearTimeoutFn, }); + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'remove'); api.saveChart('snapshot-1'); timers.advance(10); @@ -354,18 +404,29 @@ test('continuous remove-save controller persists only the final save in one remo api.emit('drawing_event', 'order-3', 'remove'); api.saveChart('snapshot-3'); timers.advance(19); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.deepEqual(saved, []); + + // When virtual time reaches the next capture or settle deadline. timers.advance(1); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-3']]); - assert.deepEqual(coalescer.stop(), expectedContinuousStats({ + // When the chart-save controller stops and releases its lifecycle. + const lifecycleResult1 = coalescer.stop(); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. + assert.deepEqual(lifecycleResult1, expectedContinuousStats({ fullSaveCount: 1, removeEventCount: 3, saveRequestCount: 3, })); }); -test('continuous remove-save controller flushes at its maximum wait during sustained removals', () => { +test('user gets a chart save at the maximum deadline during sustained removals', () => { + // Given a native chart API and a continuous-save controller with recorded saves. const { api, saved } = createTradingViewApi(); const timers = createManualTimers(); const coalescer = createTradingViewContinuousSaveController(api, { @@ -375,6 +436,7 @@ test('continuous remove-save controller flushes at its maximum wait during susta clearTimeoutFn: timers.clearTimeoutFn, }); + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'remove'); api.saveChart('snapshot-1'); for (let index = 2; index <= 5; index += 1) { @@ -383,12 +445,14 @@ test('continuous remove-save controller flushes at its maximum wait during susta api.saveChart(`snapshot-${index}`); } timers.advance(20); - assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-5']]); + // Then saves, capture results, and chart ownership match the observed lifecycle state. + assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-5']]); coalescer.stop(); }); -test('continuous remove-save controller flushes pending state and restores the original method on stop', () => { +test('user flushes pending chart state and restores normal saving when stopping', () => { + // Given a native chart API and a continuous-save controller with recorded saves. const { api, saved } = createTradingViewApi(); const originalSaveChart = api.saveChart; const timers = createManualTimers(); @@ -399,9 +463,13 @@ test('continuous remove-save controller flushes pending state and restores the o clearTimeoutFn: timers.clearTimeoutFn, }); + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'remove'); api.saveChart('pending-final'); - assert.deepEqual(coalescer.stop(), expectedContinuousStats({ + const lifecycleResult1 = coalescer.stop(); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. + assert.deepEqual(lifecycleResult1, expectedContinuousStats({ fullSaveCount: 1, removeEventCount: 1, saveRequestCount: 1, @@ -410,38 +478,58 @@ test('continuous remove-save controller flushes pending state and restores the o assert.equal(api.saveChart, originalSaveChart); assert.equal(timers.timers.size, 0); + // When the native chart requests its next snapshot. api.saveChart('after-stop'); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.deepEqual(saved.map((entry) => entry.args), [['pending-final'], ['after-stop']]); }); -test('continuous remove-save controller ignores non-remove drawing events', () => { +test('user saves unrelated drawing properties outside removal bursts', () => { + // Given a native chart API and a continuous-save controller with recorded saves. const { api, saved } = createTradingViewApi(); const coalescer = createTradingViewContinuousSaveController(api); + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'properties_changed'); api.saveChart('properties'); + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.deepEqual(saved.map((entry) => entry.args), [['properties']]); - assert.deepEqual(coalescer.stop(), expectedContinuousStats()); + + // When the chart-save controller stops and releases its lifecycle. + const lifecycleResult1 = coalescer.stop(); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. + assert.deepEqual(lifecycleResult1, expectedContinuousStats()); }); -test('continuous remove-save controller does not replace another active save wrapper', () => { +test('user keeps another operation in control of chart saving', () => { + // Given a native chart API and a continuous-save controller with recorded saves. const { api, saved } = createTradingViewApi(); const controller = createTradingViewContinuousSaveController(api); const sessionSaveChart = api.saveChart; const foreignSaves = []; api.saveChart = (...args) => foreignSaves.push(args); + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'remove'); api.saveChart('foreign'); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.deepEqual(foreignSaves, [['foreign']]); assert.deepEqual(saved, []); + // When the next chart lifecycle operation runs. api.saveChart = sessionSaveChart; - assert.deepEqual(controller.stop(), expectedContinuousStats({ removeEventCount: 1 })); + const lifecycleResult1 = controller.stop(); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. + assert.deepEqual(lifecycleResult1, expectedContinuousStats({ removeEventCount: 1 })); }); -test('continuous remove-save controller restores the chart method when the final save throws', () => { +test('user regains the original chart method after a stop-time save failure', () => { + // Given a native chart API and a continuous-save controller with recorded saves. const { api, listeners } = createTradingViewApi(); const originalSaveChart = function saveChart() { throw new Error('final save failed'); @@ -449,15 +537,18 @@ test('continuous remove-save controller restores the chart method when the final api.saveChart = originalSaveChart; const controller = createTradingViewContinuousSaveController(api); + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'remove'); api.saveChart('pending'); + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.throws(() => controller.stop(), /final save failed/); assert.equal(api.saveChart, originalSaveChart); assert.equal(listeners.get('drawing_event')?.size, 0); }); -test('continuous submit captures five order-line saves and replays only the final round snapshot', async () => { +test('user persists one final chart snapshot after five confirmed order-line captures', async () => { + // Given a native chart API, an active close round, and order-line capture state. const { api, saved, @@ -474,6 +565,7 @@ test('continuous submit captures five order-line saves and replays only the fina }); const round = controller.beginRound(); + // When the native chart delivers the next sequence of drawing events and saves. for (let index = 1; index <= 5; index += 1) { const drawingId = `order-${index}`; setDrawingToolName(drawingId, 'LineToolOrder'); @@ -486,8 +578,10 @@ test('continuous submit captures five order-line saves and replays only the fina assert.equal(api.saveChart, originalSaveChart); assert.deepEqual(saved, []); } + const lifecycleResult1 = controller.endRound(round); - assert.deepEqual(controller.endRound(round), expectedContinuousStats({ + // Then saves, capture results, and chart ownership match the observed lifecycle state. + assert.deepEqual(lifecycleResult1, expectedContinuousStats({ deferredSubmitSaveCount: 5, fullSaveCount: 1, orderEventCount: 5, @@ -497,7 +591,8 @@ test('continuous submit captures five order-line saves and replays only the fina controller.stop(); }); -test('continuous submit capture keeps the final save from multiple order-line events', async () => { +test('user keeps the final snapshot when one submit produces several order-line changes', async () => { + // Given a native chart API, an active close round, and order-line capture state. const { api, saved, setDrawingToolName } = createTradingViewApi(); const timers = createManualTimers(); const controller = createTradingViewContinuousSaveController(api, { @@ -511,6 +606,7 @@ test('continuous submit capture keeps the final save from multiple order-line ev setDrawingToolName('order-1', 'LineToolOrder'); setDrawingToolName('order-2', 'LineToolOrder'); + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'properties_changed'); api.saveChart('snapshot-1'); timers.advance(10); @@ -519,10 +615,20 @@ test('continuous submit capture keeps the final save from multiple order-line ev const completion = controller.completeSubmitCapture(capture); timers.advance(20); + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.deepEqual(await completion, { matched: true, status: 'captured' }); + + // When the active chart-save round finishes. controller.endRound(round); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-2']]); - assert.deepEqual(controller.stop(), expectedContinuousStats({ + + // When the chart-save controller stops and releases its lifecycle. + const lifecycleResult1 = controller.stop(); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. + assert.deepEqual(lifecycleResult1, expectedContinuousStats({ deferredSubmitSaveCount: 1, fullSaveCount: 1, orderEventCount: 2, @@ -530,7 +636,8 @@ test('continuous submit capture keeps the final save from multiple order-line ev })); }); -test('continuous submit capture keeps an existing remove burst independent', async () => { +test('user persists preceding removals separately from the current submit capture', async () => { + // Given a native chart API, an active close round, and order-line capture state. const { api, saved, setDrawingToolName } = createTradingViewApi(); const timers = createManualTimers(); const controller = createTradingViewContinuousSaveController(api, { @@ -543,6 +650,7 @@ test('continuous submit capture keeps an existing remove burst independent', asy const capture = controller.beginSubmitCapture(round); setDrawingToolName('order-1', 'LineToolOrder'); + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'removed-order', 'remove'); api.saveChart('remove-snapshot'); api.emit('drawing_event', 'order-1', 'properties_changed'); @@ -550,9 +658,14 @@ test('continuous submit capture keeps an existing remove burst independent', asy const completion = controller.completeSubmitCapture(capture); timers.advance(20); + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.deepEqual(await completion, { matched: true, status: 'captured' }); assert.deepEqual(saved.map((entry) => entry.args), [['remove-snapshot']]); + + // When the active chart-save round finishes. controller.endRound(round); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.deepEqual(saved.map((entry) => entry.args), [ ['remove-snapshot'], ['order-snapshot'], @@ -560,7 +673,8 @@ test('continuous submit capture keeps an existing remove burst independent', asy controller.stop(); }); -test('continuous remove burst supersedes an older deferred submit snapshot', async () => { +test('user keeps a newer removal snapshot over an older deferred submission', async () => { + // Given a native chart API, an active close round, and order-line capture state. const { api, saved, setDrawingToolName } = createTradingViewApi(); const timers = createManualTimers(); const controller = createTradingViewContinuousSaveController(api, { @@ -573,22 +687,30 @@ test('continuous remove burst supersedes an older deferred submit snapshot', asy const capture = controller.beginSubmitCapture(round); setDrawingToolName('order-1', 'LineToolOrder'); + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'properties_changed'); api.saveChart('order-snapshot'); api.emit('drawing_event', 'removed-order', 'remove'); api.saveChart('newer-remove-snapshot'); timers.advance(20); + const lifecycleResult1 = await controller.completeSubmitCapture(capture); - assert.deepEqual(await controller.completeSubmitCapture(capture), { + // Then saves, capture results, and chart ownership match the observed lifecycle state. + assert.deepEqual(lifecycleResult1, { matched: true, status: 'captured', }); + + // When the active chart-save round finishes. controller.endRound(round); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.deepEqual(saved.map((entry) => entry.args), [['newer-remove-snapshot']]); controller.stop(); }); -test('continuous submit capture ignores position lines and leaves their saves synchronous', async () => { +test('user saves position lines immediately while an order-line capture is armed', async () => { + // Given a native chart API, an active close round, and order-line capture state. const { api, saved, setDrawingToolName } = createTradingViewApi(); const timers = createManualTimers(); const controller = createTradingViewContinuousSaveController(api, { @@ -602,18 +724,21 @@ test('continuous submit capture ignores position lines and leaves their saves sy const capture = controller.beginSubmitCapture(round); setDrawingToolName('position-1', 'LineToolPosition'); + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'position-1', 'properties_changed'); api.saveChart('position-snapshot'); const completion = controller.completeSubmitCapture(capture); timers.advance(10); + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.deepEqual(await completion, { matched: false, status: 'no-order-event' }); assert.deepEqual(saved.map((entry) => entry.args), [['position-snapshot']]); controller.endRound(round); controller.stop(); }); -test('continuous submit capture ignores click and move events for order lines', async () => { +test('user does not treat order-line clicks or moves as submission drawings', async () => { + // Given a native chart API, an active close round, and order-line capture state. const { api, saved, setDrawingToolName } = createTradingViewApi(); const timers = createManualTimers(); const controller = createTradingViewContinuousSaveController(api, { @@ -625,19 +750,22 @@ test('continuous submit capture ignores click and move events for order lines', const capture = controller.beginSubmitCapture(round); setDrawingToolName('order-1', 'LineToolOrder'); + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'click'); api.emit('drawing_event', 'order-1', 'move'); api.saveChart('interaction-snapshot'); const completion = controller.completeSubmitCapture(capture); timers.advance(10); + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.deepEqual(await completion, { matched: false, status: 'no-order-event' }); assert.deepEqual(saved.map((entry) => entry.args), [['interaction-snapshot']]); controller.endRound(round); controller.stop(); }); -test('continuous submit capture restores saveChart before unrelated saves outside the capture', async () => { +test('user can save unrelated chart changes after an order-line capture settles', async () => { + // Given a native chart API, an active close round, and order-line capture state. const { api, saved, setDrawingToolName } = createTradingViewApi(); const originalSaveChart = api.saveChart; const timers = createManualTimers(); @@ -651,16 +779,26 @@ test('continuous submit capture restores saveChart before unrelated saves outsid const capture = controller.beginSubmitCapture(round); setDrawingToolName('order-1', 'LineToolOrder'); + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'properties_changed'); api.saveChart('order-snapshot'); const completion = controller.completeSubmitCapture(capture); timers.advance(20); await completion; + + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.equal(api.saveChart, originalSaveChart); + // When the native chart requests its next snapshot. api.saveChart('unrelated-snapshot'); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.deepEqual(saved.map((entry) => entry.args), [['unrelated-snapshot']]); + + // When the active chart-save round finishes. controller.endRound(round); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.deepEqual(saved.map((entry) => entry.args), [ ['unrelated-snapshot'], ['order-snapshot'], @@ -668,7 +806,8 @@ test('continuous submit capture restores saveChart before unrelated saves outsid controller.stop(); }); -test('continuous submit capture flushes the pending round snapshot on stop', async () => { +test('user persists the pending order-line snapshot when continuous closing stops', async () => { + // Given a native chart API, an active close round, and order-line capture state. const { api, saved, listeners, setDrawingToolName } = createTradingViewApi(); const originalSaveChart = api.saveChart; const timers = createManualTimers(); @@ -681,13 +820,17 @@ test('continuous submit capture flushes the pending round snapshot on stop', asy const round = controller.beginRound(); const capture = controller.beginSubmitCapture(round); setDrawingToolName('order-1', 'LineToolOrder'); + + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'properties_changed'); api.saveChart('pending-round'); const completion = controller.completeSubmitCapture(capture); timers.advance(20); await completion; + const lifecycleResult1 = controller.stop(); - assert.deepEqual(controller.stop(), expectedContinuousStats({ + // Then saves, capture results, and chart ownership match the observed lifecycle state. + assert.deepEqual(lifecycleResult1, expectedContinuousStats({ deferredSubmitSaveCount: 1, fullSaveCount: 1, orderEventCount: 1, @@ -698,7 +841,8 @@ test('continuous submit capture flushes the pending round snapshot on stop', asy assert.equal(listeners.get('drawing_event')?.size, 0); }); -test('continuous submit capture flushes pending state while keeping the round active', async () => { +test('user can flush chart state and continue capturing the same close round', async () => { + // Given a native chart API, an active close round, and order-line capture state. const { api, saved, setDrawingToolName } = createTradingViewApi(); const timers = createManualTimers(); const controller = createTradingViewContinuousSaveController(api, { @@ -710,15 +854,19 @@ test('continuous submit capture flushes pending state while keeping the round ac const round = controller.beginRound(); setDrawingToolName('order-1', 'LineToolOrder'); const firstCapture = controller.beginSubmitCapture(round); + + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'properties_changed'); api.saveChart('snapshot-1'); const firstCompletion = controller.completeSubmitCapture(firstCapture); timers.advance(20); await firstCompletion; - controller.flush(); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-1']]); + // When the next chart lifecycle operation runs. setDrawingToolName('order-2', 'LineToolOrder'); const secondCapture = controller.beginSubmitCapture(round); api.emit('drawing_event', 'order-2', 'properties_changed'); @@ -726,21 +874,26 @@ test('continuous submit capture flushes pending state while keeping the round ac const secondCompletion = controller.completeSubmitCapture(secondCapture); timers.advance(20); await secondCompletion; - controller.endRound(round); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-1'], ['snapshot-2']]); controller.stop(); }); -test('continuous submit capture does not wait for discovery after a lifecycle flush', async () => { +test('user finishes an unmatched order-line capture immediately on a lifecycle flush', async () => { + // Given a native chart API, an active close round, and order-line capture state. const { api } = createTradingViewApi(); const controller = createTradingViewContinuousSaveController(api); const round = controller.beginRound(); const capture = controller.beginSubmitCapture(round); + // When the active chart-save lifecycle is flushed. controller.flush(); + const lifecycleResult1 = await controller.completeSubmitCapture(capture); - assert.deepEqual(await controller.completeSubmitCapture(capture), { + // Then saves, capture results, and chart ownership match the observed lifecycle state. + assert.deepEqual(lifecycleResult1, { matched: false, status: 'flushed', }); @@ -748,7 +901,8 @@ test('continuous submit capture does not wait for discovery after a lifecycle fl controller.stop(); }); -test('continuous submit ownership expires from the moment capture is armed', async () => { +test('user releases an unmatched order-line capture at its discovery deadline', async () => { + // Given a native chart API, an active close round, and order-line capture state. const { api } = createTradingViewApi(); const timers = createManualTimers(); const controller = createTradingViewContinuousSaveController(api, { @@ -759,9 +913,12 @@ test('continuous submit ownership expires from the moment capture is armed', asy const round = controller.beginRound(); const capture = controller.beginSubmitCapture(round); + // When virtual time reaches the next capture or settle deadline. timers.advance(10); + const lifecycleResult1 = await controller.completeSubmitCapture(capture); - assert.deepEqual(await controller.completeSubmitCapture(capture), { + // Then saves, capture results, and chart ownership match the observed lifecycle state. + assert.deepEqual(lifecycleResult1, { matched: false, status: 'no-order-event', }); @@ -769,7 +926,8 @@ test('continuous submit ownership expires from the moment capture is armed', asy controller.stop(); }); -test('continuous submit capture skips optimization when another save wrapper is active', async () => { +test('user keeps an existing chart save owner when the submitted order line arrives', async () => { + // Given a native chart API, an active close round, and order-line capture state. const { api, saved, setDrawingToolName } = createTradingViewApi(); const controller = createTradingViewContinuousSaveController(api); const round = controller.beginRound(); @@ -779,21 +937,29 @@ test('continuous submit capture skips optimization when another save wrapper is api.saveChart = (...args) => foreignSaves.push(args); setDrawingToolName('order-1', 'LineToolOrder'); + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'properties_changed'); - assert.deepEqual(await controller.completeSubmitCapture(capture), { + const lifecycleResult1 = await controller.completeSubmitCapture(capture); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. + assert.deepEqual(lifecycleResult1, { matched: true, status: 'save-chart-busy', }); + + // When the native chart requests its next snapshot. api.saveChart('foreign-order-snapshot'); + + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.deepEqual(foreignSaves, [['foreign-order-snapshot']]); assert.deepEqual(saved, []); - api.saveChart = sessionSaveChart; controller.endRound(round); controller.stop(); }); -test('continuous submit capture preserves a wrapper installed during its active burst', async () => { +test('user preserves a chart wrapper installed during an active submit capture', async () => { + // Given a native chart API, an active close round, and order-line capture state. const { api, saved, setDrawingToolName } = createTradingViewApi(); const timers = createManualTimers(); const controller = createTradingViewContinuousSaveController(api, { @@ -806,9 +972,10 @@ test('continuous submit capture preserves a wrapper installed during its active const capture = controller.beginSubmitCapture(round); const sessionSaveChart = api.saveChart; setDrawingToolName('order-1', 'LineToolOrder'); + + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'properties_changed'); api.saveChart('pending-round'); - const foreignSaves = []; const replacedSaveChart = api.saveChart; api.saveChart = function foreignSaveChart(...args) { @@ -816,21 +983,23 @@ test('continuous submit capture preserves a wrapper installed during its active return replacedSaveChart.apply(this, args); }; timers.advance(20); + const lifecycleResult1 = await controller.completeSubmitCapture(capture); - assert.deepEqual(await controller.completeSubmitCapture(capture), { + // Then saves, capture results, and chart ownership match the observed lifecycle state. + assert.deepEqual(lifecycleResult1, { matched: true, status: 'save-chart-replaced', }); assert.deepEqual(foreignSaves, []); assert.deepEqual(saved.map((entry) => entry.args), [['pending-round']]); assert.notEqual(api.saveChart, sessionSaveChart); - controller.endRound(round); api.saveChart = sessionSaveChart; controller.stop(); }); -test('continuous submit final replay preserves a wrapper installed after capture', async () => { +test('user replays the final round snapshot through the latest chart save owner', async () => { + // Given a native chart API, an active close round, and order-line capture state. const { api, saved, setDrawingToolName } = createTradingViewApi(); const timers = createManualTimers(); const controller = createTradingViewContinuousSaveController(api, { @@ -842,12 +1011,13 @@ test('continuous submit final replay preserves a wrapper installed after capture const round = controller.beginRound(); const capture = controller.beginSubmitCapture(round); setDrawingToolName('order-1', 'LineToolOrder'); + + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'properties_changed'); api.saveChart('pending-round'); const completion = controller.completeSubmitCapture(capture); timers.advance(20); await completion; - const sessionSaveChart = api.saveChart; const foreignSaves = []; api.saveChart = function foreignSaveChart(...args) { @@ -856,13 +1026,15 @@ test('continuous submit final replay preserves a wrapper installed after capture }; controller.endRound(round); + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.deepEqual(foreignSaves.map((entry) => entry.args), [['pending-round']]); assert.deepEqual(saved.map((entry) => entry.args), [['pending-round']]); api.saveChart = sessionSaveChart; controller.stop(); }); -test('continuous submit final replay restores saveChart when the original save throws', async () => { +test('user regains chart saving after the final round snapshot fails', async () => { + // Given a native chart API, an active close round, and order-line capture state. const { api, setDrawingToolName } = createTradingViewApi(); const originalSaveChart = function saveChart() { throw new Error('round save failed'); @@ -878,228 +1050,578 @@ test('continuous submit final replay restores saveChart when the original save t const round = controller.beginRound(); const capture = controller.beginSubmitCapture(round); setDrawingToolName('order-1', 'LineToolOrder'); + + // When native drawing events and their save requests are delivered. api.emit('drawing_event', 'order-1', 'properties_changed'); api.saveChart('pending-round'); const completion = controller.completeSubmitCapture(capture); timers.advance(20); await completion; + // Then saves, capture results, and chart ownership match the observed lifecycle state. assert.throws(() => controller.endRound(round), /round save failed/); assert.equal(api.saveChart, originalSaveChart); controller.stop(); }); for (const drawingCount of [1, 5, 70, 120, 199, 200]) { - test(`coalesces ${drawingCount} drawing saves into one final full save`, async () => { + test(`user persists one final chart snapshot for ${drawingCount} drawing removals`, async () => { + // Given the chart API, original save method, and a manual timer scheduler. const { api, saved, listeners } = createTradingViewApi(); const originalSaveChart = api.saveChart; - - const result = await coalesceTradingViewDrawingSaves( - api, - () => { - for (let index = 0; index < drawingCount; index += 1) { - api.emit('drawing_event', `order-${index}`, 'remove'); - } - setTimeout(() => { - for (let index = 0; index < drawingCount; index += 1) { - api.saveChart(`snapshot-${index}`); - } - }, 0); - return 'hidden'; - }, - { settleQuietMs: 1, timeoutMs: 100 }, - ); - - assert.deepEqual(result, { - actionResult: 'hidden', - drawingEventCount: drawingCount, - saveRequestCount: drawingCount, - fullSaveCount: 1, - }); + const timers = createManualTimers(); + + // When a native drawing action emits removals and matching saves arrive after the action. + const completion = coalesceTradingViewDrawingSaves(api, () => { + for (let index = 0; index < drawingCount; index += 1) api.emit('drawing_event', 'order-' + index, 'remove'); + return 'hidden'; + }, { settleQuietMs: 1, timeoutMs: 100, ...timers }); + await Promise.resolve(); + for (let index = 0; index < drawingCount; index += 1) api.saveChart('snapshot-' + index); + timers.advance(1); + const result = await completion; + + // Then exactly the last cumulative save is persisted and chart ownership is released. + assert.deepEqual(result, { actionResult: 'hidden', drawingEventCount: drawingCount, saveRequestCount: drawingCount, fullSaveCount: 1 }); assert.equal(saved.length, 1); assert.equal(saved[0].thisValue, api); - assert.deepEqual(saved[0].args, [`snapshot-${drawingCount - 1}`]); + assert.deepEqual(saved[0].args, ['snapshot-' + (drawingCount - 1)]); assert.equal(api.saveChart, originalSaveChart); assert.equal(listeners.get('drawing_event')?.size, 0); + assert.equal(timers.timers.size, 0); }); } -test('persists the cumulative final snapshot', async () => { +test('user persists the cumulative final drawing snapshot', async () => { + // Given three cumulative native chart snapshots and a manual clock. const { api, saved } = createTradingViewApi(); - const snapshots = [ - { drawings: ['order-1'] }, - { drawings: ['order-1', 'order-2'] }, - { drawings: ['order-1', 'order-2', 'order-3'] }, - ]; - - await coalesceTradingViewDrawingSaves(api, () => { - for (const drawingId of snapshots.at(-1).drawings) { - api.emit('drawing_event', drawingId, 'properties_changed'); - } - setTimeout(() => { - for (const snapshot of snapshots) api.saveChart(snapshot); - }, 0); - }, { settleQuietMs: 1, timeoutMs: 100 }); + const timers = createManualTimers(); + const snapshots = [{ drawings: ['order-1'] }, { drawings: ['order-1', 'order-2'] }, { drawings: ['order-1', 'order-2', 'order-3'] }]; + + // When the drawing action completes and all three save requests settle. + const completion = coalesceTradingViewDrawingSaves(api, () => { + for (const drawingId of snapshots.at(-1).drawings) api.emit('drawing_event', drawingId, 'properties_changed'); + }, { settleQuietMs: 1, timeoutMs: 100, ...timers }); + await Promise.resolve(); + for (const snapshot of snapshots) api.saveChart(snapshot); + timers.advance(1); + await completion; + // Then persisted drawings equal the final cumulative snapshot. assert.deepEqual(saved.map((entry) => entry.args[0]), [snapshots.at(-1)]); }); -test('waits for drawing events that arrive after the checkbox state changes', async () => { +test('user waits for drawing changes that follow the checkbox update', async () => { + // Given an updated checkbox whose broker drawings have not arrived yet. const { api, saved } = createTradingViewApi(); + const timers = createManualTimers(); - const result = await coalesceTradingViewDrawingSaves( - api, - () => { - setTimeout(() => { - api.emit('drawing_event', 'order-1', 'remove'); - api.emit('drawing_event', 'order-2', 'remove'); - setTimeout(() => { - api.saveChart('snapshot-1'); - api.saveChart('snapshot-2'); - }, 5); - }, 10); - return 'checkbox-changed'; - }, - { eventDiscoveryTimeoutMs: 50, settleQuietMs: 1, timeoutMs: 100 }, - ); - - assert.deepEqual(result, { - actionResult: 'checkbox-changed', - drawingEventCount: 2, - saveRequestCount: 2, - fullSaveCount: 1, + // When drawing changes arrive at 10 ms and their saves arrive another 5 ms later. + const completion = coalesceTradingViewDrawingSaves(api, () => 'checkbox-changed', { + eventDiscoveryTimeoutMs: 50, settleQuietMs: 1, timeoutMs: 100, ...timers, }); + await Promise.resolve(); + timers.advance(10); + api.emit('drawing_event', 'order-1', 'remove'); + api.emit('drawing_event', 'order-2', 'remove'); + timers.advance(5); + api.saveChart('snapshot-1'); + api.saveChart('snapshot-2'); + timers.advance(1); + const result = await completion; + + // Then both delayed changes contribute to one final snapshot. + assert.deepEqual(result, { actionResult: 'checkbox-changed', drawingEventCount: 2, saveRequestCount: 2, fullSaveCount: 1 }); assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-2']]); + assert.equal(timers.timers.size, 0); }); -test('waits for interleaved drawing saves split across macrotasks', async () => { +test('user waits for a full quiet window after interleaved drawing changes and saves', async () => { + // Given a native checkbox action and a 10 ms quiet window on a manual clock. const { api, saved } = createTradingViewApi(); + const timers = createManualTimers(); - const result = await coalesceTradingViewDrawingSaves( - api, - () => { - for (let index = 0; index < 3; index += 1) { - setTimeout(() => { - api.emit('drawing_event', `order-${index}`, 'remove'); - api.saveChart(`snapshot-${index}`); - }, index * 5); - } - return 'checkbox-changed'; - }, - { - eventDiscoveryTimeoutMs: 20, - settleQuietMs: 10, - timeoutMs: 100, - }, - ); - - assert.deepEqual(result, { - actionResult: 'checkbox-changed', - drawingEventCount: 3, - saveRequestCount: 3, - fullSaveCount: 1, + // When three drawing/save pairs arrive in separate ticks five milliseconds apart. + const completion = coalesceTradingViewDrawingSaves(api, () => 'checkbox-changed', { + eventDiscoveryTimeoutMs: 20, settleQuietMs: 10, timeoutMs: 100, ...timers, }); + await Promise.resolve(); + for (let index = 0; index < 3; index += 1) { + if (index > 0) timers.advance(5); + api.emit('drawing_event', 'order-' + index, 'remove'); + api.saveChart('snapshot-' + index); + } + timers.advance(9); + + // Then no snapshot is persisted one millisecond before the final quiet window closes. + assert.deepEqual(saved, []); + + // When the complete quiet window elapses. + timers.advance(1); + const result = await completion; + + // Then all three drawing changes settle into the final cumulative snapshot. + assert.deepEqual(result, { actionResult: 'checkbox-changed', drawingEventCount: 3, saveRequestCount: 3, fullSaveCount: 1 }); assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-2']]); }); -test('ignores drawing events that do not schedule chart saves', async () => { +test('user does not wait for save requests from click or move drawing events', async () => { + // Given a manual discovery clock and drawing interactions that do not change chart data. const { api, saved } = createTradingViewApi(); + const timers = createManualTimers(); - const result = await coalesceTradingViewDrawingSaves( - api, - () => { - api.emit('drawing_event', 'order-1', 'click'); - api.emit('drawing_event', 'order-1', 'move'); - return 'unchanged'; - }, - { eventDiscoveryTimeoutMs: 5 }, - ); + // When the action emits only click and move events and discovery expires. + const completion = coalesceTradingViewDrawingSaves(api, () => { + api.emit('drawing_event', 'order-1', 'click'); + api.emit('drawing_event', 'order-1', 'move'); + return 'unchanged'; + }, { eventDiscoveryTimeoutMs: 5, ...timers }); + await Promise.resolve(); + timers.advance(5); + const result = await completion; - assert.deepEqual(result, { - actionResult: 'unchanged', - drawingEventCount: 0, - saveRequestCount: 0, - fullSaveCount: 0, - }); + // Then the unchanged result contains no drawing mutations or full saves. + assert.deepEqual(result, { actionResult: 'unchanged', drawingEventCount: 0, saveRequestCount: 0, fullSaveCount: 0 }); assert.deepEqual(saved, []); + assert.equal(timers.timers.size, 0); }); -test('skips discovery when the caller proves that no drawings can exist', async () => { +test('user skips event discovery when the chart is definitively empty', async () => { + // Given an empty-chart action and a timer adapter that rejects unexpected scheduling. const { api, saved } = createTradingViewApi(); let timerCalls = 0; + const options = { + eventDiscoveryTimeoutMs: 0, + setTimeoutFn() { timerCalls += 1; throw new Error('drawing discovery timer must not start'); }, + }; - const result = await coalesceTradingViewDrawingSaves( - api, - () => 'definitively-empty', - { - eventDiscoveryTimeoutMs: 0, - setTimeoutFn() { - timerCalls += 1; - throw new Error('drawing discovery timer must not start'); - }, - }, - ); + // When the empty action is coalesced with discovery explicitly disabled. + const result = await coalesceTradingViewDrawingSaves(api, () => 'definitively-empty', options); - assert.deepEqual(result, { - actionResult: 'definitively-empty', - drawingEventCount: 0, - saveRequestCount: 0, - fullSaveCount: 0, - }); + // Then the action completes without a timer or a chart write. + assert.deepEqual(result, { actionResult: 'definitively-empty', drawingEventCount: 0, saveRequestCount: 0, fullSaveCount: 0 }); assert.equal(timerCalls, 0); assert.deepEqual(saved, []); }); -test('restores saveChart and its subscription after action failure', async () => { +test('user regains ordinary chart saving after the drawing action fails', async () => { + // Given an action that emits a drawing event and then fails while toggling chart state. const { api, saved, listeners } = createTradingViewApi(); const originalSaveChart = api.saveChart; + const failure = new Error('chart toggle failed'); - await assert.rejects( - coalesceTradingViewDrawingSaves(api, () => { - api.emit('drawing_event', 'order-1', 'remove'); - throw new Error('chart toggle failed'); - }), - /chart toggle failed/, - ); + // When the chart action throws before its expected save request arrives. + const completion = coalesceTradingViewDrawingSaves(api, () => { + api.emit('drawing_event', 'order-1', 'remove'); + throw failure; + }); + // Then the action error is preserved and the original chart subscription and method are restored. + await assert.rejects(completion, /chart toggle failed/); assert.equal(api.saveChart, originalSaveChart); assert.equal(listeners.get('drawing_event')?.size, 0); + + // When a later unrelated chart save is requested. api.saveChart('after-error'); + + // Then it is persisted synchronously through the original method. assert.deepEqual(saved.map((entry) => entry.args), [['after-error']]); }); -test('times out when drawing events do not produce matching save requests', async () => { - const { api, saved } = createTradingViewApi(); +test('user gets an exact missing-save count at the drawing-save deadline', async () => { + // Given one drawing removal whose matching save request never arrives. + const { api, saved, listeners } = createTradingViewApi(); const originalSaveChart = api.saveChart; + const timers = createManualTimers(); - await assert.rejects( - coalesceTradingViewDrawingSaves( - api, - () => api.emit('drawing_event', 'order-1', 'remove'), - { timeoutMs: 10 }, - ), - /图表保存请求数量不一致:预期 1,实际 0/, - ); + // When the action completes and its 10 ms response deadline expires. + const completion = coalesceTradingViewDrawingSaves(api, () => api.emit('drawing_event', 'order-1', 'remove'), { timeoutMs: 10, ...timers }); + await Promise.resolve(); + timers.advance(10); + // Then the exact count mismatch is reported and timer, method, and listener ownership are restored. + await assert.rejects(completion, /图表保存请求数量不一致:预期 1,实际 0/); assert.equal(api.saveChart, originalSaveChart); + assert.equal(listeners.get('drawing_event')?.size, 0); + assert.equal(timers.timers.size, 0); + + // When the user later requests an unrelated save. api.saveChart('after-timeout'); + + // Then the ordinary save remains functional after the failed observation. assert.deepEqual(saved.map((entry) => entry.args), [['after-timeout']]); }); -test('restores the original method when the final full save throws', async () => { - const { api } = createTradingViewApi(); - const originalSaveChart = function saveChart() { - throw new Error('save failed'); - }; +test('user regains the original chart method when the final coalesced save fails', async () => { + // Given a chart whose original full-save method throws. + const { api, listeners } = createTradingViewApi(); + const originalSaveChart = function saveChart() { throw new Error('save failed'); }; api.saveChart = originalSaveChart; + const timers = createManualTimers(); - await assert.rejects( - coalesceTradingViewDrawingSaves(api, () => { - api.emit('drawing_event', 'order-1', 'properties_changed'); - setTimeout(() => api.saveChart('snapshot'), 0); - }, { settleQuietMs: 1, timeoutMs: 100 }), - /save failed/, - ); + // When one drawing event and save request settle for final persistence. + const completion = coalesceTradingViewDrawingSaves(api, () => api.emit('drawing_event', 'order-1', 'properties_changed'), { + settleQuietMs: 1, timeoutMs: 100, ...timers, + }); + await Promise.resolve(); + api.saveChart('snapshot'); + timers.advance(1); + + // Then the persistence failure does not leave the temporary save wrapper or listener installed. + await assert.rejects(completion, /save failed/); + assert.equal(api.saveChart, originalSaveChart); + assert.equal(listeners.get('drawing_event')?.size, 0); + assert.equal(timers.timers.size, 0); +}); + +const chartSaveFactories = [ + { name: 'continuous closing', create: (api, options) => createTradingViewContinuousSaveController(api, options) }, + { name: 'bulk removal', create: (api, options) => createTradingViewRemovalSaveController(api, options) }, + { name: 'a single drawing action', create: (api, options) => coalesceTradingViewDrawingSaves(api, () => {}, options) }, +]; + +for (const { name, create } of chartSaveFactories) { + for (const { missing, change, expectedError } of [ + { missing: 'chart API', change: () => null, expectedError: /图表接口不可用/ }, + { missing: 'save method', change: (api) => ({ ...api, saveChart: null }), expectedError: /图表保存接口不可用/ }, + { missing: 'event subscription', change: (api) => ({ ...api, subscribe: null }), expectedError: /图表事件接口不可用/ }, + { missing: 'event unsubscription', change: (api) => ({ ...api, unsubscribe: null }), expectedError: /图表事件接口不可用/ }, + ]) { + test(`user cannot start ${name} chart saving without the ${missing}`, async () => { + // Given a chart host missing one required capability. + const { api, listeners, saved } = createTradingViewApi(); + const unavailable = change(api); + + // When the chart-save lifecycle is requested from that host. + const start = async () => create(unavailable); + + // Then the missing capability is reported before subscriptions or chart writes begin. + await assert.rejects(start, expectedError); + assert.equal(listeners.size, 0); + assert.deepEqual(saved, []); + }); + } +} + +for (const { name, create, options, expectedError } of [ + { name: 'continuous-save quiet window', create: createTradingViewContinuousSaveController, options: { settleQuietMs: 0 }, expectedError: /图表保存合并静默时间无效/ }, + { name: 'continuous-save maximum wait', create: createTradingViewContinuousSaveController, options: { maxWaitMs: 119 }, expectedError: /图表保存合并最长等待时间无效/ }, + { name: 'order-line discovery wait', create: createTradingViewContinuousSaveController, options: { submitEventDiscoveryMs: -1 }, expectedError: /订单线事件等待时间无效/ }, + { name: 'order-line type adapter', create: createTradingViewContinuousSaveController, options: { getDrawingToolName: null }, expectedError: /订单线类型解析依赖异常/ }, + { name: 'removal quiet window', create: createTradingViewRemovalSaveController, options: { settleQuietMs: 0 }, expectedError: /删除事件保存合并静默时间无效/ }, + { name: 'removal maximum wait', create: createTradingViewRemovalSaveController, options: { maxWaitMs: 139 }, expectedError: /删除事件保存合并最长等待时间无效/ }, + { name: 'removal discovery wait', create: createTradingViewRemovalSaveController, options: { eventDiscoveryMs: -1 }, expectedError: /删除事件发现时间无效/ }, + { name: 'drawing action', create: (api) => coalesceTradingViewDrawingSaves(api, null), options: {}, expectedError: /图表操作不可用/ }, + { name: 'drawing discovery wait', create: (api, config) => coalesceTradingViewDrawingSaves(api, () => {}, config), options: { eventDiscoveryTimeoutMs: -1 }, expectedError: /图表事件等待时间无效/ }, + { name: 'drawing settle window', create: (api, config) => coalesceTradingViewDrawingSaves(api, () => {}, config), options: { settleQuietMs: 0 }, expectedError: /图表保存稳定等待时间无效/ }, + { name: 'drawing response deadline', create: (api, config) => coalesceTradingViewDrawingSaves(api, () => {}, config), options: { timeoutMs: 0 }, expectedError: /图表保存超时时间无效/ }, +]) { + test(`user gets an explicit error for an invalid ${name}`, async () => { + // Given a valid chart host and one invalid lifecycle option. + const { api, listeners, saved } = createTradingViewApi(); + const originalSaveChart = api.saveChart; + + // When the chart-save lifecycle validates the option. + const start = async () => create(api, options); + + // Then validation fails before taking over the chart or subscribing to events. + await assert.rejects(start, expectedError); + assert.equal(api.saveChart, originalSaveChart); + assert.equal(listeners.size, 0); + assert.deepEqual(saved, []); + }); +} + +for (const { name, create } of chartSaveFactories.slice(1)) { + test(`user keeps the original chart API if ${name} cannot install a save wrapper`, async () => { + // Given a chart host whose save accessor refuses replacement. + const { api, listeners } = createTradingViewApi(); + const originalSaveChart = api.saveChart; + Object.defineProperty(api, 'saveChart', { configurable: true, get: () => originalSaveChart, set() {} }); + + // When the lifecycle tries to take ownership of chart saving. + const start = async () => create(api); + + // Then wrapper installation fails explicitly and its event listener is removed. + await assert.rejects(start, /图表保存接口无法/); + assert.equal(api.saveChart, originalSaveChart); + assert.equal(listeners.get('drawing_event').size, 0); + }); +} + +test('user gets a drawing-save error if the continuous chart host refuses the burst wrapper', () => { + // Given a continuous controller on a chart whose save accessor refuses assignment. + const { api, listeners, saved } = createTradingViewApi(); + const originalSaveChart = api.saveChart; + const timers = createManualTimers(); + Object.defineProperty(api, 'saveChart', { configurable: true, get: () => originalSaveChart, set() {} }); + const controller = createTradingViewContinuousSaveController(api, timers); + + // When a removal tries to start a temporary save burst. + const remove = () => api.emit('drawing_event', 'order-1', 'remove'); + + // Then the explicit ownership error leaves the original method and no burst timer. + assert.throws(remove, /图表保存接口无法启用删除事件合并/); + assert.equal(api.saveChart, originalSaveChart); + assert.equal(timers.timers.size, 0); + assert.deepEqual(saved, []); + controller.stop(); + assert.equal(listeners.get('drawing_event').size, 0); +}); + +test('user restores an inherited chart save method after a coalesced action', async () => { + // Given a chart host inheriting its save method rather than owning that property. + const { api, saved, listeners } = createTradingViewApi(); + const originalSaveChart = api.saveChart; + const timers = createManualTimers(); + delete api.saveChart; + Object.setPrototypeOf(api, { saveChart: originalSaveChart }); + + // When one drawing event and save settle through the temporary wrapper. + const completion = coalesceTradingViewDrawingSaves(api, () => api.emit('drawing_event', 'order-1', 'remove'), { + settleQuietMs: 1, timeoutMs: 100, ...timers, + }); + await Promise.resolve(); + api.saveChart('inherited-snapshot'); + timers.advance(1); + const result = await completion; + // Then the final snapshot uses the original receiver and the temporary own property is removed. + assert.equal(result.fullSaveCount, 1); + assert.equal(Object.hasOwn(api, 'saveChart'), false); assert.equal(api.saveChart, originalSaveChart); + assert.equal(saved[0].thisValue, api); + assert.deepEqual(saved.map((entry) => entry.args), [['inherited-snapshot']]); + assert.equal(listeners.get('drawing_event').size, 0); +}); + +test('user preserves a foreign chart owner when a drawing action replaces the save method', async () => { + // Given a native chart action that hands saving to another operation. + const { api, listeners, saved } = createTradingViewApi(); + const foreignSaves = []; + const foreignSave = (...args) => foreignSaves.push(args); + + // When the action changes save ownership before coalescer cleanup. + const completion = coalesceTradingViewDrawingSaves(api, () => { api.saveChart = foreignSave; }, { eventDiscoveryTimeoutMs: 0 }); + + // Then cleanup reports the ownership change without overwriting the new owner. + await assert.rejects(completion, /图表保存接口在操作期间发生变化/); + assert.equal(api.saveChart, foreignSave); + assert.equal(listeners.get('drawing_event').size, 0); + assert.deepEqual(saved, []); + assert.deepEqual(foreignSaves, []); +}); + +test('user stops waiting at the chart-save deadline even if every drawing has requested a save', async () => { + // Given one matching drawing/save pair whose quiet window exceeds the response deadline. + const { api, saved, listeners } = createTradingViewApi(); + const originalSaveChart = api.saveChart; + const timers = createManualTimers(); + + // When the 10 ms deadline expires before the 20 ms quiet window. + const completion = coalesceTradingViewDrawingSaves(api, () => { + api.emit('drawing_event', 'order-1', 'remove'); + api.saveChart('still-changing'); + }, { settleQuietMs: 20, timeoutMs: 10, ...timers }); + await Promise.resolve(); + timers.advance(10); + + // Then a settle timeout is reported distinctly from a missing-save count and no stale save is replayed. + await assert.rejects(completion, /图表保存未在 10 毫秒内完成/); + assert.deepEqual(saved, []); + assert.equal(api.saveChart, originalSaveChart); + assert.equal(listeners.get('drawing_event').size, 0); + assert.equal(timers.timers.size, 0); +}); + +test('user can stop an unmatched capture and cannot reuse its closed round lifecycle', async () => { + // Given an active close round waiting to discover an order line. + const { api, saved, listeners } = createTradingViewApi(); + const timers = createManualTimers(); + const controller = createTradingViewContinuousSaveController(api, timers); + const round = controller.beginRound(); + const capture = controller.beginSubmitCapture(round); + + // When conflicting round and capture operations are attempted during that active capture. + const duplicateRound = () => controller.beginRound(); + const duplicateCapture = () => controller.beginSubmitCapture(round); + const wrongCaptureRound = () => controller.beginSubmitCapture({}); + const wrongEndRound = () => controller.endRound({}); + const earlyEnd = () => controller.endRound(round); + const unknownCapture = controller.completeSubmitCapture({}); + + // Then overlapping or mismatched lifecycle operations fail without altering the live capture. + assert.throws(duplicateRound, /已有图表保存轮次正在执行/); + assert.throws(duplicateCapture, /已有订单线保存捕获正在执行/); + assert.throws(wrongCaptureRound, /图表保存轮次不匹配/); + assert.throws(wrongEndRound, /结束的图表保存轮次不匹配/); + assert.throws(earlyEnd, /结束图表保存轮次时仍有订单线捕获/); + await assert.rejects(unknownCapture, /订单线保存捕获不匹配/); + + // When the user stops the controller before any order-line event arrives. + const stats = controller.stop(); + const result = await controller.completeSubmitCapture(capture); + + // Then capture discovery ends immediately, resources are released, and the stopped lifecycle rejects reuse. + assert.deepEqual(result, { matched: false, status: 'stopped' }); + assert.deepEqual(stats, expectedContinuousStats()); + assert.equal(timers.timers.size, 0); + assert.equal(listeners.get('drawing_event').size, 0); + assert.deepEqual(saved, []); + assert.throws(() => controller.beginRound(), /连续图表保存控制器已停止/); + assert.throws(() => controller.beginSubmitCapture(round), /连续图表保存控制器已停止/); + assert.throws(() => controller.stop(), /连续图表保存控制器已停止/); +}); + +test('user can stop a matched capture before its delayed native save arrives', async () => { + // Given an armed capture and a matching order line with no save request yet. + const { api, saved, setDrawingToolName, listeners } = createTradingViewApi(); + const originalSaveChart = api.saveChart; + const timers = createManualTimers(); + const controller = createTradingViewContinuousSaveController(api, timers); + const round = controller.beginRound(); + const capture = controller.beginSubmitCapture(round); + setDrawingToolName('order-1', 'LineToolOrder'); + + // When the order-line event arrives and the user stops before the save callback. + api.emit('drawing_event', 'order-1', 'properties_changed'); + const stats = controller.stop(); + const result = await controller.completeSubmitCapture(capture); + + // Then the matched capture closes without inventing a save or leaving a wrapper and timer behind. + assert.deepEqual(result, { matched: true, status: 'captured' }); + assert.deepEqual(stats, expectedContinuousStats({ orderEventCount: 1 })); + assert.equal(api.saveChart, originalSaveChart); + assert.equal(listeners.get('drawing_event').size, 0); + assert.equal(timers.timers.size, 0); + assert.deepEqual(saved, []); + + // When the delayed native save finally arrives after stopping. + api.saveChart('late-order-snapshot'); + + // Then the original synchronous method persists it normally. + assert.deepEqual(saved.map((entry) => entry.args), [['late-order-snapshot']]); +}); + +test('user leaves unrelated saves synchronous when an order drawing disappears before inspection', async () => { + // Given an armed capture whose chart no longer has the referenced drawing. + const { api, saved } = createTradingViewApi(); + const timers = createManualTimers(); + const controller = createTradingViewContinuousSaveController(api, { submitEventDiscoveryMs: 10, ...timers }); + const round = controller.beginRound(); + const capture = controller.beginSubmitCapture(round); + + // When the stale drawing event cannot be inspected and discovery reaches its deadline. + api.emit('drawing_event', 'already-removed-order', 'properties_changed'); + api.saveChart('unrelated-current-snapshot'); + timers.advance(10); + const result = await controller.completeSubmitCapture(capture); + + // Then no order line is claimed and the unrelated current snapshot is saved normally. + assert.deepEqual(result, { matched: false, status: 'no-order-event' }); + assert.deepEqual(saved.map((entry) => entry.args), [['unrelated-current-snapshot']]); + controller.endRound(round); + assert.deepEqual(controller.stop(), expectedContinuousStats()); + assert.equal(timers.timers.size, 0); +}); + +test('user receives a deferred round-save failure after stop still releases all chart resources', async () => { + // Given a settled order capture whose original chart save fails on final persistence. + const { api, listeners, setDrawingToolName } = createTradingViewApi(); + const failure = new Error('Deferred round save failed'); + const originalSaveChart = () => { throw failure; }; + api.saveChart = originalSaveChart; + const timers = createManualTimers(); + const controller = createTradingViewContinuousSaveController(api, { settleQuietMs: 20, ...timers }); + const round = controller.beginRound(); + const capture = controller.beginSubmitCapture(round); + setDrawingToolName('order-1', 'LineToolOrder'); + api.emit('drawing_event', 'order-1', 'properties_changed'); + api.saveChart('pending-round'); + timers.advance(20); + const captured = await controller.completeSubmitCapture(capture); + + // When the user stops and the deferred final snapshot fails to persist. + const stop = () => controller.stop(); + + // Then the persistence error survives while capture, event, method, and timer resources are closed. + assert.deepEqual(captured, { matched: true, status: 'captured' }); + assert.throws(stop, (error) => error === failure); + assert.equal(api.saveChart, originalSaveChart); + assert.equal(listeners.get('drawing_event').size, 0); + assert.equal(timers.timers.size, 0); + assert.throws(() => controller.beginRound(), /连续图表保存控制器已停止/); +}); + +test('user finishes an empty removal lifecycle at its discovery deadline', async () => { + // Given an empty chart and a manual 10 ms removal discovery window. + const { api, saved, listeners } = createTradingViewApi(); + const originalSaveChart = api.saveChart; + const timers = createManualTimers(); + const controller = createTradingViewRemovalSaveController(api, { eventDiscoveryMs: 10, ...timers }); + let finished = false; + + // When finish waits through the first nine milliseconds without a removal. + const completion = controller.finish().then((result) => { finished = true; return result; }); + timers.advance(9); + + // Then discovery remains pending and no snapshot is invented. + assert.equal(finished, false); + assert.deepEqual(saved, []); + + // When the final discovery millisecond elapses. + timers.advance(1); + const result = await completion; + + // Then the empty lifecycle releases ownership and refuses a second finish. + assert.deepEqual(result, { fullSaveCount: 0, removeEventCount: 0, saveRequestCount: 0, synchronousSaveCount: 0 }); + assert.equal(finished, true); + assert.equal(api.saveChart, originalSaveChart); + assert.equal(listeners.get('drawing_event').size, 0); + assert.equal(timers.timers.size, 0); + await assert.rejects(controller.finish(), /删除事件保存合并已结束/); +}); + +test('user preserves a foreign save owner installed before the first removal event', async () => { + // Given a removal lifecycle whose monitored save method is replaced by another chart operation. + const { api, saved, listeners } = createTradingViewApi(); + const timers = createManualTimers(); + const controller = createTradingViewRemovalSaveController(api, { eventDiscoveryMs: 0, ...timers }); + const foreignSaves = []; + const foreignSave = (...args) => foreignSaves.push(args); + api.saveChart = foreignSave; + + // When the first removal arrives under foreign ownership and the lifecycle finishes. + api.emit('drawing_event', 'order-1', 'remove'); + api.saveChart('foreign-snapshot'); + const completion = controller.finish(); + + // Then ownership conflict prevents stale replay and leaves the foreign save method intact. + await assert.rejects(completion, /图表保存接口正被其他操作占用/); + assert.equal(api.saveChart, foreignSave); + assert.deepEqual(saved, []); + assert.deepEqual(foreignSaves, [['foreign-snapshot']]); + assert.equal(listeners.get('drawing_event').size, 0); + assert.equal(timers.timers.size, 0); +}); + +test('user restores removal monitoring if a host makes the save method read-only between bursts', async () => { + // Given an installed removal monitor whose host changes the save property to read-only. + const { api, saved, listeners } = createTradingViewApi(); + const originalSaveChart = api.saveChart; + const timers = createManualTimers(); + const controller = createTradingViewRemovalSaveController(api, { eventDiscoveryMs: 0, ...timers }); + Object.defineProperty(api, 'saveChart', { writable: false }); + + // When a removal cannot install its temporary burst wrapper. + api.emit('drawing_event', 'order-1', 'remove'); + const completion = controller.finish(); + + // Then the assignment error is reported after the original property and listener are restored. + await assert.rejects(completion, TypeError); + assert.equal(api.saveChart, originalSaveChart); + assert.equal(Object.getOwnPropertyDescriptor(api, 'saveChart').writable, true); + assert.equal(listeners.get('drawing_event').size, 0); + assert.equal(timers.timers.size, 0); + assert.deepEqual(saved, []); }); diff --git a/test/unit/binance-orderbook-trade/close-action.test.js b/test/unit/binance-orderbook-trade/close-action.test.js index 8564e2c..2282476 100644 --- a/test/unit/binance-orderbook-trade/close-action.test.js +++ b/test/unit/binance-orderbook-trade/close-action.test.js @@ -7,97 +7,116 @@ import { shouldDisableCloseControl, } from '../../../src/binance-orderbook-trade/core/close-action.js'; -test('close direction requires both position sides to be freshly known', () => { - assert.equal(resolveConfirmedCloseDirection({ - knowsLong: true, - knowsShort: false, - hasLong: true, - hasShort: false, - }, 'LONG'), null); - assert.equal(resolveConfirmedCloseDirection({ - knowsLong: false, - knowsShort: true, - hasLong: false, - hasShort: true, - }, 'SHORT'), null); -}); +for (const { name, context, selectedSide } of [ + { name: 'the short side is still unread', context: { knowsLong: true, knowsShort: false, hasLong: true, hasShort: false }, selectedSide: 'LONG' }, + { name: 'the long side is still unread', context: { knowsLong: false, knowsShort: true, hasLong: false, hasShort: true }, selectedSide: 'SHORT' }, + { name: 'the close context has not arrived', context: null, selectedSide: 'SHORT' }, +]) { + test(`user cannot choose a close direction while ${name}`, () => { + // Given a position snapshot that does not confirm both sides. + const selection = selectedSide; -test('close direction follows confirmed positions and the explicit dual-side selection', () => { - assert.equal(resolveConfirmedCloseDirection({ - knowsLong: true, - knowsShort: true, - hasLong: true, - hasShort: false, - }, 'SHORT'), 'LONG'); - assert.equal(resolveConfirmedCloseDirection({ - knowsLong: true, - knowsShort: true, - hasLong: false, - hasShort: true, - }, 'LONG'), 'SHORT'); - assert.equal(resolveConfirmedCloseDirection({ - knowsLong: true, - knowsShort: true, - hasLong: true, - hasShort: true, - }, 'SHORT'), 'SHORT'); - assert.equal(resolveConfirmedCloseDirection({ - knowsLong: true, - knowsShort: true, - hasLong: false, - hasShort: false, - }, 'LONG'), null); -}); + // When the close action resolves its target direction. + const direction = resolveConfirmedCloseDirection(context, selection); -test('pending close transition keeps the previous close snapshot and rejects stale open quantities', () => { - assert.deepEqual(resolveCloseDisplayQuantities({ - rawLongQty: 4.07, - rawShortQty: 4.06, - cachedLongQty: 0.42, - cachedShortQty: 0, - transitionPending: true, - }), { - longQty: 0.42, - shortQty: 0, - isUsingCache: true, - shouldCommit: false, + // Then no direction is authorized from incomplete position evidence. + assert.equal(direction, null); }); -}); +} -test('first confirmed close snapshot accepts a legitimate zero immediately', () => { - assert.deepEqual(resolveCloseDisplayQuantities({ - rawLongQty: 0.42, - rawShortQty: 0, - cachedLongQty: 0.42, - cachedShortQty: 4.06, - transitionPending: false, - }), { - longQty: 0.42, - shortQty: 0, - isUsingCache: false, - shouldCommit: true, +for (const { name, hasLong, hasShort, selectedSide, expected } of [ + { name: 'only the long position exists', hasLong: true, hasShort: false, selectedSide: 'SHORT', expected: 'LONG' }, + { name: 'only the short position exists', hasLong: false, hasShort: true, selectedSide: 'LONG', expected: 'SHORT' }, + { name: 'both sides exist and short is selected', hasLong: true, hasShort: true, selectedSide: 'SHORT', expected: 'SHORT' }, + { name: 'both sides exist and long is selected', hasLong: true, hasShort: true, selectedSide: 'LONG', expected: 'LONG' }, + { name: 'both sides are confirmed flat', hasLong: false, hasShort: false, selectedSide: 'LONG', expected: null }, +]) { + test(`user closes the confirmed direction when ${name}`, () => { + // Given fresh knowledge of both sides and an explicit panel selection. + const context = { knowsLong: true, knowsShort: true, hasLong, hasShort }; + + // When the target direction is resolved. + const direction = resolveConfirmedCloseDirection(context, selectedSide); + + // Then the existing position wins unless both sides require a user choice. + assert.equal(direction, expected); }); -}); +} + +for (const { name, input, expected } of [ + { + name: 'keeps the last close quantities during a mode transition', + input: { rawLongQty: 4.07, rawShortQty: 4.06, cachedLongQty: 0.42, cachedShortQty: 0, transitionPending: true }, + expected: { longQty: 0.42, shortQty: 0, isUsingCache: true, shouldCommit: false }, + }, + { + name: 'sees unknown quantities during the first close-mode transition', + input: { rawLongQty: 4.07, rawShortQty: 4.06, transitionPending: true }, + expected: { longQty: null, shortQty: null, isUsingCache: false, shouldCommit: false }, + }, + { + name: 'keeps a cached short quantity while the long side is still unknown', + input: { rawLongQty: 4.07, rawShortQty: 4.06, cachedShortQty: 0.6, transitionPending: true }, + expected: { longQty: null, shortQty: 0.6, isUsingCache: true, shouldCommit: false }, + }, + { + name: 'sees a newly confirmed zero replace the old short quantity', + input: { rawLongQty: 0.42, rawShortQty: 0, cachedLongQty: 0.42, cachedShortQty: 4.06, transitionPending: false }, + expected: { longQty: 0.42, shortQty: 0, isUsingCache: false, shouldCommit: true }, + }, + { + name: 'keeps only the missing long side cached after the transition', + input: { rawLongQty: null, rawShortQty: 0, cachedLongQty: 0.42, cachedShortQty: 4.06 }, + expected: { longQty: 0.42, shortQty: 0, isUsingCache: true, shouldCommit: true }, + }, + { + name: 'keeps only the missing short side cached after the transition', + input: { rawLongQty: 0, rawShortQty: null, cachedLongQty: 0.42, cachedShortQty: 4.06 }, + expected: { longQty: 0, shortQty: 4.06, isUsingCache: true, shouldCommit: true }, + }, + { + name: 'retains the cached snapshot when both live quantities are missing', + input: { rawLongQty: null, rawShortQty: null, cachedLongQty: 0.42, cachedShortQty: 4.06 }, + expected: { longQty: 0.42, shortQty: 4.06, isUsingCache: true, shouldCommit: false }, + }, + { + name: 'keeps both sides unknown when neither live nor cached quantities exist', + input: { rawLongQty: null, rawShortQty: null }, + expected: { longQty: null, shortQty: null, isUsingCache: false, shouldCommit: false }, + }, + { + name: 'accepts a fresh short quantity without inventing a long quantity', + input: { rawLongQty: null, rawShortQty: 0.6 }, + expected: { longQty: null, shortQty: 0.6, isUsingCache: false, shouldCommit: true }, + }, +]) { + test(`user ${name}`, () => { + // Given the live and cached quantities for the current close-mode transition. + const snapshot = { ...input }; -test('close controls render from confirmed display state without a pending-only disabled flash', () => { - assert.equal(shouldDisableCloseControl({ - actionDisabled: false, - knowsPosition: true, - hasPosition: true, - }), false); - assert.equal(shouldDisableCloseControl({ - actionDisabled: false, - knowsPosition: true, - hasPosition: false, - }), true); - assert.equal(shouldDisableCloseControl({ - actionDisabled: false, - knowsPosition: false, - hasPosition: false, - }), false); - assert.equal(shouldDisableCloseControl({ - actionDisabled: true, - knowsPosition: true, - hasPosition: true, - }), true); -}); + // When the panel resolves the quantities it can safely display. + const display = resolveCloseDisplayQuantities(snapshot); + + // Then values, cache provenance, and permission to commit match that evidence. + assert.deepEqual(display, expected); + }); +} + +for (const { name, input, expected } of [ + { name: 'can close a confirmed position', input: { actionDisabled: false, knowsPosition: true, hasPosition: true }, expected: false }, + { name: 'cannot close a confirmed flat side', input: { actionDisabled: false, knowsPosition: true, hasPosition: false }, expected: true }, + { name: 'does not see a disabled flash while position knowledge is pending', input: { actionDisabled: false, knowsPosition: false, hasPosition: false }, expected: false }, + { name: 'cannot close while another action disables the control', input: { actionDisabled: true, knowsPosition: true, hasPosition: true }, expected: true }, + { name: 'can use the default enabled action for a confirmed position', input: { knowsPosition: true, hasPosition: true }, expected: false }, +]) { + test(`user ${name}`, () => { + // Given the confirmed display state and any action-level lock. + const controlState = { ...input }; + + // When close-button availability is derived. + const disabled = shouldDisableCloseControl(controlState); + + // Then only confirmed flatness or an action lock disables the button. + assert.equal(disabled, expected); + }); +} diff --git a/test/unit/binance-orderbook-trade/close-ladder-recovery.test.js b/test/unit/binance-orderbook-trade/close-ladder-recovery.test.js index 4d630ab..2350cd9 100644 --- a/test/unit/binance-orderbook-trade/close-ladder-recovery.test.js +++ b/test/unit/binance-orderbook-trade/close-ladder-recovery.test.js @@ -51,18 +51,27 @@ function scenario({ quantities, failures = [rejection()], replaceResult = { ok: return { options, events }; } -test('a confirmed flat position ends recovery without cancellation or another submit', async () => { +test('user finishes close recovery immediately when the position is confirmed flat', async () => { + // Given a reduce-only rejection followed by an authoritative zero position. const { options, events } = scenario({ quantities: ['0'] }); - assert.deepEqual(await runCloseLadderWithPositionRecovery(options), { status: 'position_closed' }); + // When the close round reads the confirmed position. + const result = await runCloseLadderWithPositionRecovery(options); + + // Then the round ends without cancellation or another submission. + assert.deepEqual(result, { status: 'position_closed' }); assert.deepEqual(events, [['build', '100'], ['execute'], ['position', '0']]); }); -test('a real position decrease rebuilds beyond the old two-attempt limit without cancelling', async () => { +test('user keeps recovering while each fresh position snapshot proves a real decrease', async () => { + // Given three rejections followed by strictly decreasing confirmed positions. const { options, events } = scenario({ quantities: ['100', '80', '80', '60', '60', '40'], failures: [rejection(), rejection(), rejection()], }); + // When the close round rebuilds from each smaller position. const result = await runCloseLadderWithPositionRecovery(options); + + // Then all three decreases authorize new plans without cancellation. assert.equal(result.done, 3); assert.deepEqual(events.filter(([type]) => type === 'build'), [ ['build', '100'], ['build', '80'], ['build', '60'], ['build', '40'], @@ -71,21 +80,31 @@ test('a real position decrease rebuilds beyond the old two-attempt limit without assert.equal(events.filter(([type]) => type === 'wait').length, 3); }); -test('unchanged position permits one confirmed scoped replacement before replanning', async () => { +test('user can replace conflicting close orders once at an unchanged position', async () => { + // Given a rejection and an unchanged position before and after the recovery wait. const { options, events } = scenario({ quantities: ['100', '100', '100'] }); - assert.equal((await runCloseLadderWithPositionRecovery(options)).done, 3); + // When the close round runs its scoped replacement workflow. + const result = await runCloseLadderWithPositionRecovery(options); + + // Then one replacement and its readiness wait precede a rebuilt plan. + assert.equal(result.done, 3); assert.deepEqual(events, [ ['build', '100'], ['execute'], ['position', '100'], ['wait'], ['position', '100'], ['replace'], ['wait'], ['position', '100'], ['build', '100'], ['execute'], ]); }); -test('rejection after replacement waits for progress and stops unchanged state without another cancellation', async () => { +test('user stops after replacement when a repeated rejection shows no position progress', async () => { + // Given a second reduce-only rejection after one replacement at the same quantity. const { options, events } = scenario({ quantities: ['100', '100', '100', '100', '100'], failures: [rejection(), rejection()], }); - await assert.rejects(runCloseLadderWithPositionRecovery(options), (error) => { + // When the close round rechecks position progress. + const completion = runCloseLadderWithPositionRecovery(options); + + // Then the native rejection is preserved without another replacement or outer retry permission. + await assert.rejects(completion, (error) => { assert.match(formatLocalizedText(error.localizedText, 'en'), /position has not decreased/i); assert.match(error.message, /90802022/); assert.equal(error.continuousRecoveryKind, undefined); @@ -95,42 +114,62 @@ test('rejection after replacement waits for progress and stops unchanged state w assert.equal(events.filter(([type]) => type === 'execute').length, 2); }); -test('actual position reduction after replacement allows further recovery', async () => { +test('user can replace close orders again only after a confirmed position decrease', async () => { + // Given a replacement followed by a real decrease and another unchanged rejection. const { options, events } = scenario({ quantities: ['100', '100', '100', '100', '80', '80', '80', '80'], failures: [rejection(), rejection(), rejection()], }); - assert.equal((await runCloseLadderWithPositionRecovery(options)).done, 3); + // When the close round continues its progress-guarded recovery. + const result = await runCloseLadderWithPositionRecovery(options); + + // Then the smaller position authorizes the second replacement and matching plan quantity. + assert.equal(result.done, 3); assert.equal(events.filter(([type]) => type === 'replace').length, 2); assert.deepEqual(events.filter(([type]) => type === 'build'), [ ['build', '100'], ['build', '100'], ['build', '80'], ['build', '80'], ]); }); -test('an increased position cannot reset the recovery guard', async () => { +test('user stops close recovery when the confirmed position increases', async () => { + // Given a reduce-only rejection followed by a position increase during the wait. const { options, events } = scenario({ quantities: ['100', '101'] }); - await assert.rejects(runCloseLadderWithPositionRecovery(options), /90802022/); + // When the close round rechecks the authoritative quantity. + const completion = runCloseLadderWithPositionRecovery(options); + + // Then the round rejects without another cancellation or submission. + await assert.rejects(completion, /90802022/); assert.equal(events.filter(([type]) => type === 'replace').length, 0); assert.equal(events.filter(([type]) => type === 'execute').length, 1); }); -test('unconfirmed cancellation stops without another position read or submission', async () => { +test('user stops close recovery when scoped cancellation is unconfirmed', async () => { + // Given an unchanged position and an unconfirmed scoped-cancellation result. const { options, events } = scenario({ quantities: ['100', '100'], replaceResult: { ok: false, status: 'row_cancel_failed', message: 'Cancellation unconfirmed' }, }); - await assert.rejects(runCloseLadderWithPositionRecovery(options), /Cancellation unconfirmed/); + // When the close round attempts its allowed replacement. + const completion = runCloseLadderWithPositionRecovery(options); + + // Then the cancellation reason ends the round before another submission. + await assert.rejects(completion, /Cancellation unconfirmed/); assert.equal(events.filter(([type]) => type === 'execute').length, 1); }); -test('position failures do not escape into the outer continuous retry policy', async () => { +test('user stops close recovery when the authoritative position cannot be read', async () => { + // Given a position adapter failure carrying an unrelated continuous retry classification. const { options, events } = scenario({ quantities: [] }); options.readPositionQty = async () => { throw Object.assign(new Error('Position unavailable'), { continuousRecoveryKind: 'position_state_not_ready', safeNoSubmit: true, }); }; - await assert.rejects(runCloseLadderWithPositionRecovery(options), (error) => { + // When the close round reads the position after a reduce-only rejection. + const completion = runCloseLadderWithPositionRecovery(options); + + // Then the error loses outer retry permission and cannot authorize another submission. + await assert.rejects(completion, (error) => { assert.match(error.message, /Position unavailable/); assert.equal(error.continuousRecoveryKind, undefined); assert.equal(error.safeNoSubmit, undefined); @@ -139,17 +178,23 @@ test('position failures do not escape into the outer continuous retry policy', a assert.equal(events.filter(([type]) => type === 'execute').length, 1); }); -test('unknown submission during recovery cannot start a new continuous round', async () => { +test('user stops after an unknown submission during reduce-only recovery', async () => { + // Given a genuine position decrease followed by an unconfirmed submission outcome. const unknown = Object.assign(new Error('Submission unconfirmed'), { continuousRecoveryKind: 'submit_unconfirmed' }); const { options } = scenario({ quantities: ['100', '80'], failures: [rejection(), unknown] }); - await assert.rejects(runCloseLadderWithPositionRecovery(options), (error) => { + // When the rebuilt plan executes under reduce-only recovery. + const completion = runCloseLadderWithPositionRecovery(options); + + // Then the uncertain outcome stays terminal for continuous mode. + await assert.rejects(completion, (error) => { assert.match(error.message, /Submission unconfirmed/); assert.equal(error.continuousRecoveryKind, undefined); return true; }); }); -test('the executor is told to disable unrelated recovery after the first reduce-only rejection', async () => { +test('user cannot trigger capacity cancellation after reduce-only recovery has started', async () => { + // Given a reduce-only conflict followed by a maximum-open-orders rejection. const { options, events } = scenario({ quantities: ['100', '80'] }); const executions = []; options.executePlan = async (_plan, { recovering }) => { @@ -159,7 +204,11 @@ test('the executor is told to disable unrelated recovery after the first reduce- safeNoSubmit: true, binanceCode: 90802025, ladderFailureKind: 'max_open_orders', }); }; - await assert.rejects(runCloseLadderWithPositionRecovery(options), (error) => { + // When the round retries the smaller position with recovery active. + const completion = runCloseLadderWithPositionRecovery(options); + + // Then the executor receives the recovery flag and no capacity replacement is authorized. + await assert.rejects(completion, (error) => { assert.match(error.message, /90802025/); assert.equal(error.continuousRecoveryKind, undefined); return true; @@ -168,33 +217,124 @@ test('the executor is told to disable unrelated recovery after the first reduce- assert.equal(events.filter(([type]) => type === 'replace').length, 0); }); -test('manual stop during cooldown prevents the next read, cancel and submit', async () => { +test('user can stop close recovery during its cooldown', async () => { + // Given a reduce-only rejection and a user stop delivered during the recovery wait. const stopped = Object.assign(new Error('Stopped'), { name: 'LadderStoppedError' }); const { options, events } = scenario({ quantities: ['100'], onWait: (signal) => signal.abort(stopped) }); - await assert.rejects(runCloseLadderWithPositionRecovery(options), (error) => error === stopped); + // When the close round observes the stop signal. + const completion = runCloseLadderWithPositionRecovery(options); + + // Then the original stop error propagates before another position read or side effect. + await assert.rejects(completion, (error) => error === stopped); assert.deepEqual(events, [['build', '100'], ['execute'], ['position', '100'], ['wait']]); }); -test('context changes after a wait are terminal before another side effect', async () => { +test('user stops close recovery when the symbol changes during the wait', async () => { + // Given a symbol change while the rejected close round waits for readiness. let changed = false; const { options, events } = scenario({ quantities: ['100'], onWait: () => { changed = true; } }); options.assertContext = () => { if (changed) throw new Error('Symbol changed'); }; - await assert.rejects(runCloseLadderWithPositionRecovery(options), /Symbol changed/); + // When the close round revalidates its captured context. + const completion = runCloseLadderWithPositionRecovery(options); + + // Then the symbol change is terminal before any replacement. + await assert.rejects(completion, /Symbol changed/); assert.equal(events.filter(([type]) => type === 'replace').length, 0); }); -test('text-only reduce-only feedback is not proof of a safe retry', async () => { +test('user cannot recover a reduce-only conflict from toast text alone', async () => { + // Given a reduce-only message without a confirmed safe native response. const error = new Error('Reduce-only rejected (90802022)'); const { options, events } = scenario({ quantities: [], failures: [error] }); - await assert.rejects(runCloseLadderWithPositionRecovery(options), (actual) => actual === error); + // When the close round handles that failure. + const completion = runCloseLadderWithPositionRecovery(options); + + // Then the same error propagates without a position read or cancellation. + await assert.rejects(completion, (actual) => actual === error); + assert.deepEqual(events, [['build', '100'], ['execute']]); +}); + +test('user caps a recovery plan by both fresh closeable quantity and the confirmed position', () => { + // Given exact DOM/API quantity pairs and two missing or malformed inputs. + const pairs = [['60', '80'], ['100', '80'], ['1.000000000000000002', '1.000000000000000001'], ['0', '80']]; + const invalidPairs = [[null, '80'], ['100', 'invalid']]; + + // When valid quantities are capped and invalid quantities are prepared for validation. + const quantities = pairs.map(([dom, api]) => capCloseLadderBaseQty(dom, api)); + const invalidActions = invalidPairs.map(([dom, api]) => () => capCloseLadderBaseQty(dom, api)); + + // Then the smaller exact quantity wins and missing DOM evidence never falls back to the API amount. + assert.deepEqual(quantities, ['60', '80', '1.000000000000000001', '0']); + invalidActions.forEach((action) => assert.throws(action, /quantity/i)); +}); + +for (const { name, quantities, replacements, waits } of [ + { name: 'during the initial recovery wait', quantities: ['100', '0'], replacements: 0, waits: 1 }, + { name: 'after the scoped replacement', quantities: ['100', '100', '0'], replacements: 1, waits: 2 }, +]) { + test(`user finishes the close round when the position becomes flat ${name}`, async () => { + // Given a reduce-only rejection and authoritative position reads that become zero. + const { options, events } = scenario({ quantities }); + + // When the close round performs its allowed recovery steps. + const result = await runCloseLadderWithPositionRecovery(options); + + // Then the flat result ends recovery without rebuilding or submitting another order. + assert.deepEqual(result, { status: 'position_closed' }); + assert.equal(events.filter(([type]) => type === 'execute').length, 1); + assert.equal(events.filter(([type]) => type === 'replace').length, replacements); + assert.equal(events.filter(([type]) => type === 'wait').length, waits); + assert.deepEqual(events.filter(([type]) => type === 'build'), [['build', '100']]); + }); +} + +test('user completes an ordinary close round without reading or replacing positions', async () => { + // Given a first plan that succeeds and no recovery position reads. + const { options, events } = scenario({ quantities: [], failures: [] }); + delete options.signal; + + // When the close round executes without a stop signal or rejection. + const result = await runCloseLadderWithPositionRecovery(options); + + // Then the original plan and exact execution result are returned without recovery work. + assert.deepEqual(result, { plan: { spec: { mode: 'CLOSE' }, baseQty: '100' }, done: 3 }); assert.deepEqual(events, [['build', '100'], ['execute']]); }); -test('recovery quantity respects both fresh DOM availability and exact API position', () => { - assert.equal(capCloseLadderBaseQty('60', '80'), '60'); - assert.equal(capCloseLadderBaseQty('100', '80'), '80'); - assert.equal(capCloseLadderBaseQty('1.000000000000000002', '1.000000000000000001'), '1.000000000000000001'); - assert.equal(capCloseLadderBaseQty('0', '80'), '0'); - assert.throws(() => capCloseLadderBaseQty(null, '80'), /quantity/i); - assert.throws(() => capCloseLadderBaseQty('100', 'invalid'), /quantity/i); +test('user stops reduce-only recovery when the position adapter returns an unread quantity', async () => { + // Given a confirmed rejection and a position read that returns no authoritative quantity. + const { options, events } = scenario({ quantities: [null] }); + + // When recovery tries to establish its position baseline. + const completion = runCloseLadderWithPositionRecovery(options); + + // Then invalid position evidence is terminal and cannot authorize waiting or cancellation. + await assert.rejects(completion, (error) => { + assert.equal(error.name, 'CloseLadderRecoveryError'); + assert.match(error.message, /Invalid confirmed position quantity/); + assert.match(error.message, /90802022/); + assert.equal(error.continuousRecoveryKind, undefined); + return true; + }); + assert.equal(events.filter(([type]) => type === 'execute').length, 1); + assert.equal(events.filter(([type]) => type === 'replace').length, 0); + assert.equal(events.filter(([type]) => type === 'wait').length, 0); +}); + +test('user stops recovery if the position increases after replacement', async () => { + // Given unchanged position evidence authorizing one replacement followed by a larger position. + const { options, events } = scenario({ quantities: ['100', '100', '101'] }); + + // When the post-replacement position is revalidated. + const completion = runCloseLadderWithPositionRecovery(options); + + // Then the increased position ends the round with no additional plan or submission. + await assert.rejects(completion, (error) => { + assert.match(formatLocalizedText(error.localizedText, 'en'), /Position increased/); + assert.match(error.message, /90802022/); + return true; + }); + assert.equal(events.filter(([type]) => type === 'execute').length, 1); + assert.equal(events.filter(([type]) => type === 'replace').length, 1); + assert.deepEqual(events.filter(([type]) => type === 'build'), [['build', '100']]); }); diff --git a/test/unit/binance-orderbook-trade/continuous-ladder.test.js b/test/unit/binance-orderbook-trade/continuous-ladder.test.js index 8aa6513..78046f8 100644 --- a/test/unit/binance-orderbook-trade/continuous-ladder.test.js +++ b/test/unit/binance-orderbook-trade/continuous-ladder.test.js @@ -26,6 +26,32 @@ import { const zh = (value) => formatLocalizedText(value, UI_LOCALE_ZH_CN); const en = (value) => formatLocalizedText(value, UI_LOCALE_EN); +test('user sees zero-round progress when continuous close stops before the first round', () => { + // Given a continuous session that has not received any round outcome. + const progress = createContinuousLadderProgress(); + const label = localizedText('阶梯平空', 'Close Short'); + + // When the user stops the session before its first round can start. + const message = formatContinuousLadderProgress(label, 'stopped', progress); + + // Then both locales report zero work without trying to display a nonexistent plan. + assert.equal(zh(message), '连续阶梯平空 · 已停止 · 0 轮 · 累计 0 笔'); + assert.equal(en(message), 'Continuous Close Short · Stopped · 0 rounds · Total 0'); +}); + +test('user sees a readiness wait before continuous close has any recorded round', () => { + // Given a session whose first attempt could not start on the trading page. + const progress = createContinuousLadderProgress(); + const label = localizedText('阶梯平空', 'Close Short'); + + // When the continuous session reports that it is waiting for the button. + const message = formatContinuousLadderWaitProgress(label, progress, 'waiting_ready', 1000); + + // Then the wait preserves the action identity and accurate zero-round totals. + assert.equal(zh(message), '连续阶梯平空 · 等待按钮恢复 · 0 轮 · 累计 0 笔'); + assert.equal(en(message), 'Continuous Close Short · Waiting for button · 0 rounds · Total 0'); +}); + function roundProgress({ submittedOrders, cancelledOrders = 0, @@ -40,7 +66,8 @@ function roundProgress({ }; } -test('continuous ladder status summarizes completed rounds and cumulative submissions', () => { +test('user sees completed close rounds and cumulative confirmed submissions', () => { + // Given two completed rounds with three confirmed orders in each. const progress = createContinuousLadderProgress(); const completedRound = roundProgress({ submittedOrders: 3, @@ -48,9 +75,11 @@ test('continuous ladder status summarizes completed rounds and cumulative submis currentPlanSubmittedOrders: 3, }); + // When both round outcomes are recorded. recordContinuousLadderRound(progress, { status: 'completed', progress: completedRound }); recordContinuousLadderRound(progress, { status: 'completed', progress: completedRound }); + // Then the detached progress and displayed counters show two rounds and six submissions. assert.deepEqual(progress, { startedRounds: 2, completedRounds: 2, @@ -70,13 +99,15 @@ test('continuous ladder status summarizes completed rounds and cumulative submis ); }); -test('continuous ladder stopped status separates completed rounds from a partial round', () => { +test('user sees a stopped partial round separately from completed rounds', () => { + // Given two completed close rounds and a third round stopped after one order. const progress = createContinuousLadderProgress(); const completedRound = roundProgress({ submittedOrders: 3, plannedOrders: 3, currentPlanSubmittedOrders: 3, }); + // When the completed and stopped outcomes are recorded. recordContinuousLadderRound(progress, { status: 'completed', progress: completedRound }); recordContinuousLadderRound(progress, { status: 'completed', progress: completedRound }); recordContinuousLadderRound(progress, { @@ -88,14 +119,17 @@ test('continuous ladder stopped status separates completed rounds from a partial }), }); + // Then the status keeps two completed rounds, three started rounds, and seven submissions. assert.equal( zh(formatContinuousLadderProgress('阶梯平空', 'stopped', progress)), '连续阶梯平空 · 已停止 · 2/3 轮 · 本轮 1/3 笔 · 累计 7 笔', ); }); -test('continuous ladder status shows cancellations only when they occurred', () => { +test('user sees confirmed cancellations alongside a failed continuous close round', () => { + // Given a failed round with two submissions and one confirmed cancellation. const progress = createContinuousLadderProgress(); + // When the failed round is recorded. recordContinuousLadderRound(progress, { status: 'failed', progress: roundProgress({ @@ -106,22 +140,26 @@ test('continuous ladder status shows cancellations only when they occurred', () }), }); + // Then the status preserves cancellation count and the concrete failure reason. assert.equal( zh(formatContinuousLadderProgress('阶梯平多', 'failed', progress, '下单按钮 3 秒内未恢复可点击')), '连续阶梯平多 · 失败 · 0/1 轮 · 本轮 2/3 笔 · 累计 2 笔 · 撤 1 笔 · 下单按钮 3 秒内未恢复可点击', ); }); -test('active continuous ladder status keeps the continuous action and live round totals', () => { +test('user sees the active close round combined with earlier confirmed work', () => { + // Given two completed three-order rounds and a live partial round. const progress = createContinuousLadderProgress(); const completedRound = roundProgress({ submittedOrders: 3, plannedOrders: 3, currentPlanSubmittedOrders: 3, }); + // When the completed rounds are recorded before the live progress is displayed. recordContinuousLadderRound(progress, { status: 'completed', progress: completedRound }); recordContinuousLadderRound(progress, { status: 'completed', progress: completedRound }); + // Then the continuous action identity includes live order and cancellation totals. assert.equal( zh(formatActiveContinuousLadderProgress( '阶梯平空', @@ -138,7 +176,8 @@ test('active continuous ladder status keeps the continuous action and live round ); }); -test('continuous ladder progress rejects an invalid or duplicate round outcome', () => { +test('user cannot count the same completed round twice or render an unknown phase', () => { + // Given one completed close-round outcome and an empty session aggregate. const progress = createContinuousLadderProgress(); const outcome = { status: 'completed', @@ -148,8 +187,10 @@ test('continuous ladder progress rejects an invalid or duplicate round outcome', currentPlanSubmittedOrders: 1, }), }; + // When the round is recorded once. recordContinuousLadderRound(progress, outcome); + // Then a duplicate outcome and an unknown display phase are explicitly rejected. assert.throws( () => recordContinuousLadderRound(progress, outcome), /连续阶梯本轮结果已记录/, @@ -160,77 +201,47 @@ test('continuous ladder progress rejects an invalid or duplicate round outcome', ); }); -test('continuous ladder retries only explicitly designed recoverable failures', () => { - const inputUnstable = new Error('价格框或数量框未稳定'); - inputUnstable.safeNoSubmit = true; - inputUnstable.continuousRecoveryKind = 'input_unstable'; - assert.deepEqual(resolveContinuousLadderRecovery(inputUnstable), { - cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, - reason: '价格框或数量框未稳定', - }); - - const marketDataNotReady = new Error('盘口数据未就绪'); - marketDataNotReady.safeNoSubmit = true; - marketDataNotReady.continuousRecoveryKind = 'market_data_not_ready'; - assert.deepEqual(resolveContinuousLadderRecovery(marketDataNotReady), { - cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, - reason: '盘口数据未就绪', - }); - - const precisionChanged = new Error('价格精度已变化'); - precisionChanged.safeNoSubmit = true; - precisionChanged.continuousRecoveryKind = 'precision_changed'; +test('user retries only failures covered by the continuous-close recovery policy', () => { + // Given explicit recovery kinds with the submission evidence and error text captured by the round. + const cases = [ + { kind: 'input_unstable', message: '价格框或数量框未稳定', safeNoSubmit: true, expectedMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS }, + { kind: 'market_data_not_ready', message: '盘口数据未就绪', safeNoSubmit: true, expectedMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS }, + { kind: 'controls_not_ready', message: 'Controls unavailable', safeNoSubmit: true, expectedMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS }, + { kind: 'position_state_not_ready', message: 'Position unavailable', safeNoSubmit: true, expectedMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS }, + { kind: 'submit_unconfirmed', message: '仍未确认订单结果', safeNoSubmit: false, expectedMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS }, + { kind: 'open_orders_not_ready', message: '当前委托列表暂未就绪', expectedMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS }, + { kind: 'order_capacity_not_ready', message: 'Order capacity unavailable', expectedMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS }, + { kind: 'position_quantity_not_ready', message: '当前方向暂无可平数量', safeNoSubmit: true, expectedMs: CONTINUOUS_LADDER_LONG_RECOVERY_COOLDOWN_MS }, + { kind: 'rate_limited', message: '请求频率受限', cooldownMs: 17000, expectedMs: 17000 }, + ]; + const errors = cases.map((entry) => Object.assign(new Error(entry.message), { + continuousRecoveryKind: entry.kind, + safeNoSubmit: entry.safeNoSubmit, + continuousRecoveryCooldownMs: entry.cooldownMs, + })); + const precisionChanged = Object.assign(new Error('价格精度已变化'), { safeNoSubmit: true, continuousRecoveryKind: 'precision_changed' }); + const optionsChanged = Object.assign(new Error('阶梯设置已变化'), { safeNoSubmit: true, continuousRecoveryKind: 'options_changed' }); + const unsupported = Object.assign(new Error('未知错误'), { safeNoSubmit: true, continuousRecoveryKind: 'unknown' }); + + // When the next-round recovery policies are resolved. + const recoveries = errors.map(resolveContinuousLadderRecovery); const precisionRecovery = resolveContinuousLadderRecovery(precisionChanged); + const optionsRecovery = resolveContinuousLadderRecovery(optionsChanged); + const unsupportedRecovery = resolveContinuousLadderRecovery(unsupported); + + // Then each supported kind keeps its cooldown and reason while unknown kinds are terminal. + recoveries.forEach((recovery, index) => assert.deepEqual(recovery, { cooldownMs: cases[index].expectedMs, reason: cases[index].message })); assert.equal(precisionRecovery.cooldownMs, CONTINUOUS_LADDER_COOLDOWN_MS); assert.equal(zh(precisionRecovery.reason), '价格精度已变化,下一轮按新精度继续'); assert.equal(en(precisionRecovery.reason), 'Precision changed; the next round will use the new precision'); - - const optionsChanged = new Error('阶梯设置已变化'); - optionsChanged.safeNoSubmit = true; - optionsChanged.continuousRecoveryKind = 'options_changed'; - const optionsRecovery = resolveContinuousLadderRecovery(optionsChanged); assert.equal(optionsRecovery.cooldownMs, CONTINUOUS_LADDER_COOLDOWN_MS); assert.equal(zh(optionsRecovery.reason), '比例、笔数或间距已变化,下一轮按新设置继续'); assert.equal(en(optionsRecovery.reason), 'Ratio, orders, or gap changed; the next round will use the new settings'); - - const unknownOutcome = new Error('仍未确认订单结果'); - unknownOutcome.safeNoSubmit = false; - unknownOutcome.continuousRecoveryKind = 'submit_unconfirmed'; - assert.deepEqual(resolveContinuousLadderRecovery(unknownOutcome), { - cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, - reason: '仍未确认订单结果', - }); - - const openOrdersNotReady = new Error('当前委托列表暂未就绪'); - openOrdersNotReady.continuousRecoveryKind = 'open_orders_not_ready'; - assert.deepEqual(resolveContinuousLadderRecovery(openOrdersNotReady), { - cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, - reason: '当前委托列表暂未就绪', - }); - - const positionQuantityNotReady = new Error('当前方向暂无可平数量'); - positionQuantityNotReady.safeNoSubmit = true; - positionQuantityNotReady.continuousRecoveryKind = 'position_quantity_not_ready'; - assert.deepEqual(resolveContinuousLadderRecovery(positionQuantityNotReady), { - cooldownMs: CONTINUOUS_LADDER_LONG_RECOVERY_COOLDOWN_MS, - reason: '当前方向暂无可平数量', - }); - - const rateLimited = new Error('请求频率受限'); - rateLimited.continuousRecoveryKind = 'rate_limited'; - rateLimited.continuousRecoveryCooldownMs = 17000; - assert.deepEqual(resolveContinuousLadderRecovery(rateLimited), { - cooldownMs: 17000, - reason: '请求频率受限', - }); - - const unsupported = new Error('未知错误'); - unsupported.safeNoSubmit = true; - unsupported.continuousRecoveryKind = 'unknown'; - assert.equal(resolveContinuousLadderRecovery(unsupported), null); + assert.equal(unsupportedRecovery, null); }); -test('a settings change preserves the partial round before the next round uses new settings', () => { +test('user keeps partial-round progress when changed settings defer the next round', () => { + // Given two confirmed orders followed by a safe settings-change failure. const progress = createContinuousLadderProgress(); const optionsChanged = new Error('执行中比例、笔数或间距已变化'); optionsChanged.safeNoSubmit = true; @@ -245,9 +256,11 @@ test('a settings change preserves the partial round before the next round uses n }), }; + // When the partial outcome is recorded and its recovery policy is resolved. recordContinuousLadderRound(progress, outcome); const recovery = resolveContinuousLadderRecovery(optionsChanged); + // Then the one-second wait retains the partial counts and explains the new settings. assert.equal(recovery.cooldownMs, CONTINUOUS_LADDER_COOLDOWN_MS); assert.equal(zh(recovery.reason), '比例、笔数或间距已变化,下一轮按新设置继续'); assert.equal( @@ -261,7 +274,8 @@ test('a settings change preserves the partial round before the next round uses n ); }); -test('continuous ladder starts its cooldown only after the previous round is ready', async () => { +test('user waits for button readiness before the full inter-round cooldown', async () => { + // Given two unavailable button checks followed by stable readiness and an injected delay adapter. const states = [ { status: 'waiting' }, { status: 'waiting' }, @@ -271,12 +285,14 @@ test('continuous ladder starts its cooldown only after the previous round is rea const delays = []; const waitStates = []; + // When the next-round readiness workflow runs. const result = await waitForContinuousLadderNextRound({ readReadiness: () => states.shift(), delay: async (ms) => delays.push(ms), onWaitStateChange: (state) => waitStates.push(state), }); + // Then readiness checks precede exactly one full cooldown and the two visible wait phases. assert.deepEqual(result, { status: 'ready' }); assert.deepEqual(delays, [ CONTINUOUS_LADDER_READY_CHECK_MS, @@ -289,7 +305,8 @@ test('continuous ladder starts its cooldown only after the previous round is rea ]); }); -test('continuous ladder restarts a full cooldown if readiness is lost', async () => { +test('user restarts the full cooldown after the close button loses readiness', async () => { + // Given a ready button that becomes unavailable during the first cooldown. const states = [ { status: 'ready' }, { status: 'waiting' }, @@ -300,12 +317,14 @@ test('continuous ladder restarts a full cooldown if readiness is lost', async () const delays = []; const waitStates = []; + // When the next-round readiness workflow rechecks the button. const result = await waitForContinuousLadderNextRound({ readReadiness: () => states.shift(), delay: async (ms) => delays.push(ms), onWaitStateChange: (state) => waitStates.push(state), }); + // Then readiness recovery starts a new full cooldown without duplicate waiting notices. assert.deepEqual(result, { status: 'ready' }); assert.deepEqual(delays, [ CONTINUOUS_LADDER_COOLDOWN_MS, @@ -319,23 +338,23 @@ test('continuous ladder restarts a full cooldown if readiness is lost', async () ]); }); -test('continuous ladder wait reasons distinguish readiness from the actual cooldown', () => { - assert.equal( - zh(formatContinuousLadderWaitReason('waiting_ready', CONTINUOUS_LADDER_COOLDOWN_MS)), - '等待按钮恢复', - ); - assert.equal( - zh(formatContinuousLadderWaitReason('cooldown', CONTINUOUS_LADDER_COOLDOWN_MS)), - '1s 后继续', - ); - assert.throws( - () => formatContinuousLadderWaitReason('unknown', CONTINUOUS_LADDER_COOLDOWN_MS), - /连续阶梯等待阶段无效/, - ); +test('user distinguishes button readiness from the actual cooldown duration', () => { + // Given one second and a fractional-second cooldown plus an unsupported phase. + const waits = [['waiting_ready', CONTINUOUS_LADDER_COOLDOWN_MS], ['cooldown', CONTINUOUS_LADDER_COOLDOWN_MS], ['cooldown', 250]]; + + // When the visible waiting reasons are formatted. + const reasons = waits.map(([phase, ms]) => zh(formatContinuousLadderWaitReason(phase, ms))); + const invalidPhase = () => formatContinuousLadderWaitReason('unknown', CONTINUOUS_LADDER_COOLDOWN_MS); + + // Then readiness has its own text, durations stay exact, and unknown phases are rejected. + assert.deepEqual(reasons, ['等待按钮恢复', '1s 后继续', '250ms 后继续']); + assert.throws(invalidPhase, /连续阶梯等待阶段无效/); }); -test('continuous ladder wait status puts the current wait before progress counters', () => { +test('user sees the current wait phase before continuous-round counters', () => { + // Given two completed three-order close rounds. const progress = createContinuousLadderProgress(); + // When the completed rounds are recorded. recordContinuousLadderRound(progress, { status: 'completed', progress: roundProgress({ @@ -353,6 +372,7 @@ test('continuous ladder wait status puts the current wait before progress counte }), }); + // Then cooldown and button-wait text occupy the phase slot ahead of the same counters. assert.equal( zh(formatContinuousLadderWaitProgress( '阶梯平空', @@ -373,32 +393,40 @@ test('continuous ladder wait status puts the current wait before progress counte ); }); -test('continuous ladder returns a terminal readiness state without another cooldown', async () => { +test('user ends continuous closing immediately when readiness confirms a flat position', async () => { + // Given a terminal position-flat readiness result. const delays = []; const stopped = { status: 'stopped', reason: 'position_flat' }; + // When the next-round workflow checks readiness. const result = await waitForContinuousLadderNextRound({ readReadiness: () => stopped, delay: async (ms) => delays.push(ms), }); + // Then the exact terminal result is returned without any cooldown. assert.equal(result, stopped); assert.deepEqual(delays, []); }); -test('continuous ladder supports an asynchronous confirmed-flat readiness check', async () => { +test('user can end continuous closing from an asynchronous flat-position confirmation', async () => { + // Given an asynchronous readiness adapter that confirms the position is flat. const delays = []; + // When the next-round workflow awaits that confirmation. const result = await waitForContinuousLadderNextRound({ readReadiness: async () => ({ status: 'stopped', reason: 'position_flat' }), delay: async (ms) => delays.push(ms), }); + // Then the flat result ends the wait without a cooldown. assert.deepEqual(result, { status: 'stopped', reason: 'position_flat' }); assert.deepEqual(delays, []); }); -test('continuous ladder formats confirmed flat as an ended outcome without a failed round detail', () => { +test('user sees an ended close session after the position is confirmed flat', () => { + // Given a flat-position outcome with one confirmed cancellation and no submissions. const progress = createContinuousLadderProgress(); + // When the outcome is recorded. recordContinuousLadderRound(progress, { status: 'position_closed', progress: roundProgress({ @@ -409,14 +437,17 @@ test('continuous ladder formats confirmed flat as an ended outcome without a fai }), }); + // Then the session reports ended, the flat reason, and its real cancellation total. assert.equal( zh(formatContinuousLadderPositionClosedProgress('阶梯平空', progress)), '连续阶梯平空 · 已结束 · 当前方向已无持仓 · 0/1 轮 · 累计 0 笔 · 撤 1 笔', ); }); -test('continuous ladder progress renders its counters in English', () => { +test('user sees continuous close counters and wait text in English', () => { + // Given an English close action and one completed three-order round. const progress = createContinuousLadderProgress(); + // When the completed outcome is recorded. recordContinuousLadderRound(progress, { status: 'completed', progress: roundProgress({ @@ -426,6 +457,7 @@ test('continuous ladder progress renders its counters in English', () => { }), }); + // Then English phase and order counters use the same progress values. assert.equal( en(formatContinuousLadderWaitProgress( localizedText('阶梯平空', 'Close Short'), @@ -437,10 +469,12 @@ test('continuous ladder progress renders its counters in English', () => { ); }); -test('continuous ladder cooldown is abortable', async () => { +test('user can stop a continuous close wait without completing its pending delay', async () => { + // Given a ready button, a pending delay, and a user stop signal. const abortController = new AbortController(); const stoppedError = new Error('stopped'); stoppedError.name = 'LadderStoppedError'; + // When the wait starts and the user aborts it. const task = waitForContinuousLadderNextRound({ readReadiness: () => ({ status: 'ready' }), delay: () => new Promise(() => {}), @@ -448,18 +482,224 @@ test('continuous ladder cooldown is abortable', async () => { }); abortController.abort(stoppedError); + // Then the original stop error terminates the wait immediately. await assert.rejects( task, (error) => error === stoppedError, ); }); -test('continuous ladder rejects an invalid readiness contract', async () => { - await assert.rejects( - waitForContinuousLadderNextRound({ - readReadiness: () => ({ status: 'unknown' }), - delay: async () => {}, - }), - /连续阶梯按钮就绪状态无效/, - ); +test('user stops before a cooldown when the readiness adapter returns an unknown state', async () => { + // Given a readiness adapter that cannot supply a supported status. + const delays = []; + const options = { readReadiness: () => ({ status: 'unknown' }), delay: async (ms) => delays.push(ms) }; + + // When the continuous close workflow asks whether another round can start. + const completion = waitForContinuousLadderNextRound(options); + + // Then the invalid state is reported before any cooldown is scheduled. + await assert.rejects(completion, /连续阶梯按钮就绪状态无效/); + assert.deepEqual(delays, []); +}); + +for (const safeNoSubmit of [false, undefined]) { + test(`user cannot recover an unstable input when no-submit evidence is ${String(safeNoSubmit)}`, () => { + // Given an input-instability failure without proof that no order was submitted. + const error = Object.assign(new Error('Input unstable'), { continuousRecoveryKind: 'input_unstable', safeNoSubmit }); + + // When continuous mode considers the recovery policy. + const recovery = resolveContinuousLadderRecovery(error); + + // Then input instability alone cannot authorize another round. + assert.equal(recovery, null); + }); +} + +test('user keeps a localized recovery reason supplied by the failing adapter', () => { + // Given a safe no-submit failure with localized text rather than only a raw error message. + const reason = localizedText('盘口未就绪', 'Market data unavailable'); + const error = Object.assign(new Error('Raw adapter failure'), { + continuousRecoveryKind: 'market_data_not_ready', safeNoSubmit: true, localizedText: reason, + }); + + // When the next-round recovery reason is resolved. + const recovery = resolveContinuousLadderRecovery(error); + const absent = resolveContinuousLadderRecovery(null); + + // Then the original localized reason survives and missing errors create no recovery policy. + assert.deepEqual(recovery, { cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, reason }); + assert.equal(absent, null); +}); + +for (const cooldownMs of [-1, NaN, Infinity]) { + test(`user cannot schedule a continuous recovery with invalid cooldown ${String(cooldownMs)}`, () => { + // Given an explicit rate-limit recovery with an invalid cooldown override. + const error = Object.assign(new Error('Rate limited'), { + continuousRecoveryKind: 'rate_limited', continuousRecoveryCooldownMs: cooldownMs, + }); + + // When the cooldown and its display text are validated. + const resolve = () => resolveContinuousLadderRecovery(error); + const format = () => formatContinuousLadderWaitReason('cooldown', cooldownMs); + + // Then both execution and presentation reject the invalid duration. + assert.throws(resolve, /连续阶梯恢复等待时间无效/); + assert.throws(format, /连续阶梯轮间等待时间无效/); + }); +} + +test('user keeps completed-round counters detached from later progress changes', () => { + // Given one completed close round and its mutable live progress object. + const progress = createContinuousLadderProgress(); + const live = roundProgress({ submittedOrders: 3, plannedOrders: 3, currentPlanSubmittedOrders: 3 }); + const outcome = { status: 'completed', progress: live }; + + // When the round is recorded and the old live object subsequently changes. + recordContinuousLadderRound(progress, outcome); + live.submittedOrders = 4; + live.currentPlanSubmittedOrders = 0; + live.cancelledOrders = 2; + + // Then the recorded round and cumulative totals retain the confirmed snapshot. + assert.deepEqual(progress, { + startedRounds: 1, completedRounds: 1, submittedOrders: 3, cancelledOrders: 0, + lastRound: { status: 'completed', submittedOrders: 3, cancelledOrders: 0, plannedOrders: 3, currentPlanSubmittedOrders: 3 }, + }); +}); + +for (const outcome of [null, 'completed', { status: 'unknown' }]) { + test(`user cannot record a missing or unknown round outcome ${JSON.stringify(outcome)}`, () => { + // Given a fresh session and an outcome that lacks the required round contract. + const progress = createContinuousLadderProgress(); + const initial = structuredClone(progress); + + // When that outcome is offered to the continuous-round aggregate. + const record = () => recordContinuousLadderRound(progress, outcome); + + // Then invalid outcomes do not change any completed or submitted counter. + assert.throws(record, /连续阶梯本轮结果无效/); + assert.deepEqual(progress, initial); + }); +} + +test('user sees a flat-position end state before any close round has started', () => { + // Given a session whose first readiness read confirms a flat position. + const progress = createContinuousLadderProgress(); + const label = localizedText('阶梯平空', 'Close Short'); + + // When the confirmed-flat session is formatted. + const message = formatContinuousLadderPositionClosedProgress(label, progress); + + // Then the ended status reports zero rounds and zero submissions without cancellation text. + assert.equal(zh(message), '连续阶梯平空 · 已结束 · 当前方向已无持仓 · 0 轮 · 累计 0 笔'); + assert.equal(en(message), 'Continuous Close Short · Ended · No position in this direction · 0 rounds · Total 0'); +}); + +test('user sees the first active round while its plan and phase detail are not ready', () => { + // Given an empty session and a current round that has not built a plan or submitted orders. + const progress = createContinuousLadderProgress(); + const current = roundProgress({ submittedOrders: 0, plannedOrders: null, currentPlanSubmittedOrders: 0 }); + + // When active continuous progress is displayed without a phase detail. + const message = formatActiveContinuousLadderProgress('阶梯平多', null, progress, current); + + // Then the round identity and total remain visible without invented plan or cancellation counts. + assert.equal(zh(message), '连续阶梯平多 · 0/1 轮 · 累计 0 笔'); +}); + +for (const { name, options, expectedError } of [ + { name: 'negative cooldown', options: { cooldownMs: -1 }, expectedError: /连续阶梯轮间等待时间无效/ }, + { name: 'zero readiness-check interval', options: { readyCheckMs: 0 }, expectedError: /连续阶梯按钮检查间隔无效/ }, + { name: 'non-callable phase callback', options: { onWaitStateChange: null }, expectedError: /连续阶梯等待状态回调无效/ }, +]) { + test(`user cannot start the next-round wait with a ${name}`, async () => { + // Given invalid wait configuration and adapters that record any attempted work. + const operations = []; + const adapters = { + readReadiness: () => { operations.push('read'); return { status: 'ready' }; }, + delay: async () => { operations.push('delay'); }, + }; + + // When the configured next-round wait is started. + const completion = waitForContinuousLadderNextRound({ ...adapters, ...options }); + + // Then configuration fails before a readiness read or delay can run. + await assert.rejects(completion, expectedError); + assert.deepEqual(operations, []); + }); +} + +test('user stops if the position becomes flat during the final cooldown', async () => { + // Given a ready button followed by an authoritative flat-position result after cooldown. + const states = [{ status: 'ready' }, { status: 'stopped', reason: 'position_flat' }]; + const waits = []; + + // When the next-round workflow completes its cooldown and reads fresh readiness. + const result = await waitForContinuousLadderNextRound({ + readReadiness: () => states.shift(), delay: async (ms) => waits.push(ms), + }); + + // Then the flat result ends the session after one cooldown without starting another wait. + assert.deepEqual(result, { status: 'stopped', reason: 'position_flat' }); + assert.deepEqual(waits, [CONTINUOUS_LADDER_COOLDOWN_MS]); +}); + +test('user sees an authoritative readiness-read failure without another automatic check', async () => { + // Given a readiness adapter that fails before it can confirm the close button state. + const failure = new Error('Position response unavailable'); + let reads = 0; + const waits = []; + + // When the next-round workflow asks the adapter for readiness. + const completion = waitForContinuousLadderNextRound({ + readReadiness: async () => { reads += 1; throw failure; }, + delay: async (ms) => waits.push(ms), + }); + + // Then the original failure propagates without a blind retry or cooldown. + await assert.rejects(completion, (error) => error === failure); + assert.equal(reads, 1); + assert.deepEqual(waits, []); +}); + +test('user cannot start the next close round one millisecond before the full cooldown', async () => { + // Given a manual clock, a ready close button, and a one-second next-round cooldown. + let now = 0; + let pending = null; + let finished = false; + const delayStarted = Promise.withResolvers(); + const delay = (ms) => { + const deferred = Promise.withResolvers(); + pending = { deadline: now + ms, deferred }; + delayStarted.resolve(); + return deferred.promise; + }; + const advance = (ms) => { + now += ms; + if (pending && now >= pending.deadline) { + pending.deferred.resolve(); + pending = null; + } + }; + + // When the cooldown starts and virtual time advances to 999 ms. + const completion = waitForContinuousLadderNextRound({ readReadiness: () => ({ status: 'ready' }), delay }) + .then((result) => { finished = true; return result; }); + await delayStarted.promise; + advance(999); + + // Then readiness alone cannot finish the remaining millisecond of cooldown. + assert.equal(finished, false); + assert.equal(now, 999); + assert.equal(pending.deadline, 1000); + + // When virtual time reaches the complete cooldown duration. + advance(1); + const result = await completion; + + // Then a fresh readiness check permits the next round exactly at 1000 ms. + assert.deepEqual(result, { status: 'ready' }); + assert.equal(finished, true); + assert.equal(now, 1000); + assert.equal(pending, null); }); diff --git a/test/unit/binance-orderbook-trade/order-feedback.test.js b/test/unit/binance-orderbook-trade/order-feedback.test.js index 3b0f171..5910e98 100644 --- a/test/unit/binance-orderbook-trade/order-feedback.test.js +++ b/test/unit/binance-orderbook-trade/order-feedback.test.js @@ -11,27 +11,22 @@ import { isBinancePostOnlyMakerRejectCode, isOpenLadderOpenOrdersCapacityFeedback, isPostOnlyMakerRejectionFeedback, + isPotentialOrderFeedbackText, isReduceOnlyOpenOrdersConflictFeedback, readConfirmedReduceOnlyRejection, resolveBinanceSubmitResponseRecovery, summarizeBinancePlaceOrderPayload, } from '../../../src/binance-orderbook-trade/core/order-feedback.js'; -test('reduce-only recovery requires a single settled native close rejection', () => { +test('user recovers a reduce-only conflict only from one settled native close rejection', () => { + // Given a verified rejection plus observations with missing, duplicate, or contradictory evidence. const apiError = { success: false, code: 90802022, message: 'Reduce-only order failed' }; const observation = { settled: true, diagnostics: [{ httpStatus: 200, bodyKind: 'json', payloadSummary: apiError }], apiErrors: [apiError], }; - assert.deepEqual(readConfirmedReduceOnlyRejection('CLOSE', observation, []), apiError); - assert.deepEqual(readConfirmedReduceOnlyRejection('CLOSE', { - ...observation, - diagnostics: [{ ...observation.diagnostics[0], payloadSummary: { ...apiError, code: '90802022' } }], - }, []), apiError); - assert.equal(readConfirmedReduceOnlyRejection('OPEN', observation, []), null); - assert.equal(readConfirmedReduceOnlyRejection('CLOSE', observation, [{}]), null); - for (const patch of [ + const patches = [ { settled: false }, { diagnostics: [] }, { diagnostics: [observation.diagnostics[0], observation.diagnostics[0]] }, @@ -43,163 +38,252 @@ test('reduce-only recovery requires a single settled native close rejection', () { diagnostics: [{ ...observation.diagnostics[0], bodyKind: 'invalid_json' }] }, { diagnostics: [{ ...observation.diagnostics[0], payloadSummary: { ...apiError, success: true } }] }, { diagnostics: [{ ...observation.diagnostics[0], payloadSummary: { ...apiError, code: 90802025 } }] }, - ]) { - assert.equal(readConfirmedReduceOnlyRejection('CLOSE', { ...observation, ...patch }, []), null); - } + ]; + const cases = [ + { mode: 'CLOSE', observation, successes: [], expected: apiError }, + { mode: 'CLOSE', observation: { ...observation, diagnostics: [{ ...observation.diagnostics[0], payloadSummary: { ...apiError, code: '90802022' } }] }, successes: [], expected: apiError }, + { mode: 'OPEN', observation, successes: [], expected: null }, + { mode: 'CLOSE', observation, successes: [{}], expected: null }, + ...patches.map((patch) => ({ mode: 'CLOSE', observation: { ...observation, ...patch }, successes: [], expected: null })), + { mode: 'CLOSE', observation: { ...observation, diagnostics: [{ httpStatus: 200, bodyKind: 'json' }] }, successes: [], expected: null }, + ]; + + // When native response evidence is checked for safe reduce-only recovery. + const results = cases.map((entry) => readConfirmedReduceOnlyRejection(entry.mode, entry.observation, entry.successes)); + + // Then only a single successful HTTP JSON rejection with no successful submission qualifies. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('classifies only rate limits and uncertain server responses as recoverable', () => { - assert.deepEqual(resolveBinanceSubmitResponseRecovery([ - { httpStatus: 429, retryAfter: '7' }, - ], []), { - kind: 'rate_limited', - cooldownMs: 7000, - }); - assert.deepEqual(resolveBinanceSubmitResponseRecovery([ - { httpStatus: 200, retryAfter: null }, - ], [{ code: -1003 }]), { - kind: 'rate_limited', - cooldownMs: 10000, - }); - assert.deepEqual(resolveBinanceSubmitResponseRecovery([ - { httpStatus: 503, retryAfter: null }, - ], []), { - kind: 'submit_unconfirmed', - cooldownMs: 3000, - }); - assert.equal(resolveBinanceSubmitResponseRecovery([ - { httpStatus: 400, retryAfter: null }, - ], []), null); - assert.equal(resolveBinanceSubmitResponseRecovery([ - { httpStatus: 403, retryAfter: null }, - ], []), null); +test('user gets the defined recovery policy for rate limits and server uncertainty', () => { + // Given rate-limited, server-error, and terminal client-error response evidence. + const cases = [ + { read: resolveBinanceSubmitResponseRecovery, args: [[ + { httpStatus: 429, retryAfter: '7' }, + ], []], expected: { + kind: 'rate_limited', + cooldownMs: 7000, + } }, + { read: resolveBinanceSubmitResponseRecovery, args: [[ + { httpStatus: 200, retryAfter: null }, + ], [{ code: -1003 }]], expected: { + kind: 'rate_limited', + cooldownMs: 10000, + } }, + { read: resolveBinanceSubmitResponseRecovery, args: [[ + { httpStatus: 503, retryAfter: null }, + ], []], expected: { + kind: 'submit_unconfirmed', + cooldownMs: 3000, + } }, + { read: resolveBinanceSubmitResponseRecovery, args: [[ + { httpStatus: 400, retryAfter: null }, + ], []], expected: null }, + { read: resolveBinanceSubmitResponseRecovery, args: [[ + { httpStatus: 403, retryAfter: null }, + ], []], expected: null }, + ]; + + // When response recovery is classified. + const results = cases.map(({ read, args }) => read(...args)); + + // Then the kind and cooldown match the observed failure. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('classifies localized and English order feedback', () => { - assert.equal(classifyOrderFeedback('委托已提交'), 'success'); - assert.equal(classifyOrderFeedback('Order placed successfully'), 'success'); - assert.equal(classifyOrderFeedback('设置成功'), 'unknown'); - assert.equal(classifyOrderFeedback('余额不足,下单失败'), 'failure'); - assert.equal(classifyOrderFeedback('Order rejected'), 'failure'); - assert.equal(classifyOrderFeedback('请确认订单参数'), 'unknown'); +test('user sees localized order feedback classified by its business outcome', () => { + // Given Chinese and English success, failure, and unrelated messages. + const cases = [ + { read: classifyOrderFeedback, args: ['委托已提交'], expected: 'success' }, + { read: classifyOrderFeedback, args: ['Order placed successfully'], expected: 'success' }, + { read: classifyOrderFeedback, args: ['设置成功'], expected: 'unknown' }, + { read: classifyOrderFeedback, args: ['余额不足,下单失败'], expected: 'failure' }, + { read: classifyOrderFeedback, args: ['Order rejected'], expected: 'failure' }, + { read: classifyOrderFeedback, args: ['请确认订单参数'], expected: 'unknown' }, + ]; + + // When the order messages are classified. + const results = cases.map(({ read, args }) => read(...args)); + + // Then each message retains its concrete success, failure, or unknown outcome. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('does not acknowledge ladder submission without new success feedback', () => { - assert.deepEqual(evaluateOrderSubmitAcknowledgement({ - feedback: '', - isNewFeedback: true, - sawBusy: true, - busy: false, - }), { status: 'pending' }); - - assert.deepEqual(evaluateOrderSubmitAcknowledgement({ - feedback: '委托已提交', - isNewFeedback: false, - sawBusy: true, - busy: false, - }), { status: 'pending' }); +test('user cannot count an order from absent or stale success feedback', () => { + // Given a recovered native button with absent feedback or an old success message. + const cases = [ + { read: evaluateOrderSubmitAcknowledgement, args: [{ + feedback: '', + isNewFeedback: true, + sawBusy: true, + busy: false, + }], expected: { status: 'pending' } }, + { read: evaluateOrderSubmitAcknowledgement, args: [{ + feedback: '委托已提交', + isNewFeedback: false, + sawBusy: true, + busy: false, + }], expected: { status: 'pending' } }, + ]; + + // When submission acknowledgement evaluates the feedback. + const results = cases.map(({ read, args }) => read(...args)); + + // Then both outcomes remain pending. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('acknowledges only new success feedback and surfaces failure text', () => { - assert.deepEqual(evaluateOrderSubmitAcknowledgement({ - feedback: '委托已提交', - isNewFeedback: true, - sawBusy: false, - busy: false, - }), { status: 'success' }); - - assert.deepEqual(evaluateOrderSubmitAcknowledgement({ - feedback: 'Order placed successfully', - isNewFeedback: true, - sawBusy: false, - busy: false, - }), { status: 'success' }); - - assert.deepEqual(evaluateOrderSubmitAcknowledgement({ - feedback: '设置成功', - isNewFeedback: true, - sawBusy: false, - busy: false, - }), { status: 'pending' }); - - assert.deepEqual(evaluateOrderSubmitAcknowledgement({ - feedback: '下单失败:余额不足', - isNewFeedback: true, - sawBusy: false, - busy: false, - }), { status: 'failure', message: '下单失败:余额不足' }); +test('user gets confirmation only from a new order success message', () => { + // Given fresh localized successes, an unrelated success, and a concrete order failure. + const cases = [ + { read: evaluateOrderSubmitAcknowledgement, args: [{ + feedback: '委托已提交', + isNewFeedback: true, + sawBusy: false, + busy: false, + }], expected: { status: 'success' } }, + { read: evaluateOrderSubmitAcknowledgement, args: [{ + feedback: 'Order placed successfully', + isNewFeedback: true, + sawBusy: false, + busy: false, + }], expected: { status: 'success' } }, + { read: evaluateOrderSubmitAcknowledgement, args: [{ + feedback: '设置成功', + isNewFeedback: true, + sawBusy: false, + busy: false, + }], expected: { status: 'pending' } }, + { read: evaluateOrderSubmitAcknowledgement, args: [{ + feedback: '下单失败:余额不足', + isNewFeedback: true, + sawBusy: false, + busy: false, + }], expected: { status: 'failure', message: '下单失败:余额不足' } }, + ]; + + // When submission acknowledgements are evaluated. + const results = cases.map(({ read, args }) => read(...args)); + + // Then orders succeed only on relevant feedback and failures keep their text. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('recognizes reduce-only failures caused by existing open orders', () => { - assert.equal(isReduceOnlyOpenOrdersConflictFeedback('只减仓订单失败。请取消此币种的当前挂单,然后重试。'), true); - assert.equal(isReduceOnlyOpenOrdersConflictFeedback('只减仓订单失败。如果您有该合约的未平仓头寸和挂单,请取消挂单后重试。如果您没有任何仓位,请取消只减仓选项后重试。'), true); - assert.equal(isReduceOnlyOpenOrdersConflictFeedback('下单失败:余额不足'), false); - assert.equal(isReduceOnlyOpenOrdersConflictFeedback('委托已提交'), false); +test('user can distinguish reduce-only order conflicts from unrelated failures', () => { + // Given reduce-only messages naming existing orders plus generic failure and success text. + const cases = [ + { read: isReduceOnlyOpenOrdersConflictFeedback, args: ['只减仓订单失败。请取消此币种的当前挂单,然后重试。'], expected: true }, + { read: isReduceOnlyOpenOrdersConflictFeedback, args: ['只减仓订单失败。如果您有该合约的未平仓头寸和挂单,请取消挂单后重试。如果您没有任何仓位,请取消只减仓选项后重试。'], expected: true }, + { read: isReduceOnlyOpenOrdersConflictFeedback, args: ['下单失败:余额不足'], expected: false }, + { read: isReduceOnlyOpenOrdersConflictFeedback, args: ['委托已提交'], expected: false }, + ]; + + // When the feedback is checked for a reduce-only open-order conflict. + const results = cases.map(({ read, args }) => read(...args)); + + // Then only messages containing both conflict semantics match. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('recognizes open ladder capacity failures only when feedback points to open orders', () => { - assert.equal(isOpenLadderOpenOrdersCapacityFeedback('可开数量不足,请取消当前挂单后重试'), true); - assert.equal(isOpenLadderOpenOrdersCapacityFeedback('Order failed: insufficient margin from existing open orders'), true); - assert.equal(isOpenLadderOpenOrdersCapacityFeedback('下单失败:余额不足'), false); - assert.equal(isOpenLadderOpenOrdersCapacityFeedback('可用余额不足'), false); - assert.equal(isOpenLadderOpenOrdersCapacityFeedback('可开数量不足'), false); - assert.equal(isOpenLadderOpenOrdersCapacityFeedback('Order failed: insufficient margin'), false); - assert.equal(isOpenLadderOpenOrdersCapacityFeedback('Order failed: not enough available balance'), false); - assert.equal(isOpenLadderOpenOrdersCapacityFeedback('只减仓订单失败。请取消此币种的当前挂单,然后重试。'), false); - assert.equal(isOpenLadderOpenOrdersCapacityFeedback('委托已提交'), false); +test('user cannot cancel orders from an insufficient-balance message alone', () => { + // Given capacity failures with and without an explicit open-order hint. + const cases = [ + { read: isOpenLadderOpenOrdersCapacityFeedback, args: ['可开数量不足,请取消当前挂单后重试'], expected: true }, + { read: isOpenLadderOpenOrdersCapacityFeedback, args: ['Order failed: insufficient margin from existing open orders'], expected: true }, + { read: isOpenLadderOpenOrdersCapacityFeedback, args: ['下单失败:余额不足'], expected: false }, + { read: isOpenLadderOpenOrdersCapacityFeedback, args: ['可用余额不足'], expected: false }, + { read: isOpenLadderOpenOrdersCapacityFeedback, args: ['可开数量不足'], expected: false }, + { read: isOpenLadderOpenOrdersCapacityFeedback, args: ['Order failed: insufficient margin'], expected: false }, + { read: isOpenLadderOpenOrdersCapacityFeedback, args: ['Order failed: not enough available balance'], expected: false }, + { read: isOpenLadderOpenOrdersCapacityFeedback, args: ['只减仓订单失败。请取消此币种的当前挂单,然后重试。'], expected: false }, + { read: isOpenLadderOpenOrdersCapacityFeedback, args: ['委托已提交'], expected: false }, + ]; + + // When the open-ladder conflict classifier evaluates the messages. + const results = cases.map(({ read, args }) => read(...args)); + + // Then only a capacity failure tied to open orders matches. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('reads Binance API error codes without depending on localized messages', () => { - assert.equal(getBinanceApiErrorCode({ code: -5022, msg: 'any text' }), -5022); - assert.equal(getBinanceApiErrorCode({ code: '-5022', message: '任意文案' }), -5022); - assert.equal(getBinanceApiErrorCode({ code: 90805022, message: '任意文案' }), 90805022); - assert.equal(getBinanceApiErrorCode({ code: '90805022', message: '任意文案' }), 90805022); - assert.equal(getBinanceApiErrorCode({ code: 0, success: true }), null); - assert.equal(getBinanceApiErrorCode({ code: '000000', success: true }), null); - assert.equal(getBinanceApiErrorCode({ code: -2019, msg: 'insufficient margin' }), -2019); - assert.equal(getBinanceApiErrorCode({ code: 1.5, msg: 'invalid numeric code' }), null); - assert.equal(getBinanceApiErrorCode({ code: '1.5', msg: 'invalid numeric code' }), null); - assert.equal(getBinanceApiErrorCode({ code: Number.MAX_SAFE_INTEGER + 1 }), null); - assert.equal(getBinanceApiErrorCode({ message: 'Post Only order rejected' }), null); - assert.equal(getBinanceApiErrorCode({ data: { code: -5022 } }), null); +test('user sees Binance error codes independently of the message language', () => { + // Given numeric, numeric-string, zero, malformed, unsafe, missing, and nested codes. + const cases = [ + { read: getBinanceApiErrorCode, args: [{ code: -5022, msg: 'any text' }], expected: -5022 }, + { read: getBinanceApiErrorCode, args: [{ code: '-5022', message: '任意文案' }], expected: -5022 }, + { read: getBinanceApiErrorCode, args: [{ code: 90805022, message: '任意文案' }], expected: 90805022 }, + { read: getBinanceApiErrorCode, args: [{ code: '90805022', message: '任意文案' }], expected: 90805022 }, + { read: getBinanceApiErrorCode, args: [{ code: 0, success: true }], expected: null }, + { read: getBinanceApiErrorCode, args: [{ code: '000000', success: true }], expected: null }, + { read: getBinanceApiErrorCode, args: [{ code: -2019, msg: 'insufficient margin' }], expected: -2019 }, + { read: getBinanceApiErrorCode, args: [{ code: 1.5, msg: 'invalid numeric code' }], expected: null }, + { read: getBinanceApiErrorCode, args: [{ code: '1.5', msg: 'invalid numeric code' }], expected: null }, + { read: getBinanceApiErrorCode, args: [{ code: Number.MAX_SAFE_INTEGER + 1 }], expected: null }, + { read: getBinanceApiErrorCode, args: [{ message: 'Post Only order rejected' }], expected: null }, + { read: getBinanceApiErrorCode, args: [{ data: { code: -5022 } }], expected: null }, + ]; + + // When the top-level response code is read. + const results = cases.map(({ read, args }) => read(...args)); + + // Then only exact safe nonzero integer codes are retained. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('recognizes only the verified Binance place-order success payload contract', () => { - assert.equal(isBinancePlaceOrderSuccessPayload({ code: 0, success: true }), true); - assert.equal(isBinancePlaceOrderSuccessPayload({ code: '000000', success: true }), true); - assert.equal(isBinancePlaceOrderSuccessPayload({ success: true, data: {} }), true); - - assert.equal(isBinancePlaceOrderSuccessPayload({ code: -5022, success: true }), false); - assert.equal(isBinancePlaceOrderSuccessPayload({ code: '90805022', success: true }), false); - assert.equal(isBinancePlaceOrderSuccessPayload({ code: 0 }), false); - assert.equal(isBinancePlaceOrderSuccessPayload({ success: false }), false); - assert.equal(isBinancePlaceOrderSuccessPayload({}), false); - assert.equal(isBinancePlaceOrderSuccessPayload([]), false); - assert.equal(isBinancePlaceOrderSuccessPayload(null), false); +test('user counts a submission only from the verified native success payload', () => { + // Given valid success payloads, conflicting codes, and incomplete response shapes. + const cases = [ + { read: isBinancePlaceOrderSuccessPayload, args: [{ code: 0, success: true }], expected: true }, + { read: isBinancePlaceOrderSuccessPayload, args: [{ code: '000000', success: true }], expected: true }, + { read: isBinancePlaceOrderSuccessPayload, args: [{ success: true, data: {} }], expected: true }, + { read: isBinancePlaceOrderSuccessPayload, args: [{ code: -5022, success: true }], expected: false }, + { read: isBinancePlaceOrderSuccessPayload, args: [{ code: '90805022', success: true }], expected: false }, + { read: isBinancePlaceOrderSuccessPayload, args: [{ code: 0 }], expected: false }, + { read: isBinancePlaceOrderSuccessPayload, args: [{ success: false }], expected: false }, + { read: isBinancePlaceOrderSuccessPayload, args: [{}], expected: false }, + { read: isBinancePlaceOrderSuccessPayload, args: [[]], expected: false }, + { read: isBinancePlaceOrderSuccessPayload, args: [null], expected: false }, + ]; + + // When the native response is checked for success. + const results = cases.map(({ read, args }) => read(...args)); + + // Then only explicit success without an error code counts. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('summarizes unknown place-order payloads without retaining order data values', () => { - assert.deepEqual(summarizeBinancePlaceOrderPayload({ - code: '000000', - success: false, - message: 'Request was throttled', - data: { - orderId: 123456789, - price: '0.16380', - quantity: '1', - }, - }), { - payloadType: 'object', - payloadKeys: ['code', 'data', 'message', 'success'], - dataKeys: ['orderId', 'price', 'quantity'], - success: false, - code: '000000', - message: 'Request was throttled', - }); +test('user can inspect an uncertain response without retaining submitted order values', () => { + // Given an uncertain payload with an order identifier, price, and quantity. + const cases = [ + { read: summarizeBinancePlaceOrderPayload, args: [{ + code: '000000', + success: false, + message: 'Request was throttled', + data: { + orderId: 123456789, + price: '0.16380', + quantity: '1', + }, + }], expected: { + payloadType: 'object', + payloadKeys: ['code', 'data', 'message', 'success'], + dataKeys: ['orderId', 'price', 'quantity'], + success: false, + code: '000000', + message: 'Request was throttled', + } }, + ]; + + // When a bounded response summary is constructed. + const results = cases.map(({ read, args }) => read(...args)); + + // Then only top-level evidence and data field names remain. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('formats rate-limit response evidence without exposing response data values', () => { - const detail = formatBinancePlaceOrderResponseDiagnostic({ +test('user sees rate-limit evidence without submitted order values', () => { + // Given rate-limit response headers and a non-JSON body classification. + const diagnostic = { httpStatus: 429, contentType: 'text/html; charset=utf-8', retryAfter: '2', @@ -209,81 +293,286 @@ test('formats rate-limit response evidence without exposing response data values bodyKind: 'non_json', payloadSummary: null, errorName: null, - }); + }; + + // When the bounded response evidence is formatted. + const detail = formatBinancePlaceOrderResponseDiagnostic(diagnostic); - assert.equal( - detail, - 'HTTP 429 · text/html; charset=utf-8 · Retry-After 2s · X-MBX-ORDER-COUNT-10S=301 · X-MBX-ORDER-COUNT-1M=801 · X-MBX-USED-WEIGHT-1M=2401 · non-JSON', - ); + // Then status, retry delay, and counter headers remain visible without order data. + assert.equal(detail, 'HTTP 429 · text/html; charset=utf-8 · Retry-After 2s · X-MBX-ORDER-COUNT-10S=301 · X-MBX-ORDER-COUNT-1M=801 · X-MBX-USED-WEIGHT-1M=2401 · non-JSON'); assert.doesNotMatch(detail, /orderId|0\.16380|123456789/); }); -test('formats unknown JSON response shape and parse failures', () => { - assert.equal(formatBinancePlaceOrderResponseDiagnostic({ - httpStatus: 200, - contentType: 'application/json', - retryAfter: null, - orderCount10s: null, - orderCount1m: null, - usedWeight1m: null, - bodyKind: 'json', - payloadSummary: summarizeBinancePlaceOrderPayload({ - code: '000000', - success: false, - message: 'Unknown result', - data: { orderId: 123456789 }, - }), - errorName: null, - }), 'HTTP 200 · application/json · success=false · code=000000 · message=Unknown result · keys=code,data,message,success · data.keys=orderId'); - - assert.equal(formatBinancePlaceOrderResponseDiagnostic({ - httpStatus: 502, - contentType: 'application/json', - retryAfter: null, - orderCount10s: null, - orderCount1m: null, - usedWeight1m: null, - bodyKind: 'invalid_json', - payloadSummary: null, - errorName: 'SyntaxError', - }), 'HTTP 502 · application/json · JSON parse error SyntaxError'); +test('user sees response shape or JSON parse failure in order diagnostics', () => { + // Given an unknown JSON result and an HTTP 502 JSON parse error. + const cases = [ + { read: formatBinancePlaceOrderResponseDiagnostic, args: [{ + httpStatus: 200, + contentType: 'application/json', + retryAfter: null, + orderCount10s: null, + orderCount1m: null, + usedWeight1m: null, + bodyKind: 'json', + payloadSummary: summarizeBinancePlaceOrderPayload({ + code: '000000', + success: false, + message: 'Unknown result', + data: { orderId: 123456789 }, + }), + errorName: null, + }], expected: 'HTTP 200 · application/json · success=false · code=000000 · message=Unknown result · keys=code,data,message,success · data.keys=orderId' }, + { read: formatBinancePlaceOrderResponseDiagnostic, args: [{ + httpStatus: 502, + contentType: 'application/json', + retryAfter: null, + orderCount10s: null, + orderCount1m: null, + usedWeight1m: null, + bodyKind: 'invalid_json', + payloadSummary: null, + errorName: 'SyntaxError', + }], expected: 'HTTP 502 · application/json · JSON parse error SyntaxError' }, + ]; + + // When the diagnostic evidence is formatted. + const results = cases.map(({ read, args }) => read(...args)); + + // Then the displayed text preserves each distinct failure and only data field names. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); +}); + +test('user recognizes only verified numeric Post Only rejection codes', () => { + // Given verified maker codes plus string, success, unrelated, and adjacent codes. + const cases = [ + { read: isBinancePostOnlyMakerRejectCode, args: [-5022], expected: true }, + { read: isBinancePostOnlyMakerRejectCode, args: [90805022], expected: true }, + { read: isBinancePostOnlyMakerRejectCode, args: ['90805022'], expected: false }, + { read: isBinancePostOnlyMakerRejectCode, args: [0], expected: false }, + { read: isBinancePostOnlyMakerRejectCode, args: [-2019], expected: false }, + { read: isBinancePostOnlyMakerRejectCode, args: [90805021], expected: false }, + ]; + + // When the maker code classifier evaluates each value. + const results = cases.map(({ read, args }) => read(...args)); + + // Then only the two verified numeric codes match. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); +}); + +test('user identifies the verified maximum-open-orders code', () => { + // Given the verified numeric capacity code and two nonmatching values. + const cases = [ + { read: isBinanceMaxOpenOrdersErrorCode, args: [90802025], expected: true }, + { read: isBinanceMaxOpenOrdersErrorCode, args: ['90802025'], expected: false }, + { read: isBinanceMaxOpenOrdersErrorCode, args: [90805022], expected: false }, + ]; + + // When the maximum-open-orders classifier evaluates the codes. + const results = cases.map(({ read, args }) => read(...args)); + + // Then only the observed numeric code matches. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('recognizes only verified Binance Post Only maker rejection codes', () => { - assert.equal(isBinancePostOnlyMakerRejectCode(-5022), true); - assert.equal(isBinancePostOnlyMakerRejectCode(90805022), true); - assert.equal(isBinancePostOnlyMakerRejectCode('90805022'), false); - assert.equal(isBinancePostOnlyMakerRejectCode(0), false); - assert.equal(isBinancePostOnlyMakerRejectCode(-2019), false); - assert.equal(isBinancePostOnlyMakerRejectCode(90805021), false); +test('user recognizes maker-execution rejections across localized message variants', () => { + // Given Chinese and English Post Only messages preserving maker conflict and rejection semantics. + const cases = [ + { read: isPostOnlyMakerRejectionFeedback, args: ['由于该只做Maker订单(Post Only)未作为Maker执行,因此将被拒绝。该订单不会记录在订单历史记录中。'], expected: true }, + { read: isPostOnlyMakerRejectionFeedback, args: ['只做 Maker 订单无法作为 Maker 成交,已被拒绝。'], expected: true }, + { read: isPostOnlyMakerRejectionFeedback, args: ['Due to the order could not be executed as maker, the Post Only order will be rejected.'], expected: true }, + { read: isPostOnlyMakerRejectionFeedback, args: ['The Post-Only order cannot execute as a maker and was rejected without being recorded.'], expected: true }, + ]; + + // When the feedback is classified by those semantics. + const results = cases.map(({ read, args }) => read(...args)); + + // Then each verified localized variant is recognized. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('recognizes the live Binance maximum open-orders rejection code', () => { - assert.equal(isBinanceMaxOpenOrdersErrorCode(90802025), true); - assert.equal(isBinanceMaxOpenOrdersErrorCode('90802025'), false); - assert.equal(isBinanceMaxOpenOrdersErrorCode(90805022), false); +test('user cannot infer a maker-price conflict from an incomplete rejection message', () => { + // Given generic, FOK, reduce-only, and incomplete Post Only rejection messages. + const cases = [ + { read: isPostOnlyMakerRejectionFeedback, args: ['Order rejected'], expected: false }, + { read: isPostOnlyMakerRejectionFeedback, args: ['只做Maker (Post Only) 状态丢失'], expected: false }, + { read: isPostOnlyMakerRejectionFeedback, args: ['Post Only order rejected'], expected: false }, + { read: isPostOnlyMakerRejectionFeedback, args: ['订单未作为Maker执行,因此将被拒绝'], expected: false }, + { read: isPostOnlyMakerRejectionFeedback, args: ['FOK order could not be filled immediately and was rejected'], expected: false }, + { read: isPostOnlyMakerRejectionFeedback, args: ['只减仓订单失败,请取消当前挂单后重试'], expected: false }, + ]; + + // When maker-conflict evidence is evaluated. + const results = cases.map(({ read, args }) => read(...args)); + + // Then none of the incomplete or unrelated messages authorizes that classification. + cases.forEach(({ expected }, index) => assert.deepEqual(results[index], expected)); }); -test('recognizes Post Only maker-execution rejection feedback without exact-message matching', () => { - assert.equal(isPostOnlyMakerRejectionFeedback( - '由于该只做Maker订单(Post Only)未作为Maker执行,因此将被拒绝。该订单不会记录在订单历史记录中。' - ), true); - assert.equal(isPostOnlyMakerRejectionFeedback( - '只做 Maker 订单无法作为 Maker 成交,已被拒绝。' - ), true); - assert.equal(isPostOnlyMakerRejectionFeedback( - 'Due to the order could not be executed as maker, the Post Only order will be rejected.' - ), true); - assert.equal(isPostOnlyMakerRejectionFeedback( - 'The Post-Only order cannot execute as a maker and was rejected without being recorded.' - ), true); +test('user cannot infer an order result or recovery from absent feedback', () => { + // Given empty feedback and unrelated page text before a new order response arrives. + const texts = ['', null]; + + // When the feedback classifiers inspect that evidence. + const results = texts.map((text) => ({ + potential: isPotentialOrderFeedbackText(text), + kind: classifyOrderFeedback(text), + reduceOnly: isReduceOnlyOpenOrdersConflictFeedback(text), + capacity: isOpenLadderOpenOrdersCapacityFeedback(text), + maker: isPostOnlyMakerRejectionFeedback(text), + })); + const potentialMessages = ['Order rejected', '委托已提交', 'Settings saved'].map(isPotentialOrderFeedbackText); + + // Then empty evidence stays absent and unrelated text is not an order result. + results.forEach((result) => assert.deepEqual(result, { + potential: false, kind: 'none', reduceOnly: false, capacity: false, maker: false, + })); + assert.deepEqual(potentialMessages, [true, true, false]); +}); + +for (const { name, retryAfter, expectedMs } of [ + { name: 'zero', retryAfter: '0', expectedMs: 0 }, + { name: 'fractional seconds', retryAfter: '0.25', expectedMs: 250 }, + { name: 'missing', retryAfter: null, expectedMs: 10000 }, + { name: 'empty', retryAfter: '', expectedMs: 10000 }, + { name: 'negative', retryAfter: '-1', expectedMs: 10000 }, + { name: 'non-numeric', retryAfter: 'not-a-delay', expectedMs: 10000 }, +]) { + test(`user applies the defined rate-limit wait when Retry-After is ${name}`, () => { + // Given a blocked HTTP 418 response and its observed Retry-After header. + const diagnostics = [{ httpStatus: 418, retryAfter }]; + + // When response recovery resolves the rate-limit delay. + const recovery = resolveBinanceSubmitResponseRecovery(diagnostics, []); + + // Then valid nonnegative seconds are honored and invalid evidence uses the rate-limit policy. + assert.deepEqual(recovery, { kind: 'rate_limited', cooldownMs: expectedMs }); + }); +} + +test('user keeps rate-limit precedence when the same submission also reports server uncertainty', () => { + // Given a server response and an explicit Binance rate-limit code in one observation. + const diagnostics = [{ httpStatus: 503 }]; + const errors = [{ code: -1003 }]; + + // When response recovery classifies the combined evidence. + const recovery = resolveBinanceSubmitResponseRecovery(diagnostics, errors); + + // Then the longer rate-limit cooldown governs the next action. + assert.deepEqual(recovery, { kind: 'rate_limited', cooldownMs: 10000 }); }); -test('does not infer maker-price conflicts from generic or unrelated rejection feedback', () => { - assert.equal(isPostOnlyMakerRejectionFeedback('Order rejected'), false); - assert.equal(isPostOnlyMakerRejectionFeedback('只做Maker (Post Only) 状态丢失'), false); - assert.equal(isPostOnlyMakerRejectionFeedback('Post Only order rejected'), false); - assert.equal(isPostOnlyMakerRejectionFeedback('订单未作为Maker执行,因此将被拒绝'), false); - assert.equal(isPostOnlyMakerRejectionFeedback('FOK order could not be filled immediately and was rejected'), false); - assert.equal(isPostOnlyMakerRejectionFeedback('只减仓订单失败,请取消当前挂单后重试'), false); +for (const { name, diagnostics, errors } of [ + { name: 'diagnostics are unavailable', diagnostics: null, errors: [] }, + { name: 'API errors are unavailable', diagnostics: [], errors: null }, +]) { + test(`user gets an explicit contract error when ${name}`, () => { + // Given incomplete response evidence rather than a confirmed rejection. + const evidence = { diagnostics, errors }; + + // When recovery classification is requested with that incomplete contract. + const classify = () => resolveBinanceSubmitResponseRecovery(evidence.diagnostics, evidence.errors); + + // Then recovery fails explicitly rather than inventing an outcome. + assert.throws(classify, /下单响应恢复证据无效/); + }); +} + +test('user does not read error codes from primitive, array, or unsafe response values', () => { + // Given non-object payloads and a numeric string outside the safe integer range. + const payloads = [null, [], '90802022', { code: '9007199254740992' }]; + + // When the top-level Binance error-code contract is evaluated. + const codes = payloads.map(getBinanceApiErrorCode); + + // Then malformed response shapes and unsafe integers remain unknown. + assert.deepEqual(codes, [null, null, null, null]); }); + +for (const { name, payload, expectedType } of [ + { name: 'array', payload: [], expectedType: 'array' }, + { name: 'null', payload: null, expectedType: 'null' }, + { name: 'text', payload: 'Unavailable', expectedType: 'string' }, + { name: 'numeric', payload: 503, expectedType: 'number' }, +]) { + test(`user sees the ${name} payload type without fabricated JSON fields`, () => { + // Given a response body that is not an object matching the native order contract. + const body = payload; + + // When a response summary is created. + const summary = summarizeBinancePlaceOrderPayload(body); + + // Then only the actual payload type is retained. + assert.deepEqual(summary, { + payloadType: expectedType, payloadKeys: [], dataKeys: [], success: null, code: null, message: null, + }); + }); +} + +test('user gets a bounded normalized error message without nested response values', () => { + // Given nested diagnostic fields, array data, and a long fallback message. + const payload = { success: {}, code: [], data: [{ orderId: 123 }], msg: ' ' + 'failure\n'.repeat(30) }; + + // When the response is summarized for display. + const summary = summarizeBinancePlaceOrderPayload(payload); + + // Then nested values are omitted and whitespace-normalized text is capped at 160 characters. + assert.deepEqual(summary, { + payloadType: 'object', payloadKeys: ['code', 'data', 'msg', 'success'], dataKeys: [], + success: null, code: null, message: 'failure '.repeat(30).trim().slice(0, 160), + }); +}); + +test('user sees no diagnostic message for blank or non-text response messages', () => { + // Given an empty object and responses with blank or non-text message fields. + const payloads = [{}, { message: ' \n ' }, { message: 123 }]; + + // When those response summaries are constructed. + const summaries = payloads.map(summarizeBinancePlaceOrderPayload); + + // Then no message is invented and only observed field names remain. + assert.deepEqual(summaries, [ + { payloadType: 'object', payloadKeys: [], dataKeys: [], success: null, code: null, message: null }, + { payloadType: 'object', payloadKeys: ['message'], dataKeys: [], success: null, code: null, message: null }, + { payloadType: 'object', payloadKeys: ['message'], dataKeys: [], success: null, code: null, message: null }, + ]); +}); + +for (const { name, diagnostic, expected } of [ + { name: 'network failure', diagnostic: { bodyKind: 'network_error', errorName: 'TypeError' }, expected: 'network error TypeError' }, + { name: 'network failure without a named error', diagnostic: { bodyKind: 'network_error' }, expected: 'network error' }, + { name: 'observer failure', diagnostic: { bodyKind: 'observation_error', errorName: 'Error' }, expected: 'response observer error Error' }, + { name: 'observer failure without a named error', diagnostic: { bodyKind: 'observation_error' }, expected: 'response observer error' }, + { name: 'unnamed JSON parse failure', diagnostic: { bodyKind: 'invalid_json' }, expected: 'JSON parse error' }, + { name: 'date-valued retry header', diagnostic: { bodyKind: 'non_json', retryAfter: 'Wed, 16 Sep 2026 00:00:00 GMT' }, expected: 'Retry-After Wed, 16 Sep 2026 00:00:00 GMT · non-JSON' }, + { name: 'empty retry header', diagnostic: { bodyKind: 'non_json', retryAfter: '' }, expected: 'non-JSON' }, + { name: 'array JSON response', diagnostic: { bodyKind: 'json', payloadSummary: summarizeBinancePlaceOrderPayload([]) }, expected: 'JSON type=array' }, + { name: 'empty object JSON response', diagnostic: { bodyKind: 'json', payloadSummary: summarizeBinancePlaceOrderPayload({}) }, expected: 'JSON type=object' }, +]) { + test(`user sees precise diagnostics for ${name}`, () => { + // Given the observed body classification and any available response metadata. + const evidence = { ...diagnostic }; + + // When order-response evidence is rendered. + const detail = formatBinancePlaceOrderResponseDiagnostic(evidence); + + // Then the displayed reason preserves the failure without inventing missing fields. + assert.equal(detail, expected); + }); +} + +for (const { name, diagnostic, expectedError } of [ + { name: 'a JSON summary is missing', diagnostic: { bodyKind: 'json' }, expectedError: /下单 JSON 响应摘要缺失/ }, + { name: 'the body classification is unknown', diagnostic: { bodyKind: 'unknown' }, expectedError: /未知下单响应类型:unknown/ }, +]) { + test(`user gets an explicit diagnostic error when ${name}`, () => { + // Given a response diagnostic that violates the required body contract. + const evidence = { ...diagnostic }; + + // When the invalid diagnostic is formatted. + const format = () => formatBinancePlaceOrderResponseDiagnostic(evidence); + + // Then the missing contract is reported directly. + assert.throws(format, expectedError); + }); +} diff --git a/test/unit/binance-orderbook-trade/quantity.test.js b/test/unit/binance-orderbook-trade/quantity.test.js index 104440f..1dbf216 100644 --- a/test/unit/binance-orderbook-trade/quantity.test.js +++ b/test/unit/binance-orderbook-trade/quantity.test.js @@ -9,40 +9,125 @@ import { isPositiveDecimalString, } from '../../../src/binance-orderbook-trade/core/quantity.js'; -test('converts decimal quantities to exchange step counts', () => { - assert.equal(decimalToStepCount('1.29', '0.1', 'floor'), 12n); - assert.equal(decimalToStepCount('1.21', '0.1', 'ceil'), 13n); - assert.equal(formatStepCount(13n, '0.1'), '1.3'); - assert.equal(formatStepCount(2500n, '0.001'), '2.5'); -}); +for (const { name, value, step, rounding, expected } of [ + { name: 'rounds a submitted quantity down to the exchange step', value: '1.29', step: '0.1', rounding: 'floor', expected: 12n }, + { name: 'rounds a minimum quantity up to the exchange step', value: '1.21', step: '0.1', rounding: 'ceil', expected: 13n }, + { name: 'uses floor rounding when no rounding mode is supplied', value: '1.29', step: '0.1', expected: 12n }, + { name: 'preserves an exact minimum without adding another step', value: '1.2', step: '0.1', rounding: 'ceil', expected: 12n }, + { name: 'keeps fine steps exact for an integer quantity', value: '2', step: '0.001', expected: 2000n }, + { name: 'keeps a sub-step quantity at zero for order sizing', value: '0.0009', step: '0.001', expected: 0n }, + { name: 'rejects an unread quantity', value: null, step: '0.1', expected: null }, + { name: 'rejects a missing exchange step', value: '1', step: null, expected: null }, + { name: 'rejects a zero exchange step', value: '1', step: '0', expected: null }, +]) { + test(`user ${name}`, () => { + // Given the requested quantity and the symbol's exchange step. + const input = { value, step, rounding }; + + // When the quantity is converted to an integer number of tradable steps. + const count = decimalToStepCount(input.value, input.step, input.rounding); -test('allocates exact ladder quantity splits', () => { - assert.deepEqual(allocateLadderQuantities('1.0', 5, '0.1', '0.1'), { - requestedLevels: 5, - actualLevels: 5, - totalQty: '1', - quantities: ['0.2', '0.2', '0.2', '0.2', '0.2'], + // Then the exact count respects the requested rounding or rejects invalid evidence. + assert.equal(count, expected); }); -}); +} + +for (const { name, count, step, expected } of [ + { name: 'formats thirteen tenths without trailing zeros', count: 13n, step: '0.1', expected: '1.3' }, + { name: 'formats two thousand five hundred fine steps exactly', count: 2500n, step: '0.001', expected: '2.5' }, + { name: 'formats an empty position as zero', count: 0n, step: '0.001', expected: '0' }, + { name: 'rejects a missing step count', count: null, step: '0.1', expected: null }, + { name: 'rejects a negative step count', count: -1n, step: '0.1', expected: null }, + { name: 'rejects an invalid formatting step', count: 1n, step: 'bad', expected: null }, + { name: 'rejects a zero formatting step', count: 1n, step: '0', expected: null }, +]) { + test(`user ${name}`, () => { + // Given an allocated number of steps and its exchange step size. + const input = { count, step }; -test('reduces ladder level count when total quantity cannot satisfy desired levels', () => { - assert.deepEqual(allocateLadderQuantities('0.3', 5, '0.1', '0.1'), { - requestedLevels: 5, - actualLevels: 3, - totalQty: '0.3', - quantities: ['0.1', '0.1', '0.1'], + // When the order quantity is formatted for submission. + const quantity = formatStepCount(input.count, input.step); + + // Then the quantity stays exact and invalid counts are refused. + assert.equal(quantity, expected); }); -}); +} + +for (const { name, total, levels, step, minimum, expected } of [ + { + name: 'splits an exactly divisible quantity across all requested levels', + total: '1.0', levels: 5, step: '0.1', minimum: '0.1', + expected: { requestedLevels: 5, actualLevels: 5, totalQty: '1', quantities: ['0.2', '0.2', '0.2', '0.2', '0.2'] }, + }, + { + name: 'gets fewer levels when the quantity only funds three minimum orders', + total: '0.3', levels: 5, step: '0.1', minimum: '0.1', + expected: { requestedLevels: 5, actualLevels: 3, totalQty: '0.3', quantities: ['0.1', '0.1', '0.1'] }, + }, + { + name: 'keeps remaining exchange steps in the final ladder order', + total: '1.09', levels: 3, step: '0.1', minimum: '0.21', + expected: { requestedLevels: 3, actualLevels: 3, totalQty: '1', quantities: ['0.3', '0.3', '0.4'] }, + }, + { name: 'cannot create even one order below the minimum', total: '0.09', levels: 3, step: '0.01', minimum: '0.1', expected: null }, + { name: 'cannot allocate orders with a zero exchange step', total: '1', levels: 3, step: '0', minimum: '0.1', expected: null }, + { name: 'cannot allocate a sub-step total quantity', total: '0.09', levels: 3, step: '0.1', minimum: '0.1', expected: null }, + { name: 'cannot allocate orders without a valid minimum quantity', total: '1', levels: 3, step: '0.1', minimum: 'bad', expected: null }, + { name: 'cannot allocate orders against a zero minimum quantity', total: '1', levels: 3, step: '0.1', minimum: '0', expected: null }, + { name: 'cannot allocate a ladder with zero requested levels', total: '1', levels: 0, step: '0.1', minimum: '0.1', expected: null }, +]) { + test(`user ${name}`, () => { + // Given the total quantity, requested levels, and symbol-specific order limits. + const input = { total, levels, step, minimum }; -test('returns null when even one ladder level cannot satisfy minimum quantity', () => { - assert.equal(allocateLadderQuantities('0.09', 3, '0.01', '0.1'), null); - assert.equal(allocateLadderQuantities('1', 3, '0', '0.1'), null); + // When the ladder divides the quantity into executable orders. + const allocation = allocateLadderQuantities(input.total, input.levels, input.step, input.minimum); + + // Then every reported quantity and the actual number of orders match the limits. + assert.deepEqual(allocation, expected); + }); +} + +test('user can distinguish positive quantities and exact minimum boundaries', () => { + // Given valid, empty, and malformed quantities around the exchange minimum. + const quantities = ['0.001', '0', 'bad']; + const candidates = ['1.20', '1.19']; + + // When quantity validity and the minimum boundary are evaluated. + const positive = quantities.map(isPositiveDecimalString); + const atLeastMinimum = candidates.map((value) => isDecimalAtLeast(value, '1.2')); + + // Then equality meets the minimum while zero and malformed quantities stay invalid. + assert.deepEqual(positive, [true, false, false]); + assert.deepEqual(atLeastMinimum, [true, false]); }); -test('checks decimal positivity and minimum thresholds', () => { - assert.equal(isPositiveDecimalString('0.001'), true); - assert.equal(isPositiveDecimalString('0'), false); - assert.equal(isPositiveDecimalString('bad'), false); - assert.equal(isDecimalAtLeast('1.20', '1.2'), true); - assert.equal(isDecimalAtLeast('1.19', '1.2'), false); +test('user keeps every tradable step while all ladder orders meet the minimum', () => { + // Given small and uneven totals, exchange minimums, and configured or reduced ladder sizes. + const cases = []; + for (const total of [1, 2, 3, 5, 10, 19, 50, 101]) { + for (const minimum of [1, 2, 3, 7, 20]) { + for (const levels of [1, 2, 3, 5, 7, 9]) cases.push({ total, minimum, levels }); + } + } + + // When real quantity allocation builds each plan on a one-hundredth exchange step. + const results = cases.map((input) => ({ input, allocation: allocateLadderQuantities( + (input.total / 100).toFixed(2), input.levels, '0.01', (input.minimum / 100).toFixed(2), + ) })); + + // Then insufficient funds produce no plan and every executable plan conserves steps without undersized orders. + for (const { input, allocation } of results) { + const label = JSON.stringify(input); + if (input.total < input.minimum) { + assert.equal(allocation, null, label); + continue; + } + const steps = allocation.quantities.map((quantity) => decimalToStepCount(quantity, '0.01')); + assert.equal(allocation.actualLevels, Math.min(input.levels, Math.floor(input.total / input.minimum)), label); + assert.equal(allocation.quantities.length, allocation.actualLevels, label); + assert.equal(steps.reduce((sum, count) => sum + count, 0n), BigInt(input.total), label); + assert.deepEqual(steps.filter((count) => count < BigInt(input.minimum)), [], label); + assert.equal(decimalToStepCount(allocation.totalQty, '0.01'), BigInt(input.total), label); + } }); diff --git a/test/unit/binance-orderbook-trade/smoke.test.js b/test/unit/binance-orderbook-trade/smoke.test.js deleted file mode 100644 index d2663ae..0000000 --- a/test/unit/binance-orderbook-trade/smoke.test.js +++ /dev/null @@ -1,6 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; - -test('test runner is wired', () => { - assert.equal(1 + 1, 2); -}); diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index af3ede0..32f98f5 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -72,13 +72,6 @@ test('close snapshot validation refreshes button scope before checking close act assert.match(waitBody, /closeQuantityChanged[\s\S]*findCloseLongButton\(\)[\s\S]*findCloseShortButton\(\)[\s\S]*snapshotReady = true/); }); -test('cancel-symbol flow restores temporary symbol filter through cleanup path', () => { - const cancelBody = readFunctionBody('runCancelCurrentSymbolOpenOrders'); - assert.match(cancelBody, /finally\s*\{/); - assert.match(cancelBody, /await waitForBinanceCancelAllDialogDecision\(/); - assert.match(cancelBody, /restoreOpenOrdersSymbolFilter\(openOrdersScope,\s*symbolFilterOriginalChecked,\s*symbol\)/); -}); - test('fixed ladder panel avoids rebuilding unchanged body markup', () => { const ladderBody = readFunctionBody('refreshLadderPanel'); assert.match(ladderBody, /ladderPanelBodySignature/); diff --git a/test/unit/coverage-capture.test.js b/test/unit/coverage-capture.test.js new file mode 100644 index 0000000..11f7eba --- /dev/null +++ b/test/unit/coverage-capture.test.js @@ -0,0 +1,81 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { verifyCaptures } from '../../scripts/test-coverage/capture-contract.mjs'; + +function captureEvidence() { + return { + expectedNodeTests: ['test/unit/example.test.js'], + capturedNodeTests: new Set(['test/unit/example.test.js']), + browserManifest: { + status: 'passed', + tests: [{ id: 'production-1', file: 'e2e/binance-orderbook/specs/example.pw.js', + title: 'user sees accepted orders', result: { status: 'passed', retry: 0 } }], + }, + capturedBrowserTests: new Set(['production-1']), + }; +} + +test('user cannot receive a complete report when a Node process missed collection', () => { + // Given one selected Node process has no coverage capture. + const evidence = captureEvidence(); + evidence.capturedNodeTests.clear(); + + // When report completeness is verified. + const verify = () => verifyCaptures(evidence); + + // Then test completion alone cannot produce a complete-coverage result. + assert.throws(verify, /Every selected Node test process/); +}); + +test('user cannot mistake a passed browser scenario for a completed coverage capture', () => { + // Given the scenario passed but its coverage fixture did not write a capture. + const evidence = captureEvidence(); + evidence.capturedBrowserTests.clear(); + + // When report completeness is verified. + const verify = () => verifyCaptures(evidence); + + // Then the absent scenario invalidates the coverage report. + assert.throws(verify, /Every production browser scenario/); +}); + +test('user can run the collector self-test without crediting its virtual code to production', () => { + // Given production was captured and an extra collector test executed virtual source. + const evidence = captureEvidence(); + evidence.browserManifest.tests.push({ id: 'collector-proof', + file: 'e2e/binance-orderbook/specs/coverage-merge.pw.js', title: 'user gets a branch union', + result: { status: 'passed', retry: 0 } }); + + // When the report verifies all production captures and the separate self-test outcome. + const result = verifyCaptures(evidence); + + // Then completeness succeeds without a synthetic production capture. + assert.deepEqual(result, { nodeFiles: 1, browserScenarios: 1, collectorScenarios: 1 }); + assert.deepEqual([...evidence.capturedBrowserTests], ['production-1']); +}); + +for (const status of ['failed', 'skipped', 'timedOut']) { + test(`user cannot get a complete report after a browser scenario was ${status}`, () => { + // Given an otherwise complete capture set contains an unsuccessful scenario. + const evidence = captureEvidence(); + evidence.browserManifest.tests[0].result.status = status; + + // When the report verifies the scenario outcome. + const verify = () => verifyCaptures(evidence); + + // Then even an existing capture cannot turn the run into passing evidence. + assert.throws(verify, /Every selected browser test must pass/); + }); +} + +test('user cannot merge stale browser captures into a fresh run', () => { + // Given current captures are mixed with a test ID from an older run. + const evidence = captureEvidence(); + evidence.capturedBrowserTests.add('previous-run'); + + // When capture ownership is verified against the current browser manifest. + const verify = () => verifyCaptures(evidence); + + // Then stale captures are rejected even though no current scenario is missing. + assert.throws(verify, /current browser run/); +}); diff --git a/test/unit/coverage-gates.test.js b/test/unit/coverage-gates.test.js new file mode 100644 index 0000000..81a85c6 --- /dev/null +++ b/test/unit/coverage-gates.test.js @@ -0,0 +1,97 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { assessBranchCoverage } from '../../scripts/test-coverage/gates.mjs'; + +const critical = 'src/binance-orderbook-trade/core/cancel-orders.js'; +const policy = { minimumBranches: 80, criticalSources: [critical] }; + +function measuredCoverage() { + return { + layers: ['node', 'browser'], + summary: { branches: { total: 1000, covered: 850, pct: 85 } }, + files: [{ path: critical, summary: { branches: { total: 100, covered: 92, pct: 92 } } }], + }; +} + +test('user sees a passed staged gate separately from the unmet repository target', () => { + // Given complete merged coverage meets the staged floor and critical-module threshold. + const coverage = measuredCoverage(); + + // When the staged policy assesses the measured counts. + const result = assessBranchCoverage(coverage, policy); + + // Then passing the current gate does not claim that all production branches reached 90 percent. + assert.equal(result.passed, true); + assert.equal(result.targetMet, false); + assert.equal(result.measured, 85); + assert.deepEqual(result.critical, [{ path: critical, percentage: 92, passed: true }]); + assert.deepEqual(result.failures, []); +}); + +test('user gets a failing gate when global coverage falls below its staged floor', () => { + // Given production coverage has fallen below the checked-in threshold. + const coverage = measuredCoverage(); + coverage.summary.branches.covered = 790; + + // When coverage is checked with the same explicit policy. + const result = assessBranchCoverage(coverage, policy); + + // Then the global deficit fails even though the critical module still passes. + assert.equal(result.passed, false); + assert.deepEqual(result.failures, ['All production sources: 79.00% is below the staged 80% threshold']); +}); + +test('user cannot hide a critical-module regression behind high aggregate coverage', () => { + // Given global coverage is high but a critical module has lost a branch scenario. + const coverage = measuredCoverage(); + coverage.summary.branches.covered = 960; + coverage.files[0].summary.branches.covered = 89; + + // When the critical-source policy is applied. + const result = assessBranchCoverage(coverage, policy); + + // Then the named critical module fails independently of the aggregate. + assert.equal(result.passed, false); + assert.equal(result.targetMet, true); + assert.deepEqual(result.failures, [critical + ': 89.00% is below 90%']); +}); + +test('user can require the final target without accepting a rounded-up display percentage', () => { + // Given an exact branch ratio is below 90 percent although its display rounded to 90. + const coverage = measuredCoverage(); + coverage.summary.branches = { covered: 89999, total: 100000, pct: 90 }; + + // When the final target is required rather than only the staged threshold. + const result = assessBranchCoverage(coverage, policy, { requireTarget: true }); + + // Then the exact counts keep the target unmet and fail the strict run. + assert.equal(result.passed, false); + assert.equal(result.targetMet, false); + assert.equal(result.failures.length, 1); + assert.match(result.failures[0], /final 90% target/); +}); + +test('user cannot substitute a Node-only report for merged coverage', () => { + // Given every reported Node branch is covered but no browser layer was collected. + const coverage = measuredCoverage(); + coverage.layers = ['node']; + coverage.summary.branches.covered = 1000; + + // When a caller offers that partial report to the repository gate. + const assess = () => assessBranchCoverage(coverage, policy); + + // Then the layer mismatch is rejected before any threshold can pass. + assert.throws(assess, /both Node and browser/); +}); + +test('user cannot drop a critical source from the denominator to pass the gate', () => { + // Given a report has aggregate metrics but omits a required critical source. + const coverage = measuredCoverage(); + coverage.files = []; + + // When the report is assessed against the explicit source policy. + const assess = () => assessBranchCoverage(coverage, policy); + + // Then the incomplete source set is rejected rather than treated as fully covered. + assert.throws(assess, /Missing or duplicate critical coverage source/); +}); diff --git a/test/unit/coverage-report.test.js b/test/unit/coverage-report.test.js new file mode 100644 index 0000000..5612c7a --- /dev/null +++ b/test/unit/coverage-report.test.js @@ -0,0 +1,88 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { parse } from 'acorn'; +import { decode, encode } from '@jridgewell/sourcemap-codec'; +import { ROOT, productionSourceFiles } from '../../scripts/test-coverage/config.mjs'; +import { + createSourceRegistry, + mapCoverageEntry, +} from '../../scripts/test-coverage/source-maps.mjs'; + +const registry = await createSourceRegistry(); + +test('user gets coverage maps for the exact install artifacts and complete original sources', async () => { + // Given the six generated installers and two hand-maintained installers. + const paths = registry.artifacts.map((artifact) => artifact.path).sort(); + const expected = [ + 'auto_refresh', 'binance-coinmarketcap-data', 'binance-orderbook-trade', + 'binance-strategy27-events', 'binance-strategy29-bollinger', 'binance-trading-data', + 'coinmarketcap-valuation-helper', 'm3u8-downloader', + ].map((name) => 'scripts/' + name + '.user.js').sort(); + // When the coverage compiler produces its maps without changing executable bytes. + const entries = await Promise.all(registry.artifacts.map(async (artifact) => ({ + artifact, installed: await readFile(resolve(ROOT, artifact.path), 'utf8'), + }))); + // Then every installer remains exact and each original source includes its real header and line positions. + assert.deepEqual(paths, expected); + for (const { artifact, installed } of entries) { + assert.equal(artifact.code, installed); + for (const [index, path] of artifact.map.sources.entries()) { + assert.equal(artifact.map.sourcesContent[index], registry.sources.get(path), path); + } + } + assert.equal(registry.sources.size, (await productionSourceFiles()).length); +}); + +test('user sees shared originals once when generated scripts execute before and after each other', () => { + // Given two actual generated scripts within a sandbox prefix and suffix. + const orderbook = registry.artifacts.find((artifact) => artifact.path.endsWith('/binance-orderbook-trade.user.js')); + const signals = registry.artifacts.find((artifact) => artifact.path.endsWith('/binance-strategy29-bollinger.user.js')); + const source = 'const fixtureBefore = true;\n' + orderbook.code + '\n{\n' + signals.code + '}\n'; + // When one browser script's coverage is mapped without changing its collected offsets. + const functions = [{ functionName: '', isBlockCoverage: true, ranges: [{ startOffset: 0, endOffset: source.length, count: 1 }] }]; + const mapped = mapCoverageEntry({ url: 'https://www.binance.com/__binance_orderbook_userscript__.js', source, functions }, registry); + // Then both installers use one canonical entry for their shared chart controller. + const shared = resolve(ROOT, 'src/shared/chart-marker-save-controller.js'); + assert.equal(mapped.sourceMap.sources.filter((path) => path === shared).length, 1); + assert.equal(mapped.source, source); + assert.equal(mapped.functions, functions); + const lines = decode(mapped.sourceMap.mappings); + assert.deepEqual(lines[0], []); + assert.equal(mapped.sourceMap.sourcesContent[mapped.sourceMap.sources.indexOf(shared)], + registry.sources.get('src/shared/chart-marker-save-controller.js')); +}); + +test('user gets no execution credit from a quoted installer or an extracted source fragment', () => { + // Given installer bytes that occur only as a string value, with the same file path as real code. + const code = '(() => { if (globalThis.flag) globalThis.result = 1; })();\n'; + const path = 'src/shared/proof.js'; + const proofRegistry = { + sources: new Map([[path, code]]), + artifacts: [{ + path: 'scripts/proof.user.js', code, + body: parse(code, { ecmaVersion: 'latest', sourceType: 'module' }).body, + map: { version: 3, sources: [path], sourcesContent: [code], names: [], mappings: encode([[[0, 0, 0, 0]]]) }, + }], + }; + const quoted = 'const data = ' + String.fromCharCode(96) + code + String.fromCharCode(96) + ';'; + // When quoted data and partial functions are offered as coverage of the original source. + const quotedResult = mapCoverageEntry({ url: path, source: quoted, functions: [] }, proofRegistry); + const fragmentResult = mapCoverageEntry({ url: path, source: 'if (globalThis.flag) globalThis.result = 1;', functions: [] }, proofRegistry); + // Then neither input is attributed to the original production file. + assert.equal(quotedResult, null); + assert.equal(fragmentResult, null); +}); + +test('user can attribute anonymous VM execution only when the complete original source matches', () => { + // Given the complete auto-refresh installer and an anonymous VM script URL. + const path = 'scripts/auto_refresh.user.js'; + const source = registry.sources.get(path); + const entry = { url: 'evalmachine.', source, functions: [] }; + // When coverage identifies the anonymous script by its exact original bytes. + const mapped = mapCoverageEntry(entry, registry); + // Then coverage names the real installer without substituting any executed text. + assert.equal(mapped.url, new URL('../../scripts/auto_refresh.user.js', import.meta.url).href); + assert.equal(mapped.source, source); +}); diff --git a/test/unit/coverage-split.test.js b/test/unit/coverage-split.test.js new file mode 100644 index 0000000..07a3e61 --- /dev/null +++ b/test/unit/coverage-split.test.js @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { parse } from 'acorn'; + +import { findArtifactSegments, splitCoverageEntry } from '../../scripts/test-coverage/split-entries.mjs'; + +function artifact(code, path = 'scripts/proof.user.js') { + return { path, code, body: parse(code, { ecmaVersion: 'latest', sourceType: 'module' }).body }; +} + +function captured(source, functions) { + return { url: 'https://fixture.invalid/composed.js', source, functions }; +} + +function fn(functionName, ranges) { + return { functionName, isBlockCoverage: true, ranges }; +} + +function range(startOffset, endOffset, count) { + return { startOffset, endOffset, count }; +} + +test('user gets separate exact artifacts without crediting their surrounding fixture code', () => { + // Given two complete artifacts between unrelated prefix and suffix statements + const first = artifact('const first = 1;\n', 'scripts/first.user.js'); + const second = artifact('const second = 2;\n', 'scripts/second.user.js'); + const source = 'const prefix = 0;\n' + first.code + second.code + 'const suffix = 3;'; + const entry = captured(source, [fn('', [range(0, source.length, 1)])]); + // When coverage is split at validated artifact statement boundaries + const result = splitCoverageEntry(entry, { artifacts: [first, second] }); + // Then each entry has original artifact bytes and one independently rebased script range + assert.deepEqual(result.map(({ source: code }) => code), [first.code, second.code]); + assert.deepEqual(result.map(({ functions }) => functions[0].ranges), [ + [range(0, first.code.length, 1)], [range(0, second.code.length, 1)], + ]); + assert.deepEqual(result.map(({ artifactSegment }) => artifactSegment.offset), [ + source.indexOf(first.code), source.indexOf(second.code), + ]); + assert.equal(entry.source, source); + assert.deepEqual(entry.functions, [fn('', [range(0, source.length, 1)])]); +}); + +test('user preserves internal function and branch counts while rebasing their real offsets', () => { + // Given a function with one unexecuted return inside a prefixed artifact + const item = artifact('function choose(flag) { if (flag) return 1; return 2; }\n'); + const source = 'const prefix = 0;\n' + item.code; + const offset = source.indexOf(item.code); + const returnStart = item.code.indexOf('return 1;'); + const entry = captured(source, [ + fn('', [range(0, source.length, 1)]), + fn('choose', [range(offset, offset + item.code.length - 1, 2), range(offset + returnStart, offset + returnStart + 9, 0)]), + ]); + // When the artifact is isolated from its enclosing script + const [result] = splitCoverageEntry(entry, { artifacts: [item] }); + // Then the function keeps its original count and its unexecuted branch remains zero + assert.deepEqual(result.functions, [ + fn('', [range(0, item.code.length, 1)]), + fn('choose', [range(0, item.code.length - 1, 2), range(returnStart, returnStart + 9, 0)]), + ]); +}); + +test('user does not credit an artifact inside an unexecuted wrapper block', () => { + // Given a script executed once whose enclosing conditional block never ran + const item = artifact('const answer = 1;\n'); + const source = 'if (false) {\n' + item.code + '}\n'; + const blockStart = source.indexOf('{'); + const blockEnd = source.lastIndexOf('}') + 1; + const entry = captured(source, [fn('', [range(0, source.length, 1), range(blockStart, blockEnd, 0)])]); + // When both parent ranges clip to the same artifact boundaries + const [result] = splitCoverageEntry(entry, { artifacts: [item] }); + // Then the most specific zero count overrides the executed outer script + assert.deepEqual(result.functions, [fn('', [range(0, item.code.length, 0)])]); +}); + +test('user counts repeated artifacts from their inner wrapper instead of summing wrapper parents', () => { + // Given two copies in one wrapper, with only the first block executed twice + const item = artifact('const answer = 1;\n'); + const source = 'function execute() {\n{\n' + item.code + '}\n{\n' + item.code + '}\n}\n'; + const offsets = [source.indexOf(item.code), source.lastIndexOf(item.code)]; + const entry = captured(source, [ + fn('', [range(0, source.length, 1)]), + fn('execute', [ + range(0, source.length - 1, 4), + range(offsets[0] - 2, offsets[0] + item.code.length + 1, 2), + range(offsets[1] - 2, offsets[1] + item.code.length + 1, 0), + ]), + ]); + // When both artifact occurrences receive their own coverage entry + const result = splitCoverageEntry(entry, { artifacts: [item] }); + // Then the first copy has two executions and the second stays unexecuted + assert.deepEqual(result.map(({ functions }) => functions), [ + [fn('', [range(0, item.code.length, 2)])], [fn('', [range(0, item.code.length, 0)])], + ]); +}); + +test('user gets no artifact attribution for quoted or incomplete installer bytes', () => { + // Given exact code quoted as template data and a separate incomplete fragment + const item = artifact('const answer = 1;\n'); + const quoted = 'const text = ' + String.fromCharCode(96) + item.code + String.fromCharCode(96) + ';'; + const fragment = 'const answer = 1'; + const entry = captured(quoted, [fn('', [range(0, quoted.length, 1)])]); + // When candidate artifact occurrences are validated against the complete AST + const quotedSegments = findArtifactSegments(quoted, [item]); + const partialSegments = findArtifactSegments(fragment, [item]); + const result = splitCoverageEntry(entry, { artifacts: [item] }); + // Then unknown input passes through unchanged for the existing mapper to reject + assert.deepEqual(quotedSegments, []); + assert.deepEqual(partialSegments, []); + assert.equal(result.length, 1); + assert.equal(result[0], entry); +}); + +test('user rejects a function that partially crosses an exact artifact boundary', () => { + // Given an inconsistent capture whose function starts before and ends inside the artifact + const item = artifact('const answer = 1;\n'); + const source = 'const prefix = 0;\n' + item.code; + const offset = source.indexOf(item.code); + const entry = captured(source, [ + fn('', [range(0, source.length, 1)]), + fn('crossing', [range(offset - 1, source.length - 2, 1)]), + ]); + // When that entry is offered for exact source attribution + const split = () => splitCoverageEntry(entry, { artifacts: [item] }); + // Then invalid function geometry fails instead of manufacturing covered ranges + assert.throws(split, /partially crosses an exact artifact boundary/); +}); + +test('user rejects a capture without an enclosing script or wrapper range', () => { + // Given an artifact whose only captured range covers a small internal fragment + const item = artifact('const answer = 1;\n'); + const entry = captured(item.code, [fn('fragment', [range(6, 12, 1)])]); + // When the splitter would otherwise have to invent a root count + const split = () => splitCoverageEntry(entry, { artifacts: [item] }); + // Then the missing source of root coverage is an explicit failure + assert.throws(split, /requires a captured script or wrapper/); +}); + +test('user rejects overlapping artifact definitions instead of counting the same bytes twice', () => { + // Given a complete installer that also contains another registered installer as its prefix + const first = artifact('const first = 1;\n', 'scripts/first.user.js'); + const combined = artifact(first.code + 'const second = 2;\n', 'scripts/combined.user.js'); + // When both definitions match executable statement boundaries + const find = () => findArtifactSegments(combined.code, [first, combined]); + // Then ambiguous overlapping attribution is rejected + assert.throws(find, /segments must not overlap/); +}); diff --git a/test/unit/test-policy.test.js b/test/unit/test-policy.test.js new file mode 100644 index 0000000..0996f16 --- /dev/null +++ b/test/unit/test-policy.test.js @@ -0,0 +1,414 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { Linter } from 'eslint'; + +import config from '../../eslint.config.js'; +import plugin from '../../scripts/test-policy/eslint-plugin.js'; +import { contractCallAllowances, legacyBehaviorFiles, legacyBehaviorGroups, legacyCallAllowances } from '../../scripts/test-policy/migration-inventory.js'; + +const projectRoot = fileURLToPath(new URL('../../', import.meta.url)); + +function lint(code, rule, options = []) { + return new Linter().verify(code, { + plugins: { 'test-policy': plugin }, + rules: { [`test-policy/${rule}`]: ['error', ...options] }, + }); +} + +function lintConfigured(code, filename) { + return new Linter({ cwd: projectRoot }).verify(code, config, { filename }); +} + +function messages(result) { + return result.map(({ ruleId, messageId, severity }) => ({ ruleId, messageId, severity })); +} + +function ids(result) { + return result.map(({ messageId }) => messageId); +} + +const commentBehavior = `test('user sees a submitted order', () => { + // Given the panel has a valid order quantity + const panel = createPanel({ quantity: 2 }); + // When the user submits the order + panel.submit(); + // Then the status confirms the accepted quantity + assert.equal(panel.status, 'Submitted 2'); +});`; + +test('user accepts concrete behavior comments around executable phases', () => { + // Given a behavior test with setup, action, and an observable status assertion + const code = commentBehavior; + // When the policy checks the scenario + const result = lint(code, 'behavior-contract'); + // Then the behavior contract accepts every phase + assert.deepEqual(result, []); +}); + +test('user accepts awaited steps and additional action-result pairs', () => { + // Given a browser scenario that submits an order and then closes its panel + const code = `test('user sees confirmation before closing the panel', async () => { + let panel; + await test.step('Given the order panel is ready', async () => { panel = await openPanel(); }); + await test.step('When the user submits the order', async () => { await panel.submit(); }); + await test.step('Then the accepted quantity is visible', async () => { expect(panel.quantity).toBe(2); }); + await test.step('When the user closes the panel', async () => { await panel.close(); }); + return test.step('Then the panel is absent from the page', async () => { expect(panel.visible).toBe(false); }); + });`; + // When the policy checks the ordered steps + const result = lint(code, 'behavior-contract'); + // Then both observable result stages satisfy the contract + assert.deepEqual(result, []); +}); + +test('user accepts parameterized titles whose visible prefix remains user', () => { + // Given a scenario matrix registered through an imported test alias + const code = "import { test as scenario } from 'node:test';\n" + + commentBehavior.replace("test('user sees a submitted order'", 'scenario(`user sees ${quantity} accepted orders`'); + // When the policy checks the parameterized registration + const result = lint(code, 'behavior-contract'); + // Then a dynamic quantity does not hide the behavioral title prefix + assert.deepEqual(result, []); +}); + +for (const [reason, code, expected] of [ + ['an implementation title', commentBehavior.replace('user sees a submitted order', 'submitOrder calls the API'), ['title']], + ['a computed title without a stable prefix', commentBehavior.replace("'user sees a submitted order'", "name + ' user submits'"), ['title']], + ['bare stage keywords', `test('user sees an order', () => { + // Given + const panel = createPanel(); + // When + panel.submit(); + // Then + assert.equal(panel.count, 1); + });`, ['description', 'description', 'description']], + ['placeholder stage descriptions', `test('user sees an order', () => { + // Given the setup + const panel = createPanel(); + // When the action + panel.submit(); + // Then expected result + assert.equal(panel.count, 1); + });`, ['description', 'description', 'description']], + ['empty setup and action phases', `test('user sees an order', () => { + // Given the order panel is ready + // When the user submits the order + // Then the order appears in current orders + assert.equal(readOrderCount(), 1); + });`, ['emptyStage', 'emptyStage']], + ['a result stage before the action', `test('user sees an order', () => { + // Given the order panel is ready + const panel = createPanel(); + // Then the order appears in current orders + assert.equal(panel.count, 1); + // When the user submits the order + panel.submit(); + });`, ['stages']], + ['phase comments hidden in an unused function', `test('user sees an order', () => { + function unused() { + // Given the order panel is ready + const panel = createPanel(); + // When the user submits the order + panel.submit(); + // Then the order appears in current orders + assert.equal(panel.count, 1); + } + assert.equal(readOrderCount(), 1); + });`, ['stages']], + ['stage labels hidden in strings', `test('user sees an order', () => { + const unused = 'Given the panel When user submits Then order appears'; + assert.equal(readOrderCount(), 1); + });`, ['stages']], + ['phase comments hidden behind a condition', `test('user sees an order', () => { + if (false) { + // Given the order panel is ready + const panel = createPanel(); + // When the user submits the order + panel.submit(); + // Then the order appears in current orders + assert.equal(panel.count, 1); + } + assert.equal(readOrderCount(), 1); + });`, ['stages']], + ['an external callback that conceals the stages', "test('user sees an order', sharedCallback);", ['callback']], +]) { + test(`user rejects ${reason} instead of accepting superficial BDD labels`, () => { + // Given a source example with a specific missing behavioral contract + const example = code; + // When ESLint parses the source and applies the behavior rule + const result = lint(example, 'behavior-contract'); + // Then the policy reports the exact violated contract + assert.deepEqual(ids(result), expected); + }); +} + +test('user rejects unawaited and empty browser steps', () => { + // Given a setup step that is empty and an action step that can race assertions + const code = `test('user sees a submitted order', async () => { + await test.step('Given the order panel is ready', async () => {}); + test.step('When the user submits the order', async () => { await submit(); }); + await test.step('Then the accepted order is visible', async () => { expect(orderCount()).toBe(1); }); + });`; + // When the policy checks the steps + const result = lint(code, 'behavior-contract'); + // Then both the missing setup and the sequencing race are rejected + assert.deepEqual(ids(result).sort(), ['awaitedStep', 'emptyStage']); +}); + +for (const [code, expectedCount = 1] of [ + ["test.only('case', () => assert.equal(readCount(), 1));"], + ["test['skip']('case', () => assert.equal(readCount(), 1));"], + ["test.describe.only('suite', () => {});"], + ["import { test as scenario } from 'node:test'; scenario.skip('case', () => {});"], + ["import * as scenarios from '@playwright/test'; scenarios.test.only('case', () => {});"], + ["import * as scenarios from '../test.js'; scenarios.test.skip('case', () => {});"], + ["test('case', context => { context.skip('temporarily disabled'); });"], + ["const { only: exclusive } = test; exclusive('case', () => {});"], + ["const scenario = test; scenario[`skip`]('case', () => {});"], + ["const scenario = test.only.bind(test); scenario('case', () => {});", 2], + ["test['s' + 'kip']('case', () => {});"], + ["test('case', { skip: true }, () => {});"], + ["describe('suite', { only: true }, () => {});"], + ["test('case', { todo: 'later' }, () => {});"], + ["test.fixme(true, 'broken behavior');"], +]) { + test(`user rejects a focused or omitted scenario registered as ${code}`, () => { + // Given a runner call that would focus, omit, or defer a scenario + const example = code; + // When the policy checks the actual JavaScript syntax + const result = lint(example, 'no-focused-tests'); + // Then the runner cannot silently drop coverage through that call + assert.deepEqual(ids(result), Array(expectedCount).fill('forbidden')); + }); +} + +test('user keeps ordinary runner options and unrelated only fields valid', () => { + // Given a fully enabled scenario and an unrelated media selection object + const code = "test('case', { only: false, skip: false }, () => {}); const asset = { only: 'video' }; test.setTimeout(30000);"; + // When the focus rule checks the source + const result = lint(code, 'no-focused-tests'); + // Then ordinary data fields and runner deadlines remain valid + assert.deepEqual(result, []); +}); + +for (const code of [ + "t.mock.method(Date, 'now', () => 42);", + 't["mock"]["fn"](() => 42);', + "import { mock as tracker } from 'node:test'; tracker.method(transport, 'send', send);", + "const { method: replace } = t.mock; replace(transport, 'send', send);", + "const spy = t.mock.fn.bind(t.mock); spy(() => 42);", + "t.mock[method](transport, 'send', send);", +]) { + test(`user rejects an ad hoc mock introduced as ${code}`, () => { + // Given a direct or aliased method replacement without a boundary contract + const example = code; + // When the mock policy checks the source + const result = lint(example, 'no-uncontracted-mocks'); + // Then the replacement requires a tested fake or virtual clock + assert.deepEqual(ids(result), ['forbidden']); + }); +} + +test('user allows the built-in virtual clock and an explicit fake factory', () => { + // Given a deterministic clock and a named transport fake + const code = "t.mock.timers.enable({ apis: ['Date', 'setTimeout'] }); t.mock.timers.tick(1000); const transport = createContractTestedTransport();"; + // When the mock rule checks these boundary choices + const result = lint(code, 'no-uncontracted-mocks'); + // Then time control is not confused with ad hoc method replacement + assert.deepEqual(result, []); +}); + +for (const code of [ + 'await page.waitForTimeout(500);', + "const pause = page['waitForTimeout'].bind(page); await pause(500);", + 'await new Promise(resolve => setTimeout(resolve, 500));', + 'await new Promise(resolve => window.setTimeout(() => resolve(), 0));', + 'await new Promise(resolve => { const finish = resolve; setTimeout(finish, 500); });', + "import { setTimeout as pause } from 'node:timers/promises'; await pause(500);", + "import * as timers from 'node:timers/promises'; await timers.setTimeout(500);", + "import { scheduler } from 'node:timers/promises'; await scheduler.wait(500);", + 'await sleep(1000);', + 'await delay(duration);', +]) { + test(`user rejects a fixed wait expressed as ${code}`, () => { + // Given a real elapsed-time wait that could hide a race or slow a test + const example = code; + // When the wait policy checks the parsed call + const result = lint(example, 'no-fixed-waits'); + // Then the wait must become an observed condition or clock advance + assert.deepEqual(ids(result), ['forbidden']); + }); +} + +test('user preserves modeled host events and bounded failure deadlines', () => { + // Given timer callbacks that emit modeled events or reject a deadline + const code = `setTimeout(() => host.emit('accepted'), 20); + const result = new Promise((resolve, reject) => setTimeout(() => reject(new Error('deadline')), 1000)); + test.setTimeout(30000); + await page.clock.runFor(1000); + await expect.poll(readStatus).toBe('ready');`; + // When the fixed-wait rule checks those scheduling contracts + const result = lint(code, 'no-fixed-waits'); + // Then scheduling a modeled event is not treated as sleeping before assertions + assert.deepEqual(result, []); +}); + +test('user can retain only the recorded number of legacy mock targets', () => { + // Given one explicitly documented Date.now replacement in a legacy file + const code = "t.mock.method(Date, 'now', () => 42);"; + const options = [{ allow: [{ target: 'method:Date:now', count: 1, reason: 'Legacy footer clock awaits migration to mock.timers.' }] }]; + // When the same call is checked once and then duplicated + const accepted = lint(code, 'no-uncontracted-mocks', options); + const duplicated = lint(`${code}\n${code}`, 'no-uncontracted-mocks', options); + // Then the inventory allows the old call and rejects any growth + assert.deepEqual(accepted, []); + assert.deepEqual(ids(duplicated), ['forbidden']); +}); + +test('user must remove stale wait allowances when a legacy wait is migrated', () => { + // Given one recorded legacy timer target + const options = [{ allow: [{ target: 'setTimeout(20)', count: 1, reason: 'Legacy export settling awaits a completion-event migration.' }] }]; + // When the old wait is retained, removed, or replaced with a longer sleep + const retained = lint('await new Promise(resolve => setTimeout(resolve, 20));', 'no-fixed-waits', options); + const migrated = lint('await exportFinished;', 'no-fixed-waits', options); + const changed = lint('await new Promise(resolve => setTimeout(resolve, 100));', 'no-fixed-waits', options); + // Then only the exact retained target matches the shrinking inventory + assert.deepEqual(retained, []); + assert.deepEqual(ids(migrated), ['staleAllowance']); + assert.deepEqual(ids(changed), ['staleAllowance', 'forbidden']); +}); + +test('user permits only the performance-tail task boundary in its owning helper', () => { + // Given the single real browser task used to collect PerformanceObserver entries + const file = 'e2e/binance-orderbook/helpers/live-performance-probe.js'; + const boundary = 'window.setTimeout(resolve, 0);'; + const code = `const probe = { finishAfterPerformanceTail() { + return new Promise(resolve => { ${boundary} }); + } };`; + // When another wait is added inside or outside the approved method + const accepted = lintConfigured(code, file); + const duplicate = lintConfigured(code.replace(boundary, `${boundary} ${boundary}`), file); + const businessWait = lintConfigured(`${code}\nawait new Promise(resolve => window.setTimeout(resolve, 0));`, file); + // Then the exact host boundary is retained and both growth paths fail + assert.deepEqual(accepted, []); + assert.deepEqual(ids(duplicate), ['forbidden']); + assert.deepEqual(ids(businessWait), ['forbidden']); +}); + +for (const [reason, code, expected] of [ + ['an empty callback', "test('case', () => {});", 'empty'], + ['a pending callback', "test('case');", 'empty'], + ['constant arithmetic', "test('case', () => { assert.equal(1 + 1, 2); });", 'constant'], + ['constant browser assertions', "test('case', () => { expect(true).toBe(true); });", 'constant'], + ['a value compared with itself', "test('case', () => { const result = readState(); assert.equal(result, result); });", 'constant'], +]) { + test(`user rejects ${reason} as evidence of tested behavior`, () => { + // Given a test that cannot detect a behavioral regression + const example = code; + // When the policy examines its executable body and assertions + const result = lint(example, 'no-vacuous-tests'); + // Then the test is rejected for its specific missing evidence + assert.deepEqual(ids(result), [expected]); + }); +} + +test('user retains meaningful source contracts and exception assertions', () => { + // Given metadata, runtime-result, and invalid-input assertions + const code = `test('metadata has install URLs', () => assert.match(source, /@downloadURL/)); + test('accepted count', () => assert.equal(readCount(), 2)); + test('invalid quantity', () => assert.throws(() => submit(-1), /quantity/));`; + // When the policy checks the assertion inputs + const result = lint(code, 'no-vacuous-tests'); + // Then useful string contracts and runtime behavior remain covered + assert.deepEqual(result, []); +}); + +test('user can call a regular expression test method without registering a test case', () => { + // Given a validation predicate that uses the standard RegExp method + const code = "const matches = /ready/.test(status); const expression = /ready/; expression.test(status);"; + // When test registration rules inspect those calls + const results = ['behavior-contract', 'no-vacuous-tests'].map((rule) => lint(code, rule)); + // Then neither call is mistaken for a Node or Playwright test registration + assert.deepEqual(results, [[], []]); +}); + +test('user resolves test aliases in their lexical scope without confusing local data', () => { + // Given a runner alias beside a helper parameter with the same name + const code = `import { test as scenario } from 'node:test'; + function inspect(scenario) { scenario.skip('ordinary object method'); } + scenario.only('case', () => assert.equal(readCount(), 1));`; + // When the policy resolves each reference through the ESLint scope model + const result = lint(code, 'no-focused-tests'); + // Then only the imported runner can focus a test + assert.deepEqual(ids(result), ['forbidden']); + assert.equal(result[0].line, 3); +}); + +test('user applies full behavior rules to every new test file and migrated suite', () => { + // Given one implementation-named case in new, migrated, and browser test paths + const code = "test('internal helper works', () => assert.equal(readCount(), 2));"; + const files = [ + 'test/unit/new-behavior.test.js', + 'test/unit/binance-orderbook-trade/quantity.test.js', + 'test/unit/test-policy.test.js', + 'test/unit/binance-fixture-contract.test.js', + 'test/unit/coverage-report.test.js', + 'test/unit/test-selection.test.js', + 'e2e/binance-orderbook/specs/new-behavior.pw.js', + ]; + // When ESLint uses the repository configuration for every path + const results = files.map((filename) => ids(lintConfigured(code, filename))); + // Then each path requires the user title and concrete behavior stages + assert.deepEqual(results, files.map(() => ['title', 'stages'])); +}); + +test('user keeps legacy behavioral debt visible without disabling universal rules', () => { + // Given an explicitly inventoried source-regression suite + const file = 'test/unit/binance-orderbook-trade/source-regressions.test.js'; + // When its useful source assertion and a newly skipped case are linted + const accepted = lintConfigured("test('metadata', () => assert.match(source, /@version/));", file); + const skipped = lintConfigured("test.skip('metadata', () => assert.match(source, /@version/));", file); + // Then only the staged BDD organization is deferred + assert.deepEqual(accepted, []); + assert.deepEqual(messages(skipped), [{ ruleId: 'test-policy/no-focused-tests', messageId: 'forbidden', severity: 2 }]); +}); + +test('user cannot hide test-policy failures with an inline ESLint disable', () => { + // Given a local disable directive attached to a test without behavior stages + const code = "/* eslint-disable test-policy/behavior-contract */\ntest('user sees an order', () => assert.equal(readCount(), 1));"; + // When the repository configuration checks the source + const result = lintConfigured(code, 'test/unit/new-behavior.test.js'); + // Then the directive has no effect and the missing stages still fail lint + assert.deepEqual(messages(result), [ + { ruleId: null, messageId: undefined, severity: 1 }, + { ruleId: 'test-policy/behavior-contract', messageId: 'stages', severity: 2 }, + ]); +}); + +test('user inventories only existing exact paths with an explicit migration reason', () => { + // Given the repository migration inventory + const files = [...legacyBehaviorFiles, ...legacyCallAllowances.map(({ file }) => file), ...contractCallAllowances.map(({ file }) => file)]; + // When inventory paths and explanatory scope are inspected + const invalidFiles = files.filter((file) => /[*?{}]/.test(file) || !existsSync(new URL(file, new URL('../../', import.meta.url)))); + const missingReasons = legacyBehaviorGroups.filter(({ reason }) => reason.trim().length < 20); + const duplicateBehaviorFiles = legacyBehaviorFiles.length - new Set(legacyBehaviorFiles).size; + // Then no directory-wide allowance or silent unexplained debt exists + assert.deepEqual(invalidFiles, []); + assert.deepEqual(missingReasons, []); + assert.equal(duplicateBehaviorFiles, 0); +}); + +test('user keeps nested browser scenarios subject to the same behavior policy', () => { + // Given a nested browser spec has real phases but a title without the required prefix. + const code = commentBehavior.replace('user sees a submitted order', 'nested order scenario'); + + // When the actual repository configuration checks the nested spec path. + const result = lintConfigured(code, 'e2e/binance-orderbook/specs/nested/order.pw.js'); + + // Then nesting cannot bypass the behavior title rule. + assert.deepEqual(messages(result), [ + { ruleId: 'test-policy/behavior-contract', messageId: 'title', severity: 2 }, + ]); +}); diff --git a/test/unit/test-selection.test.js b/test/unit/test-selection.test.js new file mode 100644 index 0000000..f757963 --- /dev/null +++ b/test/unit/test-selection.test.js @@ -0,0 +1,584 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, writeFile, rm, rename } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +import { buildTestGraph, selectTests } from '../../scripts/test-selection/graph.mjs'; +import { collectRepositoryState, createRepositoryPlan, parseArgs, runnerCommands, runCommands } from '../../scripts/test-selection/run.mjs'; + +const execute = promisify(execFile); +const cli = fileURLToPath(new URL('../../scripts/test-selection/run.mjs', import.meta.url)); + +function repositoryFiles(extra = {}) { + return { + 'src/shared/math.js': 'export const value = 1;', + 'src/feature/index.user.js': "export { value } from '../shared/math.js';", + 'src/other.js': 'export const other = 2;', + 'scripts/build-userscript.mjs': "export const TARGETS = { feature: { entry: 'src/feature/index.user.js', output: 'scripts/feature.user.js' } };", + 'scripts/feature.user.js': '// Generated install artifact.', + 'test/unit/math.test.js': "import '../../src/shared/math.js';", + 'test/unit/other.test.js': "import '../../src/other.js';", + 'test/dom/form.test.js': "import './form-helper.js';", + 'test/dom/form-helper.js': "export const fixture = new URL('../fixtures/form.html', import.meta.url);", + 'test/fixtures/form.html': '
', + 'e2e/app/specs/page.pw.js': "import '../helpers/page.js';", + 'e2e/app/helpers/page.js': "export const file = new URL('../../../scripts/feature.user.js', import.meta.url);", + 'playwright.config.js': "export default { testDir: './e2e/app/specs', testMatch: '**/*.pw.js', snapshotPathTemplate: '{testDir}/{testFilePath}-snapshots/{arg}{ext}' };", + 'e2e/app/specs/page.pw.js-snapshots/panel.json': '{"visible":true}', + 'docs/guide.md': '# Guide', + ...extra, + }; +} + +async function graphFor(files) { + return buildTestGraph({ files: Object.keys(files), readText: async (path) => { + assert.equal(Object.hasOwn(files, path), true, 'Unexpected graph read: ' + path); + return files[path]; + } }); +} + +async function createRepository(t, extra = {}) { + const root = await mkdtemp(join(tmpdir(), 'userscripts-selection-')); + t.after(() => rm(root, { recursive: true, force: true })); + const files = repositoryFiles({ '.nvmrc': process.versions.node + '\n', ...extra }); + for (const [file, content] of Object.entries(files)) { + await mkdir(join(root, file, '..'), { recursive: true }); + await writeFile(join(root, file), content); + } + await execute('git', ['init', '--quiet'], { cwd: root }); + await execute('git', ['add', '--all'], { cwd: root }); + await execute('git', ['-c', 'user.name=Selection Test', '-c', 'user.email=selection@example.invalid', 'commit', '--quiet', '-m', 'Initial test fixture'], { cwd: root }); + return root; +} + +test('user selects direct and generated-artifact consumers of a shared source change', async () => { + // Given independent tests and an artifact built transitively from the shared module. + const graph = await graphFor(repositoryFiles()); + + // When the shared module changes. + const plan = selectTests(graph, ['src/shared/math.js']); + + // Then the direct Node test and browser artifact consumer run without unrelated tests. + assert.equal(plan.schemaVersion, 1); + assert.equal(plan.mode, 'affected'); + assert.deepEqual(plan.nodeTests, ['test/unit/math.test.js']); + assert.deepEqual(plan.browserTests, ['e2e/app/specs/page.pw.js']); + assert.deepEqual(plan.changedFiles, ['src/shared/math.js']); +}); + +test('user selects the DOM consumer of a statically referenced HTML fixture', async () => { + // Given a helper whose fixture URL is resolved relative to import.meta.url. + const graph = await graphFor(repositoryFiles()); + + // When the fixture changes. + const plan = selectTests(graph, ['test/fixtures/form.html']); + + // Then the transitive DOM test runs by its actual filename. + assert.equal(plan.mode, 'affected'); + assert.deepEqual(plan.nodeTests, ['test/dom/form.test.js']); + assert.deepEqual(plan.browserTests, []); +}); + +test('user preserves encoded filename variants in static URL dependencies', async () => { + // Given literal URL inputs for filenames containing spaces and a newline. + const source = "new URL('../fixtures/first%20file.json', import.meta.url); new URL('../fixtures/second%0Afile.json', import.meta.url);"; + const graph = await graphFor(repositoryFiles({ + 'test/unit/templates.test.js': source, + 'test/fixtures/first file.json': '{}', + 'test/fixtures/second\nfile.json': '{}', + })); + + // When the newline-containing fixture changes. + const plan = selectTests(graph, ['test/fixtures/second\nfile.json']); + + // Then URL decoding selects its test and preserves the exact path bytes. + assert.equal(plan.mode, 'affected'); + assert.deepEqual(plan.nodeTests, ['test/unit/templates.test.js']); + assert.deepEqual(plan.changedFiles, ['test/fixtures/second\nfile.json']); +}); + +test('user runs every test when a template-derived fixture has no independently verified edge', async () => { + // Given a runtime template whose array values are deliberately not treated as a complete domain. + const graph = await graphFor(repositoryFiles({ + 'test/unit/templates.test.js': "const names = ['first', 'second']; for (const name of names) new URL(`../fixtures/${name}.json`, import.meta.url);", + 'test/fixtures/first.json': '{}', + 'test/fixtures/second.json': '{}', + })); + + // When one of those fixtures changes without another static dependency edge. + const plan = selectTests(graph, ['test/fixtures/second.json']); + + // Then incomplete runtime value analysis cannot turn an unmapped fixture into an empty plan. + assert.equal(plan.mode, 'full'); + assert.equal(plan.nodeTests.includes('test/unit/templates.test.js'), true); + assert.match(plan.reasons.join('\n'), /Unmapped runtime change/); +}); + +for (const mutation of [ + 'names.push(process.argv[2]);', + 'names[0] = process.argv[2];', + 'const alias = names; alias.push(process.argv[2]);', + 'changeNames(names);', +]) { + test(`user keeps unknown fixture consumers after the path list changes through ${mutation}`, async () => { + // Given a known consumer and a fixture path domain that can change before iteration. + const graph = await graphFor(repositoryFiles({ + 'test/unit/mutable.test.js': "import { readFile } from 'node:fs/promises'; const names = ['first']; " + mutation + "; for (const name of names) await readFile(new URL(`../fixtures/${name}.json`, import.meta.url));", + 'test/fixtures/first.json': '{}', + })); + + // When another known fixture changes and may be read through the modified path list. + const plan = selectTests(graph, ['test/fixtures/form.html']); + + // Then the unproved fixture consumer is retained instead of trusting its initializer forever. + assert.equal(plan.mode, 'affected'); + assert.deepEqual(plan.nodeTests, ['test/dom/form.test.js', 'test/unit/mutable.test.js']); + assert.match(plan.reasons.join('\n'), /Unresolved/); + }); +} + +test('user selects the owner of a derived Playwright snapshot', async () => { + // Given the verified Playwright snapshot template and its derived snapshot directory. + const graph = await graphFor(repositoryFiles()); + + // When the panel snapshot changes without a source import referring to it. + const plan = selectTests(graph, ['e2e/app/specs/page.pw.js-snapshots/panel.json']); + + // Then its browser spec runs even though the snapshot dependency is implicit. + assert.equal(plan.mode, 'affected'); + assert.deepEqual(plan.browserTests, ['e2e/app/specs/page.pw.js']); + assert.deepEqual(plan.nodeTests, []); +}); + +test('user discovers nested browser specs in both affected and full plans', async () => { + // Given a new spec and snapshot below a nested directory matched by Playwright. + const spec = 'e2e/app/specs/nested/new.pw.js'; + const snapshot = spec + '-snapshots/panel.json'; + const graph = await graphFor(repositoryFiles({ [spec]: '', [snapshot]: '{}' })); + + // When the snapshot changes and when an explicit full run is requested. + const affected = selectTests(graph, [snapshot]); + const full = selectTests(graph, [], { full: true }); + + // Then the owning nested spec is selected and no full plan loses it during discovery. + assert.equal(affected.mode, 'affected'); + assert.deepEqual(affected.browserTests, [spec]); + assert.equal(full.mode, 'full'); + assert.deepEqual(full.browserTests, [spec, 'e2e/app/specs/page.pw.js']); +}); + +for (const { name, source } of [ + { name: 'an unresolved dynamic import', source: "await import(process.argv[2]);" }, + { name: 'an unbounded template path', source: "await import(`../../src/${process.argv[2]}.js`);" }, + { name: 'a directory input', source: "import { readdir } from 'node:fs/promises'; await readdir(new URL('../fixtures/', import.meta.url));" }, + { name: 'an unresolved file-read input', source: "import { readFile as read } from 'node:fs/promises'; await read(process.argv[2]);" }, + { name: 'a shadowed reader argument', source: "import { readFile } from 'node:fs/promises'; const path = '../../src/other.js'; function load(path) { return readFile(path); } load(process.argv[2]);" }, + { name: 'a reader assigned to another name', source: "import { readFile } from 'node:fs/promises'; const read = readFile; await read(process.argv[2]);" }, + { name: 'a destructured namespace reader', source: "import * as fs from 'node:fs/promises'; const { readFile: read } = fs; await read(process.argv[2]);" }, + { name: 'a dynamically imported filesystem module', source: "const fs = await import('node:fs/promises'); await fs.readFile(process.argv[2]);" }, + { name: 'a required filesystem module', source: "const fs = require('fs'); fs.readFileSync(process.argv[2]);" }, + { name: 'an alias created by the Node module loader', source: "import { createRequire } from 'node:module'; const load = createRequire(import.meta.url); load(process.argv[2]);" }, + { name: 'an alias created by the unprefixed module loader', source: "import { createRequire } from 'module'; const load = createRequire(import.meta.url); load(process.argv[2]);" }, + { name: 'a subprocess with runtime-dependent inputs', source: "import { execFile } from 'node:child_process'; execFile(process.execPath, [process.argv[2]]);" }, + { name: 'a module embedded in a data URL', source: "await import('data:text/javascript,export const loaded = true;');" }, +]) { + test(`user retains complete uncertain consumers when the graph contains ${name}`, async () => { + // Given one known consumer plus a second consumer whose dependency set is incomplete. + const graph = await graphFor(repositoryFiles({ 'test/unit/dynamic.test.js': source })); + + // When a source with some known consumers changes. + const plan = selectTests(graph, ['src/shared/math.js']); + + // Then the uncertain file and known consumers run while independent test files stay excluded. + assert.equal(plan.mode, 'affected'); + assert.deepEqual(plan.nodeTests, ['test/unit/dynamic.test.js', 'test/unit/math.test.js']); + assert.deepEqual(plan.browserTests, ['e2e/app/specs/page.pw.js']); + assert.match(plan.reasons.join('\n'), /Unresolved|Directory input/); + }); +} + +test('user retains every test that reaches an unresolved intermediate reader', async () => { + // Given two test roots sharing a helper with an unknown file input and other independent roots. + const graph = await graphFor(repositoryFiles({ + 'test/helpers/reader.js': "import { readFile } from 'node:fs/promises'; await readFile(process.argv[2]);", + 'test/unit/unknown-a.test.js': "import '../helpers/reader.js';", + 'test/dom/unknown-b.test.js': "import '../helpers/reader.js';", + })); + + // When a statically known independent source changes. + const plan = selectTests(graph, ['src/other.js']); + + // Then reverse dependency traversal includes both unknown consumers and the known affected test. + assert.equal(plan.mode, 'affected'); + assert.deepEqual(plan.nodeTests, ['test/dom/unknown-b.test.js', 'test/unit/other.test.js', 'test/unit/unknown-a.test.js']); + assert.deepEqual(plan.browserTests, []); +}); + +test('user retains unknown readers when an apparently unconsumed Markdown file changes', async () => { + // Given a reader whose runtime path could include documentation. + const graph = await graphFor(repositoryFiles({ + 'test/unit/dynamic.test.js': "import { readFile } from 'node:fs/promises'; await readFile(process.argv[2]);", + })); + + // When only the documentation file changes. + const plan = selectTests(graph, ['docs/guide.md']); + + // Then Markdown alone is insufficient evidence to skip an unknown runtime consumer. + assert.equal(plan.mode, 'affected'); + assert.deepEqual(plan.nodeTests, ['test/unit/dynamic.test.js']); + assert.deepEqual(plan.browserTests, []); +}); + +for (const build of [ + 'export const TARGETS = loadTargets();', + "export const TARGETS = { feature: { entry: '../outside.js', output: 'scripts/feature.user.js' } };", + "export const TARGETS = { feature: { entry: 'src/feature/index.ts', output: 'scripts/feature.user.js' } };", + "export const TARGETS = { first: { entry: 'src/feature/index.user.js', output: 'scripts/feature.user.js' }, second: { entry: 'src/other.js', output: 'scripts/feature.user.js' } };", +]) { + test(`user runs every test when the exported artifact mapping is invalid ${build}`, async () => { + // Given a build mapping that cannot prove a unique supported source for every artifact. + const graph = await graphFor(repositoryFiles({ 'scripts/build-userscript.mjs': build })); + + // When a source with a known direct consumer changes. + const plan = selectTests(graph, ['src/shared/math.js']); + + // Then a global artifact-graph error cannot be reduced to the visible direct consumers. + assert.equal(plan.mode, 'full'); + assert.deepEqual(plan.nodeTests, ['test/dom/form.test.js', 'test/unit/math.test.js', 'test/unit/other.test.js']); + assert.deepEqual(plan.browserTests, ['e2e/app/specs/page.pw.js']); + assert.match(plan.reasons.join('\n'), /Build/); + }); +} + +test('user runs every test when the configured snapshot layout is unsupported', async () => { + // Given a snapshot layout that cannot derive an owning spec from the stored filename. + const graph = await graphFor(repositoryFiles({ + 'playwright.config.js': "export default { testDir: './e2e/app/specs', testMatch: '**/*.pw.js', snapshotPathTemplate: 'custom/{arg}{ext}' };", + })); + + // When a known source changes while global snapshot ownership is unresolved. + const plan = selectTests(graph, ['src/shared/math.js']); + + // Then the global layout uncertainty selects all test roots explicitly. + assert.equal(plan.mode, 'full'); + assert.deepEqual(plan.nodeTests, ['test/dom/form.test.js', 'test/unit/math.test.js', 'test/unit/other.test.js']); + assert.deepEqual(plan.browserTests, ['e2e/app/specs/page.pw.js']); + assert.match(plan.reasons.join('\n'), /snapshot layout/); +}); + +test('user runs every test when Playwright root discovery no longer matches its configuration', async () => { + // Given a narrower configured test pattern than the supported recursive spec inventory. + const graph = await graphFor(repositoryFiles({ + 'playwright.config.js': "export default { testDir: './e2e/app/specs', testMatch: '*.pw.js', snapshotPathTemplate: '{testDir}/{testFilePath}-snapshots/{arg}{ext}' };", + })); + + // When a known source changes under that unverified discovery contract. + const plan = selectTests(graph, ['src/other.js']); + + // Then the root mismatch is explicit global work rather than a partial plan. + assert.equal(plan.mode, 'full'); + assert.deepEqual(plan.nodeTests, ['test/dom/form.test.js', 'test/unit/math.test.js', 'test/unit/other.test.js']); + assert.match(plan.reasons.join('\n'), /test root configuration/); +}); + +test('user retains the consumer of an unsupported executable dependency', async () => { + // Given a test importing TypeScript whose transitive imports the JavaScript parser cannot prove. + const graph = await graphFor(repositoryFiles({ + 'test/unit/typed.test.js': "import '../../src/typed.ts';", + 'src/typed.ts': "export { other } from './other.js';", + })); + + // When a separately known source changes and might also feed the unsupported module. + const plan = selectTests(graph, ['src/other.js']); + + // Then the unsupported branch remains a complete test-file consumer in the plan. + assert.equal(plan.mode, 'affected'); + assert.deepEqual(plan.nodeTests, ['test/unit/other.test.js', 'test/unit/typed.test.js']); + assert.match(plan.reasons.join('\n'), /Unsupported module dependency/); +}); + +for (const changed of ['unrecognized.json', 'test/fixtures/unknown.html', 'images/unknown.png', 'src/unused.js']) { + test(`user runs all tests for an unmapped runtime change ${changed}`, async () => { + // Given a repository file with no proven consumers in the graph. + const graph = await graphFor(repositoryFiles({ [changed]: '' })); + + // When that runtime file changes. + const plan = selectTests(graph, [changed]); + + // Then an unmapped file is explicit full-suite work rather than a silent skip. + assert.equal(plan.mode, 'full'); + assert.match(plan.reasons.join('\n'), /Unmapped runtime change/); + assert.equal(plan.nodeTests.length, 3); + }); +} + +for (const changed of ['package.json', 'package-lock.json', '.nvmrc', '.github/workflows/check.yml', 'playwright.config.js', 'eslint.config.js', 'scripts/test-policy/rules.js', 'scripts/test-coverage/report.mjs', 'scripts/test-selection/graph.mjs']) { + test(`user runs all tests after infrastructure changes to ${changed}`, async () => { + // Given the current test inventory and an infrastructure change. + const graph = await graphFor(repositoryFiles()); + + // When the infrastructure change is classified. + const plan = selectTests(graph, [changed]); + + // Then all actual Node and browser roots are selected with an infrastructure reason. + assert.equal(plan.mode, 'full'); + assert.match(plan.reasons.join('\n'), /Infrastructure changed/); + assert.deepEqual(plan.nodeTests, ['test/dom/form.test.js', 'test/unit/math.test.js', 'test/unit/other.test.js']); + assert.deepEqual(plan.browserTests, ['e2e/app/specs/page.pw.js']); + }); +} + +test('user skips only documentation that has no runtime consumer', async () => { + // Given an unconsumed documentation file in an otherwise complete dependency graph. + const graph = await graphFor(repositoryFiles()); + + // When only that documentation changes. + const plan = selectTests(graph, ['docs/guide.md']); + + // Then the plan explicitly reports that no runtime tests are affected. + assert.equal(plan.mode, 'none'); + assert.deepEqual(plan.nodeTests, []); + assert.deepEqual(plan.browserTests, []); + assert.match(plan.reasons.join('\n'), /documentation/i); +}); + +test('user skips a clean checkout while still allowing an explicit full run', async () => { + // Given a valid dependency graph without any changed paths. + const graph = await graphFor(repositoryFiles()); + + // When the empty change set is selected normally and with the full-run override. + const clean = selectTests(graph, []); + const full = selectTests(graph, [], { full: true }); + + // Then normal selection is explicitly empty and the override includes every discovered root. + assert.equal(clean.mode, 'none'); + assert.deepEqual(clean.nodeTests, []); + assert.deepEqual(clean.browserTests, []); + assert.deepEqual(clean.reasons, ['No changed files']); + assert.equal(full.mode, 'full'); + assert.deepEqual(full.nodeTests, ['test/dom/form.test.js', 'test/unit/math.test.js', 'test/unit/other.test.js']); + assert.deepEqual(full.browserTests, ['e2e/app/specs/page.pw.js']); +}); + +test('user still tests documentation consumed at runtime', async () => { + // Given a Node test that reads a Markdown fixture from the documentation directory. + const graph = await graphFor(repositoryFiles({ + 'test/unit/docs.test.js': "import { readFile } from 'node:fs/promises'; await readFile(new URL('../../docs/guide.md', import.meta.url));", + })); + + // When the consumed Markdown changes. + const plan = selectTests(graph, ['docs/guide.md']); + + // Then its runtime consumer runs instead of treating the extension as sufficient to skip. + assert.equal(plan.mode, 'affected'); + assert.deepEqual(plan.nodeTests, ['test/unit/docs.test.js']); +}); + +test('user runs all remaining tests after a tracked source is deleted or renamed', async () => { + // Given a current checkout where a source moved and an unrelated new test was discovered. + const files = repositoryFiles({ 'src/new-name.js': 'export const other = 2;', 'test/unit/new.test.js': '' }); + delete files['src/other.js']; + const graph = await graphFor(files); + + // When both old and new paths are included in the change set. + const plan = selectTests(graph, ['src/other.js', 'src/new-name.js']); + + // Then deletion cannot lose its old consumers and the newly discovered test is included. + assert.equal(plan.mode, 'full'); + assert.equal(plan.nodeTests.includes('test/unit/new.test.js'), true); + assert.equal(plan.nodeTests.includes('test/unit/other.test.js'), true); +}); + +test('user selects a newly discovered test even before it is tracked', async () => { + // Given a new test root in the current filesystem inventory. + const graph = await graphFor(repositoryFiles({ 'test/unit/new file.test.js': '' })); + + // When the new test itself is the changed file. + const plan = selectTests(graph, ['test/unit/new file.test.js']); + + // Then the exact new filename is selected directly. + assert.equal(plan.mode, 'affected'); + assert.deepEqual(plan.nodeTests, ['test/unit/new file.test.js']); +}); + +test('user never reads protected configuration while constructing the dependency graph', async () => { + // Given a test that names a protected config path and a reader that records content access. + const files = repositoryFiles({ 'test/unit/protected.test.js': "await import('../../.codex/config.toml');", '.codex/config.toml': '' }); + const reads = []; + + // When the graph is constructed and the changed source is classified. + const graph = await buildTestGraph({ files: Object.keys(files), readText: async (path) => { reads.push(path); return files[path]; } }); + const plan = selectTests(graph, ['src/shared/math.js']); + + // Then protected contents are never requested and their uncertain consumer still runs. + assert.equal(reads.includes('.codex/config.toml'), false); + assert.equal(plan.mode, 'affected'); + assert.deepEqual(plan.nodeTests, ['test/unit/math.test.js', 'test/unit/protected.test.js']); + assert.match(plan.reasons.join('\n'), /Protected dependency/); +}); + +test('user collects staged unstaged and untracked paths without splitting spaces or newlines', async (t) => { + // Given a test-owned Git repository with staged, unstaged, renamed, and untracked changes. + const root = await createRepository(t); + await writeFile(join(root, 'src/shared/math.js'), 'export const value = 2;'); + await execute('git', ['add', 'src/shared/math.js'], { cwd: root }); + await writeFile(join(root, 'docs/guide.md'), '# Updated'); + await rename(join(root, 'src/other.js'), join(root, 'src/renamed.js')); + const newTest = 'test/unit/new \nfile.test.js'; + await writeFile(join(root, newTest), ''); + + // When the local change set and actual file inventory are collected. + const state = await collectRepositoryState(root); + + // Then every change preserves its exact path and deleted tests or files are not invented as current roots. + assert.deepEqual(state.changedFiles, ['docs/guide.md', 'src/other.js', 'src/renamed.js', 'src/shared/math.js', newTest].sort()); + assert.equal(state.files.includes(newTest), true); + assert.equal(state.files.includes('src/other.js'), false); +}); + +test('user can compare changes against an explicit committed base', async (t) => { + // Given a test-owned repository with a later committed source change. + const root = await createRepository(t); + const { stdout } = await execute('git', ['rev-parse', 'HEAD'], { cwd: root }); + const base = stdout.trim(); + await writeFile(join(root, 'src/shared/math.js'), 'export const value = 2;'); + await execute('git', ['add', '--all'], { cwd: root }); + await execute('git', ['-c', 'user.name=Selection Test', '-c', 'user.email=selection@example.invalid', 'commit', '--quiet', '-m', 'Change shared source'], { cwd: root }); + + // When selection compares the current checkout with the supplied base commit. + const plan = await createRepositoryPlan({ root, base }); + + // Then the committed source change still selects its Node and browser consumers. + assert.deepEqual(plan.changedFiles, ['src/shared/math.js']); + assert.equal(plan.mode, 'affected'); + assert.deepEqual(plan.nodeTests, ['test/unit/math.test.js']); + assert.deepEqual(plan.browserTests, ['e2e/app/specs/page.pw.js']); +}); + +for (const args of [['--base'], ['--base', ''], ['--base', '0000000000000000000000000000000000000000'], ['--unknown']]) { + test(`user gets an explicit argument error for ${JSON.stringify(args)}`, () => { + // Given a missing base, zero base, or unsupported CLI option. + const input = [...args]; + + // When CLI arguments are parsed. + const parse = () => parseArgs(input); + + // Then the selector refuses the invalid request instead of planning an empty run. + assert.throws(parse, /base|argument|option/i); + }); +} + +test('user gets an explicit error when a comparison base cannot resolve to a commit', async (t) => { + // Given a test-owned repository and a nonexistent base reference. + const root = await createRepository(t); + + // When the selector tries to collect changes from that base. + const collect = collectRepositoryState(root, { base: 'missing-base' }); + + // Then unavailable history is reported instead of silently dropping changes. + await assert.rejects(collect, /base.*commit/i); +}); + +test('user receives only the JSON plan from list mode without running selected tests', async (t) => { + // Given a changed fixture test that would throw if the CLI executed it. + const root = await createRepository(t); + await writeFile(join(root, 'test/unit/math.test.js'), "throw new Error('must not execute in list mode');"); + + // When the CLI is invoked in list mode through an argument array. + const { stdout, stderr } = await execute(process.execPath, [cli, '--list'], { cwd: root }); + const plan = JSON.parse(stdout); + + // Then stdout is one valid plan and the throwing test is only selected, not executed. + assert.equal(plan.mode, 'affected'); + assert.deepEqual(plan.nodeTests, ['test/unit/math.test.js']); + assert.equal(stderr, ''); +}); + +test('user executes exact Node paths and escaped browser path filters without a shell', () => { + // Given selected filenames containing spaces, newlines, and regular-expression characters. + const plan = { nodeTests: ['test/unit/a \nfile.test.js'], browserTests: ['e2e/app/specs/panel[one].pw.js'] }; + + // When child-process commands are constructed. + const commands = runnerCommands(plan, { nodeExecutable: '/runtime/node', playwrightCli: '/runtime/playwright/cli.js' }); + + // Then Node receives literal paths in its payload and Playwright treats brackets literally. + assert.deepEqual(commands, [ + { command: '/runtime/node', args: [ + fileURLToPath(new URL('../../scripts/test-selection/node-runner.mjs', import.meta.url)), + JSON.stringify({ files: ['test/unit/a \nfile.test.js'] }), + ] }, + { command: '/runtime/node', args: ['/runtime/playwright/cli.js', 'test', '(?:^|/)e2e/app/specs/panel\\[one\\]\\.pw\\.js$'] }, + ]); +}); + +test('user sees a failed selected runner stop execution before later commands', async (t) => { + // Given a test-owned directory and two commands where the first exits unsuccessfully. + const root = await createRepository(t); + const commands = [ + { command: process.execPath, args: ['-e', 'process.exit(7)'] }, + { command: 'a-later-command-that-must-not-run', args: [] }, + ]; + + // When selected runners execute in sequence. + const execution = runCommands(commands, { root }); + + // Then the first failed exit is reported rather than continuing to another runner. + await assert.rejects(execution, /exit code 7/); +}); + +for (const [kind, selected, other] of [ + ['brackets', 'case[one].test.js', 'caseo.test.js'], + ['asterisks', 'case*.test.js', 'casex.test.js'], + ['question marks', 'case?.test.js', 'casex.test.js'], + ['braces', 'case{one,two}.test.js', 'caseone.test.js'], + ['spaces and newlines', 'case (one)\nfile.test.js', 'unselected.test.js'], +]) { + test(`user executes the literal selected filename containing ${kind}`, async (t) => { + // Given a selected literal filename and an unrelated file that must never execute. + const root = await mkdtemp(join(tmpdir(), 'userscripts-exact-node-')); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile(join(root, selected), ` + import test from 'node:test'; + import { writeFileSync } from 'node:fs'; + test('user executes the selected file', () => writeFileSync('executed.txt', ${JSON.stringify(selected)})); + `); + await writeFile(join(root, other), "throw new Error('unselected file executed');"); + const [command] = runnerCommands({ nodeTests: [selected], browserTests: [] }); + + // When the actual selected runner starts outside the parent test runner's environment. + const { stdout } = await execute(command.command, command.args, { cwd: root, env: {} }); + + // Then only the literal file's test executes successfully. + assert.equal(await readFile(join(root, 'executed.txt'), 'utf8'), selected); + assert.match(stdout, /pass 1/); + assert.match(stdout, /fail 0/); + }); +} + +for (const kind of ['failed', 'missing']) { + test(`user receives a failing exit when the exact selected test file is ${kind}`, async (t) => { + // Given a selected file is absent or contains a real failing assertion. + const root = await mkdtemp(join(tmpdir(), 'userscripts-exact-node-')); + t.after(() => rm(root, { recursive: true, force: true })); + if (kind === 'failed') await writeFile(join(root, 'selected.test.js'), ` + import test from 'node:test'; + import assert from 'node:assert/strict'; + test('user sees a failing assertion', () => assert.fail('intentional runner failure')); + `); + const [command] = runnerCommands({ nodeTests: ['selected.test.js'], browserTests: [] }); + + // When the actual selected runner attempts that exact file. + const execution = execute(command.command, command.args, { cwd: root, env: {} }); + + // Then the child process reports failure instead of a successful empty selection. + await assert.rejects(execution, error => { + assert.equal(error.code, 1); + assert.match(error.stdout + error.stderr, kind === 'failed' ? /intentional runner failure/ : /selected\.test\.js/); + return true; + }); + }); +} From 9715feb35073cedce01c62f42cc4bb1aad02a5a2 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:35:55 +0800 Subject: [PATCH 2/2] test: complete behavioral migration and enforce coverage gates Migrate the remaining 83 legacy Node test files, remove method-replacement and fixed-wait allowances, and replace runtime source checks with observed behavior. Preserve fixes exposed by the new entrypoint regressions. Validate all 2,078 Node tests and 364 offline Chromium scenarios. Enforce 90% branch coverage across all 82 production files and each critical module; the final retained-evidence lower bound is 9,179/10,184 branches (90.13%). --- docs/test-coverage.md | 110 +- docs/test-migration-map.md | 222 ++ docs/test-policy.md | 84 +- .../fixtures/account-rebalance-api.js | 99 + .../fixtures/binance-futures.js | 164 +- .../helpers/account-lifecycle-host.js | 188 ++ .../scenarios/cancel-current-symbol.js | 40 + .../specs/account-lifecycle-behavior.pw.js | 388 +++ .../specs/account-rebalance-behavior.pw.js | 216 ++ .../active-ladder-context-behavior.pw.js | 289 +++ .../specs/cancel-boundaries-behavior.pw.js | 355 +++ .../cancel-confirmed-stop-behavior.pw.js | 175 ++ .../specs/cancel-observation-boundaries.pw.js | 118 + .../continuous-chart-saves-behavior.pw.js | 275 +++ .../specs/continuous-readiness-behavior.pw.js | 672 ++++++ .../specs/coverage-merge.pw.js | 75 + .../specs/draft-preparation-boundaries.pw.js | 151 ++ .../specs/ladder-plan-behavior.pw.js | 194 ++ .../specs/ladder-replacement-behavior.pw.js | 158 ++ .../specs/native-submit-boundaries.pw.js | 173 ++ .../specs/order-capacity-behavior.pw.js | 252 ++ .../specs/order-entry-wiring-behavior.pw.js | 163 ++ .../specs/order-submit-behavior.pw.js | 266 +++ .../panel-host-transition-boundaries.pw.js | 234 ++ .../specs/panel-lifecycle-behavior.pw.js | 692 ++++++ .../specs/precision-bootstrap-behavior.pw.js | 328 +++ .../precision-selection-boundaries.pw.js | 237 ++ .../quantity-and-reprice-boundaries.pw.js | 121 + .../specs/route-recovery-behavior.pw.js | 436 ++++ .../specs/rules-and-form-boundaries.pw.js | 236 ++ .../specs/strategy29-panel-drag.pw.js | 4 +- e2e/binance-orderbook/test.js | 9 + eslint.config.js | 9 +- package-lock.json | 11 + package.json | 1 + scripts/auto_refresh.user.js | 11 +- scripts/binance-orderbook-trade.user.js | 51 +- scripts/m3u8-downloader.user.js | 10 +- scripts/test-coverage/branch-policy.json | 2 +- scripts/test-coverage/browser-snapshots.mjs | 68 + scripts/test-coverage/collect-browser.mjs | 60 +- scripts/test-coverage/gates.mjs | 6 +- scripts/test-coverage/report.mjs | 15 +- scripts/test-coverage/run.mjs | 2 +- scripts/test-policy/migration-inventory.js | 163 +- .../core/binance-native-depth-source.js | 10 +- .../core/order-feedback.js | 9 +- src/binance-orderbook-trade/index.user.js | 50 +- src/m3u8-downloader/index.user.js | 6 +- .../binance-data-panels-entrypoint.test.js | 280 +++ .../dom/binance-data-panels-responses.test.js | 360 +++ .../account-orders.test.js | 367 ++- .../chart-orders.test.js | 64 +- .../depth-profile.test.js | 170 +- .../dialog-storage-boundaries.test.js | 253 ++ .../orderbook-precision.test.js | 124 +- .../remaining-dom-boundaries.test.js | 481 ++++ .../trade-form-boundaries.test.js | 259 ++ .../trade-form.test.js | 35 +- .../usdt-rebalance-dialog.test.js | 20 +- .../compound-candidate-controller.test.js | 173 +- .../strategy27-entrypoint.test.js | 161 +- .../strategy27-event-panel.test.js | 241 +- .../tradingview-compound-layer.test.js | 99 +- .../tradingview-event-layer.test.js | 496 +++- .../chart-runtime-boundaries.test.js | 478 ++++ .../native-provider-boundaries.test.js | 217 ++ .../runtime.test.js | 230 +- .../strategy29-summary-panel.test.js | 55 +- .../summary-locale-position.test.js | 43 +- .../summary-runtime-boundaries.test.js | 249 ++ .../tradingview-bearish-alerts.test.js | 546 ++++- test/dom/binance-trading-data-footer.test.js | 101 +- test/dom/brooks-export-entrypoint.test.js | 393 ++++ .../coinmarketcap-valuation-helper.test.js | 104 +- .../data-media-remaining-boundaries.test.js | 338 +++ test/dom/m3u8-download-entrypoint.test.js | 292 +++ test/dom/m3u8-media-scan.test.js | 43 +- test/helpers/data-media-migration-host.js | 214 ++ .../data-media-migration-media-host.js | 144 ++ .../native-precision-selection-host.js | 37 + test/helpers/native-submit-feedback-host.js | 71 + test/helpers/order-entry-host-boundaries.js | 196 ++ test/helpers/orderbook-migration-errors.js | 9 + test/helpers/orderbook-migration-frames.js | 29 + .../orderbook-migration-ladder-options.js | 98 + test/helpers/strategy-migration-boundaries.js | 147 ++ .../strategy29-runtime-boundary-host.js | 202 ++ .../account-rebalance-api-contract.test.js | 67 + test/unit/auto-refresh.test.js | 175 +- ...e-data-panel-lifecycle-regressions.test.js | 396 +++- ...nance-data-panel-route-regressions.test.js | 66 +- test/unit/binance-data-panel-symbols.test.js | 36 +- test/unit/binance-fixture-contract.test.js | 382 +++ .../unit/binance-live-capture-builder.test.js | 75 +- test/unit/binance-live-capture-cli.test.js | 19 +- .../binance-live-order-scale-config.test.js | 79 +- .../binance-live-performance-probe.test.js | 57 +- test/unit/binance-live-performance.test.js | 78 +- ...inance-order-entry-host-boundaries.test.js | 150 ++ .../auto-open-leverage.test.js | 142 +- .../binance-native-depth-source.test.js | 512 +++- .../binance-page-text.test.js | 44 +- .../cancel-all-dialog.test.js | 246 +- .../cancel-dialog-decision.test.js | 64 +- .../chart-marker-save-controller.test.js | 67 +- .../chart-marker-save-entrypoints.test.js | 23 +- .../chart-orders-recovery.test.js | 43 +- .../binance-orderbook-trade/decimal.test.js | 179 +- .../depth-profile-book.test.js | 289 ++- .../depth-profile-render-cycle.test.js | 15 +- .../depth-profile-session.test.js | 205 +- .../interaction-feedback.test.js | 23 +- .../ladder-options.test.js | 320 ++- .../ladder-progress.test.js | 61 +- .../binance-orderbook-trade/ladder.test.js | 111 +- .../migration-error-boundary.test.js | 32 + .../open-order-capacity.test.js | 35 +- .../open-order-rows.test.js | 58 +- .../binance-orderbook-trade/orderbook.test.js | 71 +- .../panel-copy.test.js | 47 +- .../panel-options.test.js | 35 +- .../position-marker-boundaries.test.js | 289 +++ .../binance-orderbook-trade/precision.test.js | 267 ++- .../remaining-core-boundaries.test.js | 506 ++++ .../binance-orderbook-trade/route.test.js | 55 +- .../source-regressions.test.js | 2083 +---------------- .../status-symbol.test.js | 23 +- .../trade-form.test.js | 666 ++++-- .../tradingview-target.test.js | 54 +- .../ui-covering-array.test.js | 45 +- .../usdt-rebalance.test.js | 311 ++- .../binance-shared-route-architecture.test.js | 16 +- .../binance-signal-client-settings.test.js | 25 +- test/unit/binance-stage3-evidence.test.js | 113 +- .../compound-candidate-annotation.test.js | 22 +- .../compound-candidate-client.test.js | 78 +- .../compound-candidate-contract.test.js | 57 +- .../compound-candidate-lifecycle.test.js | 77 +- .../event-annotation.test.js | 45 +- .../live-event-client-boundaries.test.js | 226 ++ .../live-event-client.test.js | 63 +- .../live-event-contract.test.js | 93 +- .../remaining-contract-boundaries.test.js | 430 ++++ .../strategy-migration-boundaries.test.js | 186 ++ .../bearish-bollinger-pattern.test.js | 141 +- .../coordination.test.js | 43 +- .../entry-sandbox.test.js | 5 +- .../pattern-and-client-boundaries.test.js | 208 ++ .../public-boundary-contracts.test.js | 171 ++ .../remote-summary-client.test.js | 119 +- ...remote-summary-contract-boundaries.test.js | 202 ++ .../remote-summary-contract.test.js | 112 +- .../remote-summary-controller.test.js | 75 +- .../runtime-boundary-host.test.js | 216 ++ .../source-regressions.test.js | 68 +- test/unit/binance-symbol.test.js | 43 +- test/unit/binance-ui-workflow.test.js | 5 +- .../brooks-export-status-boundaries.test.js | 154 ++ test/unit/brooks-media-audit.test.js | 61 +- test/unit/brooks-media-download.test.js | 109 +- test/unit/brooks-media-import-index.test.js | 28 +- test/unit/coverage-browser-snapshots.test.js | 100 + test/unit/coverage-gates.test.js | 30 +- test/unit/data-media-host-contract.test.js | 172 ++ .../data-media-remaining-boundaries.test.js | 214 ++ .../m3u8-downloader-course-export.test.js | 315 ++- .../native-precision-selection-host.test.js | 78 + test/unit/native-submit-feedback-host.test.js | 75 + test/unit/signal-gateway-bridge.test.js | 33 +- test/unit/spa-route-change.test.js | 20 +- test/unit/test-policy.test.js | 24 +- test/unit/userscript-metadata-icons.test.js | 15 +- test/unit/userscript-release-contract.test.js | 79 +- 174 files changed, 23953 insertions(+), 4321 deletions(-) create mode 100644 docs/test-migration-map.md create mode 100644 e2e/binance-orderbook/fixtures/account-rebalance-api.js create mode 100644 e2e/binance-orderbook/helpers/account-lifecycle-host.js create mode 100644 e2e/binance-orderbook/specs/account-lifecycle-behavior.pw.js create mode 100644 e2e/binance-orderbook/specs/account-rebalance-behavior.pw.js create mode 100644 e2e/binance-orderbook/specs/active-ladder-context-behavior.pw.js create mode 100644 e2e/binance-orderbook/specs/cancel-boundaries-behavior.pw.js create mode 100644 e2e/binance-orderbook/specs/cancel-confirmed-stop-behavior.pw.js create mode 100644 e2e/binance-orderbook/specs/cancel-observation-boundaries.pw.js create mode 100644 e2e/binance-orderbook/specs/continuous-chart-saves-behavior.pw.js create mode 100644 e2e/binance-orderbook/specs/continuous-readiness-behavior.pw.js create mode 100644 e2e/binance-orderbook/specs/draft-preparation-boundaries.pw.js create mode 100644 e2e/binance-orderbook/specs/ladder-plan-behavior.pw.js create mode 100644 e2e/binance-orderbook/specs/ladder-replacement-behavior.pw.js create mode 100644 e2e/binance-orderbook/specs/native-submit-boundaries.pw.js create mode 100644 e2e/binance-orderbook/specs/order-capacity-behavior.pw.js create mode 100644 e2e/binance-orderbook/specs/order-entry-wiring-behavior.pw.js create mode 100644 e2e/binance-orderbook/specs/order-submit-behavior.pw.js create mode 100644 e2e/binance-orderbook/specs/panel-host-transition-boundaries.pw.js create mode 100644 e2e/binance-orderbook/specs/panel-lifecycle-behavior.pw.js create mode 100644 e2e/binance-orderbook/specs/precision-bootstrap-behavior.pw.js create mode 100644 e2e/binance-orderbook/specs/precision-selection-boundaries.pw.js create mode 100644 e2e/binance-orderbook/specs/quantity-and-reprice-boundaries.pw.js create mode 100644 e2e/binance-orderbook/specs/route-recovery-behavior.pw.js create mode 100644 e2e/binance-orderbook/specs/rules-and-form-boundaries.pw.js create mode 100644 scripts/test-coverage/browser-snapshots.mjs create mode 100644 test/dom/binance-data-panels-entrypoint.test.js create mode 100644 test/dom/binance-data-panels-responses.test.js create mode 100644 test/dom/binance-orderbook-trade/dialog-storage-boundaries.test.js create mode 100644 test/dom/binance-orderbook-trade/remaining-dom-boundaries.test.js create mode 100644 test/dom/binance-orderbook-trade/trade-form-boundaries.test.js create mode 100644 test/dom/binance-strategy29-bollinger/chart-runtime-boundaries.test.js create mode 100644 test/dom/binance-strategy29-bollinger/native-provider-boundaries.test.js create mode 100644 test/dom/binance-strategy29-bollinger/summary-runtime-boundaries.test.js create mode 100644 test/dom/brooks-export-entrypoint.test.js create mode 100644 test/dom/data-media-remaining-boundaries.test.js create mode 100644 test/dom/m3u8-download-entrypoint.test.js create mode 100644 test/helpers/data-media-migration-host.js create mode 100644 test/helpers/data-media-migration-media-host.js create mode 100644 test/helpers/native-precision-selection-host.js create mode 100644 test/helpers/native-submit-feedback-host.js create mode 100644 test/helpers/order-entry-host-boundaries.js create mode 100644 test/helpers/orderbook-migration-errors.js create mode 100644 test/helpers/orderbook-migration-frames.js create mode 100644 test/helpers/orderbook-migration-ladder-options.js create mode 100644 test/helpers/strategy-migration-boundaries.js create mode 100644 test/helpers/strategy29-runtime-boundary-host.js create mode 100644 test/unit/account-rebalance-api-contract.test.js create mode 100644 test/unit/binance-order-entry-host-boundaries.test.js create mode 100644 test/unit/binance-orderbook-trade/migration-error-boundary.test.js create mode 100644 test/unit/binance-orderbook-trade/position-marker-boundaries.test.js create mode 100644 test/unit/binance-orderbook-trade/remaining-core-boundaries.test.js create mode 100644 test/unit/binance-strategy27-events/live-event-client-boundaries.test.js create mode 100644 test/unit/binance-strategy27-events/remaining-contract-boundaries.test.js create mode 100644 test/unit/binance-strategy27-events/strategy-migration-boundaries.test.js create mode 100644 test/unit/binance-strategy29-bollinger/pattern-and-client-boundaries.test.js create mode 100644 test/unit/binance-strategy29-bollinger/public-boundary-contracts.test.js create mode 100644 test/unit/binance-strategy29-bollinger/remote-summary-contract-boundaries.test.js create mode 100644 test/unit/binance-strategy29-bollinger/runtime-boundary-host.test.js create mode 100644 test/unit/brooks-export-status-boundaries.test.js create mode 100644 test/unit/coverage-browser-snapshots.test.js create mode 100644 test/unit/data-media-host-contract.test.js create mode 100644 test/unit/data-media-remaining-boundaries.test.js create mode 100644 test/unit/native-precision-selection-host.test.js create mode 100644 test/unit/native-submit-feedback-host.test.js diff --git a/docs/test-coverage.md b/docs/test-coverage.md index ec02204..6f85af4 100644 --- a/docs/test-coverage.md +++ b/docs/test-coverage.md @@ -42,6 +42,29 @@ Partial functions extracted from a source file and quoted copies of installer text receive no credit for executing that source file. Browser collection also recognizes complete original modules loaded through Blob URLs. +Browser coverage uses one public CDP session per page and caches script bytes when +Chromium parses them. A real reload must go through `reloadPageWithCoverage` in +`e2e/binance-orderbook/test.js`: it takes a precise checkpoint before discarding +the outgoing document. Raw checkpoints remain in the capture. Only snapshots +with the same session, script ID, URL, and exact source bytes are merged; a new +document keeps its own identity even when it loads the same URL. + +Chromium can return positive function-call counts without block ranges after +document teardown. Those calls cannot establish which branches ran. The report +preserves them in `blockEvidenceUnavailable` with raw-capture provenance and +gives them no additional branch credit. Each such function must already have a +captured detailed or zero record with the same function bounds; otherwise the +collector fails, because dropping it could make the reporter infer execution +from the enclosing script. No zero record or execution count is invented. + +When coarse calls exist, `metricInterpretation` is +`retained-evidence-lower-bound`: the displayed metrics describe retained evidence, +not all actual calls. Capture completeness and block-evidence completeness are +separate. A lower bound at or above 90% proves the repository target was reached; +a lower bound below 90% does not establish the exact actual coverage. Isolated +Chromium proofs verify ordinary checkpoint counts and a real reload through the +collector, splitter, source map, and final coverage report. + One browser script can contain several installers. The collector validates exact installer segments against JavaScript statement boundaries and keeps their V8 execution ranges associated with the original bytes. Shared originals appear @@ -55,25 +78,21 @@ invalidates completeness. The one explicit collector self-test file uses virtual code; it must pass but does not contribute production coverage. Source-map and capture tests validate these boundaries independently. -## Target and Staged Gate - -The final repository target is **90% branch coverage across the complete scope**. -The rollout also uses an explicit **66.5% aggregate floor** and requires each -migrated critical module to reach 90%. The floor rounds the initial 66.58% -measurement down to one decimal place. The checked-in threshold policy names -those files; it does not exclude other production sources from the aggregate. - -The staged gate and final target are separate facts. A run may pass the staged -gate while still reporting `meetsBranchTarget: false`. It must not be described as -reaching the repository target. Threshold decisions use exact covered/total -counts, not rounded display percentages. New failures, recovery paths, and -boundary conditions should close the remaining gap; do not shrink the source -scope, remove a guard, or invent invalid business states to improve the metric. - -`npm run test:coverage -- --require-target` also requires the final aggregate 90% -target. `npm run test:coverage -- --report-only` collects diagnostic evidence -without applying thresholds; CI uses the default gated command. The aggregate -floor and exact critical-source list are stored in +## Repository Gate + +The default command requires **90% branch coverage across the complete scope** +and **90% in each of seven critical orderbook modules**. The temporary 66.5% +migration floor has been replaced. The critical-source list adds checks; it does +not exclude other production sources from the aggregate. + +Threshold decisions use exact covered/total counts, not rounded display +percentages. New failures, recovery paths, and boundary conditions should close +coverage gaps; do not shrink the source scope, remove a guard, or invent invalid +business states to improve the metric. + +`npm run test:coverage -- --report-only` collects diagnostic evidence without +applying thresholds; CI uses the default gated command. The aggregate threshold +and exact critical-source list are stored in [`branch-policy.json`](../scripts/test-coverage/branch-policy.json). The policy applies to merged Node and browser results. The complete pipeline runs @@ -84,29 +103,45 @@ workflows retain their independent checks. ## Reports and Interpretation -The complete baseline on 2026-09-16 used Node 24.16.0, 97 Node test files -(1,326 passing tests), and 92 passing Chromium scenarios. The browser total -contains 87 production scenarios and five collector proofs. All required -captures completed. The merged denominator contains 82 distinct production -source files and **6,765 / 10,160 covered branches (66.58%)**. -The six unmapped VM entries are executions of the three historical installers in -`test/fixtures/strategy29-migration/`, each loaded twice by the settings migration -tests. They remain visible in the report and receive no current-source credit. +The completed migration run on 2026-09-16 used Node 24.16.0, 126 Node test files +(**2,078 passing tests**), and **364 passing Chromium scenarios**. The browser +total contains 357 production scenarios and seven collector proofs. All required +captures completed, without skipped or retried scenarios. The merged denominator +contains 82 distinct production source files and **9,179 / 10,184 covered +branches (90.13%)**. The aggregate gate and all seven critical-module gates pass. + +The retained local evidence is +[`run-5M5oY5/report/index.html`](../test-results/coverage/run-5M5oY5/report/index.html), +with exact counts, capture completion, and source hashes in +[`coverage-summary.json`](../test-results/coverage/run-5M5oY5/report/coverage-summary.json). +All 82 recorded source hashes matched the workspace after collection. These +generated reports are local test artifacts and are not committed. + +This run records `metricInterpretation: retained-evidence-lower-bound` and 49 +function-call records without block evidence. Those records remain auditable +but add no branch credit. The retained lower bound itself exceeds 90%. Six +unmapped Node VM entries also remain visible and receive no current-source +credit. | Migrated critical source | Covered / total branches | Coverage | | --- | ---: | ---: | | `core/cancel-orders.js` | 74 / 74 | 100% | -| `core/close-action.js` | 39 / 42 | 92.86% | +| `core/close-action.js` | 42 / 42 | 100% | | `core/close-ladder-recovery.js` | 39 / 39 | 100% | -| `core/continuous-ladder.js` | 108 / 119 | 90.76% | -| `core/order-feedback.js` | 194 / 204 | 95.10% | +| `core/continuous-ladder.js` | 110 / 119 | 92.44% | +| `core/order-feedback.js` | 194 / 200 | 97.00% | | `core/quantity.js` | 26 / 27 | 96.30% | -| `core/chart-save-coalescer.js` | 247 / 273 | 90.48% | +| `core/chart-save-coalescer.js` | 253 / 273 | 92.67% | + +These paths are under `src/binance-orderbook-trade/`. This is a dated measurement, +not a promise about later revisions; subsequent reports must verify their own +source hashes, complete captures, and exact thresholds. -These paths are under `src/binance-orderbook-trade/`. The baseline passes the -staged policy and fails the final aggregate 90% target. This is a dated -measurement, not a promise about later revisions; current reports record source -hashes so their scope can be verified. +For historical comparison, the first-stage baseline earlier on 2026-09-16 used +97 Node test files (1,326 tests) and 92 Chromium scenarios, with **6,765 / 10,160 +covered branches (66.58%)** across the same 82-file source scope. It passed only +the former 66.5% migration floor. That baseline and its smaller branch count do +not describe the completed migration or the currently enforced 90% gate. Each collection creates `test-results/coverage/run-*/` with the raw captures, Node test output, and a `report/` directory. `test-results/coverage/latest.json` @@ -116,13 +151,14 @@ than assuming an older report covers current edits. The HTML entry is `report/index.html`. `report/coverage-summary.json` records: - Node version, executed layers, source list, and original source identities; -- exact branch, statement, function, line, and byte metrics; +- branch, statement, function, line, and byte metrics for retained evidence, + together with their interpretation and any unavailable block evidence; - the final target and whether it was met; - capture completeness counts and any executed entries that could not be mapped. An unmapped historical installer or extracted snippet is visible as unmapped evidence and is not substituted for current-source execution. Tests passing, -capture completion, meeting a staged threshold, and meeting the final 90% target +capture completion, detailed block evidence, and meeting the 90% repository target are distinct outcomes. Browser fixture coverage is L2 evidence; Tampermonkey installation and current Binance behavior still require their own authorized L3/L4 checks. diff --git a/docs/test-migration-map.md b/docs/test-migration-map.md new file mode 100644 index 0000000..c1644b2 --- /dev/null +++ b/docs/test-migration-map.md @@ -0,0 +1,222 @@ +# Behavioral Test Migration Map + +This records the second migration stage from `176d9ff`. Its 83 legacy Node +files, seven method-replacement allowances, and 31 fixed-wait allowances have +been removed from the executable inventory. Every existing and new executable +test file now uses the strict behavior policy. The separate real performance +observer tail remains an explicitly tested host contract. + +The former orderbook source-regression file contained 71 top-level checks. +All 71 original titles are preserved below, in their original order, with named +current tests. Nine retained distribution, CSS, and component-boundary tests +cover ten original rows: 1, 2, 3, 6, 7, 23, 24, 25, 26, and 29. Rows 25 and 26 +share one retained test. The other 61 rows point to runtime behavior. + +This is a traceability map, not a branch-coverage result. All 71 rows have +identified replacement evidence without an outstanding contract gap recorded +here. No original title is missing from the map. A pure helper test is not a +substitute for verifying its entrypoint wiring. + +## Completed verification + +The complete run on 2026-09-16 passed **2,078 Node tests across 126 files** and +**364 Chromium scenarios** (357 production scenarios and seven collector +proofs), with no skipped or retried scenarios. All production captures completed. +Full-source branch coverage is **9,179 / 10,184 (90.13%)** across 82 files; +the aggregate 90% gate and each of the seven critical-module gates pass. +This percentage is a retained-evidence lower bound: coarse Chromium teardown +calls receive no additional branch credit. + +The local [HTML report](../test-results/coverage/run-5M5oY5/report/index.html) and +[machine-readable summary](../test-results/coverage/run-5M5oY5/report/coverage-summary.json) +record the complete run and source identities. All 82 production source hashes +matched the workspace after collection. These generated artifacts are not +committed; [Source Coverage](test-coverage.md) records the measured critical +modules and explains how to reproduce the gate. + +The strict repository-wide test lint and affected build and syntax checks +passed. Independent read-only reviews passed for the migrated contracts, +production regression fixes, and coverage collector. The final rule-response +and browser-frame synchronization follow-up also passed independent review: +cooldown assertions finish before the clock resumes, and the released response +must produce the exact quantity, formula, request count, and zero-order result. +No review finding remains unresolved. + +## Targeted follow-up results + +The additional scenarios below have completed their targeted runs. Counts are +per-file results, not a combined coverage or final-review verdict. + +| Original rows | Dedicated runtime evidence | Targeted result | +| --- | --- | --- | +| 59, 60 | [Active context]: ordinary and continuous ladders retain confirmed work when native precision, ratio, order count, or gap changes; continuous recovery rebuilds after its full cooldown. | 8/8 passed. | +| 50, 66 | [Precision bootstrap]: wait for bid/ask/precision readiness; failed missing or malformed menus require explicit refresh; a late old-symbol portal cannot replace current shortcuts. | 6/6 passed. | +| 34, 40 | [Continuous chart saves]: accepted drawing bursts, one final round save, partial-round Stop, later rejection, and restoration of ordinary chart-saving ownership. | 4/4 passed. | +| 39, 40 | [Confirmed cancellation Stop]: the first and second row removals remain unconfirmed at 239 ms; at 240 ms the rendered confirmed count precedes a same-time Stop, with no next cancellation or recovery submission. | 2/2 passed. | +| 19, 32, 36 | [Quantity and reprice]: zero-balance feedback at 239/240 ms, missing or zero-with-funds quantity at 1,199/1,200 ms, and the fifth maker rejection's full 2,999/3,000 ms pause before repricing only unfinished orders. | 4/4 passed. | +| 9, 22, 53 | [Entry wiring]: an uncommitted native Post Only selection prevents field writes and submission; three watchdog cycles do not scan or measure 1,000 added book rows; the first confirmed close quantity renders before the pending 50 ms debounce. | 3/3 passed; three repeated runs passed 9/9. Host boundary contracts passed 4/4. | + +These 27 targeted cases are no longer pending and are included in the completed +combined run above. Their file-level results are separate evidence; the complete +run establishes the aggregate coverage target. + +## Completed entrypoint follow-ups + +- **Row 9 — Post Only transition:** a native Limit selection remains active + for 650 ms after the real ladder requests Post Only. No input write or + submission occurs before the separate native commit. The exact three + acknowledged quantities are 0.66, 0.66, and 0.68. +- **Row 22 — scan and geometry boundaries:** three actual watchdog cycles + perform zero book scans, zero book geometry reads, and zero panel mutations. + The panel and spacer each retain one necessary layout read per cycle. +- **Row 53 — immediate close-quantity observation:** native mode and button + state settle first. A later native class mutation starts the generic + debounce; quantity publication follows after 16 ms. The next frame updates + the direction and ladder controls at 32 ms, before the 50 ms deadline, + through the real observer without explicitly calling `renderPanel`. + +## Orderbook source checks + +Numbers preserve the original declaration order, and previous titles are +copied exactly from `176d9ff`. Current test titles below are exact executable +names, including expanded parameter values where one named example represents +a documented family. A row can retain a static contract alongside runtime +behavior. No partial replacement remains in this map. + +| No. | Previous check | Replacement evidence | +| --- | --- | --- | +| 1 | source and generated userscript versions stay synchronized | `user installs the same version and update endpoints declared by the editable source` ([Source]). | +| 2 | route changes are event-driven with one low-frequency watchdog | `user receives an event-driven route integration with one declared watchdog` ([Source]); `user leaves a futures route while an order is pending without allowing a later ladder submission` ([Routes]). | +| 3 | permanent trade-mode observer is scoped to the trade tab root | `user receives permanent native observers scoped to their owning controls` ([Source]). | +| 4 | close snapshot validation refreshes button scope before checking close actions | `user reacquires a replaced native form root while retaining the same panel and multiplier` ([Panel]); `user cannot resolve a close action when native buttons and quantity labels are absent` ([Panel]). | +| 5 | fixed ladder panel avoids rebuilding unchanged body markup | `user keeps an unchanged panel free of DOM writes during repeated stable renders` ([Panel]). | +| 6 | panel primary values and ladder selections share the Binance emphasis standard | `user receives shared emphasis styles for numeric values and selected options` ([Source]); `user sees the fixed panel layout in open mode` ([Visual]). | +| 7 | panel buttons inherit one scoped disabled-state contract | `user receives disabled styles only for panel buttons and explicitly owned native controls` ([Source]); `user starts closing a short position while unavailable close-long controls stay disabled` ([Controls]). | +| 8 | route watcher owns non-trading page pause instead of business timers spinning forever | `user leaving futures stops readiness position polls and does not revive the continuous session on return` ([Routes]). | +| 9 | trade mode and Post Only switches wait for observed state instead of fixed sleeps | `user switches between native open and close modes without moving the direction controls` ([Controls]); `user waits for the native Post Only selection before the ladder can submit its exact orders` ([Entry wiring]). | +| 10 | ladder execution waits for the current semantic action button before every submit | `user completes a ladder only after each native submit control becomes ready again` ([Controls]); `user waits for a disabled close button and then receives a complete cooldown` ([Continuous]). | +| 11 | trade input synchronization confirms live controlled values instead of sleeping | `user confirms controlled trade inputs only after consecutive stable frames` ([Trade form]); `user rejects trade inputs that keep rolling back before their virtual deadline` ([Trade form]). | +| 12 | labeled quantity matching resets its global regexp for every DOM node | `user reads both open quantities from one shared label beside native buttons` ([Panel]) and its separate-direction label variant. | +| 13 | visible SVG controls do not require offset dimensions | `user must confirm each native row dialog before replacement can continue` ([Replacement]); the four-direction row-replacement family clicks the actual SVG controls. | +| 14 | ladder retries with restricted open-order replacement after supported feedback | `user replaces only the required current-symbol basic 开多 rows before completing the ladder` ([Replacement]) and its other three directions; `user keeps existing orders when replacement finds insufficient matching quantity` ([Replacement]). | +| 15 | only continuous close routes confirmed conflicts through position-based recovery | `user retains partial close progress through reduce-only rejections until the position is confirmed flat` ([Close recovery]); `user stops a close ladder on a conflicting reduce-only response without assuming the position was closed` ([Submit]). | +| 16 | continuous close recovers a confirmed max-open-orders rejection by freeing farthest slots | `user frees only the fifty farthest same-direction slots with a native row mount delay of 120 ms` ([Capacity]); `user does not cancel another capacity batch after a second confirmed rejection in the same round` ([Capacity]). | +| 17 | capacity recovery waits through an unrendered open-orders list instead of treating it as empty | `user waits for the native order list to mount before choosing capacity cancellations` ([Capacity]); `user finishes delayed scroll restoration with the original Conditional list after freeing capacity` ([Capacity]). | +| 18 | capacity recovery skips an unconfirmed row cancellation without claiming the slot was released | `user retains one confirmed released slot when the next native cancellation remains unconfirmed` ([Capacity]). | +| 19 | open and close ladders reprice only remaining orders after explicit maker conflicts | `user reprices only the three remaining OPEN_LONG orders after a native maker rejection` ([Submit]) and its other three directions; `user reprices only the three unfinished orders from the current book after the full fifth-rejection pause` ([Quantity and reprice]) checks 2,999/3,000 ms after the fifth rejection, success sequences `[1, 2, 8, 9, 10]`, and the refreshed remaining prices. | +| 20 | an in-flight order request receives a separate response deadline | `user sees one order remain pending until its matching Binance response succeeds` ([Controls]); `user advances an unconfirmed continuous order to a new round without counting a late success` ([Continuous]). | +| 21 | bapi headers wake leverage checks without startup or 500ms polling sleeps | `user wakes a pending leverage check when native headers arrive after 1000 milliseconds` ([Account lifecycle]) and the 5,500 ms variant. | +| 22 | stable panel renders avoid repeated orderbook scans and layout writes | `user keeps stable watchdog refreshes independent of orderbook size and limits panel layout checks` ([Entry wiring]); `user keeps an unchanged panel free of DOM writes during repeated stable renders` ([Panel]). | +| 23 | dynamic panel text keeps fixed single-line slots | `user receives fixed single-line layout slots for dynamic text and actions` ([Source]); `user sees the fixed panel layout in open mode` ([Visual]). | +| 24 | floating panel stays below Binance native portal overlays | `user receives a floating panel below the native Binance portal layer` ([Source]). | +| 25 | panel keeps controls in cohesive ordered semantic groups | `user receives semantic panel groups and multiplier controls in the declared visual order` ([Source]). | +| 26 | multiplier row reads as a labeled value followed by decrement and increment controls | `user receives semantic panel groups and multiplier controls in the declared visual order` ([Source]); `user decrements a multiplier only to one and repeated presses retain a single field identity` ([Panel]). | +| 27 | multiplier clicks use non-blocking local feedback without writing business status | `user increases the quantity multiplier with local feedback and unchanged operation status` ([Controls]). | +| 28 | multiplier calculation keeps the formula primary and separates the notional constraint visually | `user sanitizes multiplier typing and repairs an invalid value on blur` ([Panel]); `user sees the amount constraint separated from the formula only while an opening notional applies` ([Rules and form]); `user sees the fixed panel layout in open mode` ([Visual]). | +| 29 | direction selector is a compact mutually exclusive radio group | `user receives accessible two-direction radio markup with a shared boundary` ([Source]); `user changes open direction with arrow keys while preserving radio focus and symbol ownership` ([Panel]). | +| 30 | ladder feedback labels captured API codes without exposing bare numbers | `user can stop after five consecutive maker rejections during the declared reprice pause` ([Submit]); `user reprices only the three unfinished orders from the current book after the full fifth-rejection pause` ([Quantity and reprice]) assert labelled captured API codes. | +| 31 | ladder minimum quantity failure explains safe manual options | `user receives safe minimum-quantity guidance when OPEN_LONG cannot fit even one order` ([Plan]) and the `OPEN_SHORT`, `CLOSE_LONG`, and `CLOSE_SHORT` variants. | +| 32 | ladder actions keep only their final UI feedback visible for a minimum window | `user keeps an immediate failure pending until its feedback window is visible` ([Interaction feedback]); `user distinguishes confirmed zero balance after brief action feedback through the actual open-ladder entrypoint` ([Quantity and reprice]). | +| 33 | Option or Alt click continuously repeats close ladders only after readiness and cooldown | `user completes two close-short rounds with a full cooldown and exact cumulative progress` ([Continuous]); `user restarts the full cooldown when the close button becomes busy before it expires` ([Continuous]). | +| 34 | continuous close captures only owned order-line saves and restores the chart method | `user saves one complete chart per continuous round after every accepted order drawing settles` ([Continuous chart saves]); `user coalesces native order-removal saves while a continuous submit is pending` ([Continuous chart saves]); `user keeps another operation in control of chart saving` ([Chart saves]). | +| 35 | trade input frame synchronization reuses only its initially proven form root | `user sees that trade input resolver starts from a proven root without another document scan` ([Trade form]); `user sees that active trade inputs ignore hidden duplicate forms and remain one coherent pair` ([Trade form]). | +| 36 | open ladder stops immediately only for a confirmed zero available balance | `user distinguishes confirmed zero balance after brief action feedback through the actual open-ladder entrypoint` ([Quantity and reprice]); `user distinguishes temporarily missing quantity through the actual open-ladder entrypoint` ([Quantity and reprice]); `user distinguishes zero quantity with available balance through the actual open-ladder entrypoint` ([Quantity and reprice]). Zero balance keeps the 240 ms action-feedback minimum; missing quantity and zero quantity with funds wait the 1,200 ms quantity deadline. | +| 37 | user-facing trading failures preserve one precise reason and shared terminology | `user gets a precise refusal for a changed captured precision before a replacement plan can submit` ([Plan]); `user receives a concrete refusal when a native quantity input disappears before a price click` ([Submit]); `user keeps existing orders when replacement finds no basic orders` ([Replacement]). | +| 38 | ladder replacement cancels visible current-symbol same-direction rows up to planned quantity | `user replaces only the required current-symbol basic 开多 rows before completing the ladder` ([Replacement]) and its other three directions verify the exact cancelled IDs and preserved unrelated orders. | +| 39 | stopping a ladder aborts replacement waits before another cancel or submit click | `user can stop replacement while a native row decision is pending without another cancellation or submit` ([Replacement]); `user retains a confirmed cancellation count of 1 when Stop follows settlement before the next native row mounts` ([Confirmed cancellation Stop]). | +| 40 | stopping a ladder preserves confirmed submit and cancel progress | `user stopping inside a drawing burst preserves the partial round and restores ordinary native removal saves` ([Continuous chart saves]) preserves the second accepted submit while its drawing capture is pending; `user retains a confirmed cancellation count of 2 when Stop follows settlement before the next native row mounts` ([Confirmed cancellation Stop]) and the count-of-one variant preserve settled removals. `user stops a pending continuous order without counting its late acknowledgement` ([Continuous]) separately covers Stop before acknowledgement. | +| 41 | ladder task statuses name the active action and observed outcome | `user completes two close-short rounds with a full cooldown and exact cumulative progress` ([Continuous]); `user retains one confirmed released slot when the next native cancellation remains unconfirmed` ([Capacity]). | +| 42 | panel statuses omit the current full symbol and compact the retained interrupted symbol | `user sees that status symbols omit supported futures quote assets` ([Status symbol]); `user stops the original cancellation workflow by changing symbol during confirmation` ([Cancel]). | +| 43 | bulk cancel keeps chart orders visible and coalesces their removal saves after native confirmation | `user cancels seventy orders while chart drawings stay visible and save once at completion` ([Cancel]). | +| 44 | bulk cancel distinguishes native confirm from cancellation before clear polling | `user confirms cancellation for the current symbol while other-symbol orders survive` ([Cancel]); `user dismisses native cancellation with Escape and restores the original view` ([Cancel]); `user cannot continue cancellation through an invalid extraAction native dialog` ([Cancel]) and the backdrop/missing-primary variants. | +| 45 | chart Open Orders reload recovery remains pending until restoration succeeds | `user keeps a reload recovery record until the chart is ready and its final restored drawing is saved` ([Routes]); `user keeps the reload journal when native chart restoration cannot close its menu` ([Routes]). | +| 46 | Binance SPA locale changes rebuild only the userscript panel and preserve task state | `user preserves an active close ladder and exact round totals across SPA locale changes` ([Routes]). | +| 47 | cancel current-symbol open orders wait for confirmed clearing before restoring page state | `user sees cancellation progress while the current-symbol clear is delayed` ([Cancel]); `user receives an incomplete-cancellation result when confirmed orders never clear` ([Cancel]). | +| 48 | cancel current-symbol open orders are single-flight and follow the native dialog lifecycle | `user can click cancellation twice rapidly without opening duplicate dialogs` ([Cancel]); `user can resume a cancellation dialog after a BFCache pagehide` ([Cancel]); `user can dismiss cancellation after the host replaces the dialog subtree` ([Cancel]). | +| 49 | stable panel refreshes avoid writing unchanged text and state attributes | `user keeps an unchanged panel free of DOM writes during repeated stable renders` ([Panel]). | +| 50 | orderbook precision recommendation marks one shortcut without applying it automatically | `user sees every native precision shortcut without an automatic precision selection` ([Precision]); `user refreshes precision recommendations from the currently visible trades` ([Controls]); `user waits for bid quotes before reading a new symbol's precision menu` ([Precision bootstrap]) and the ask-quotes/precision-field variants. | +| 51 | close state is committed only for the currently observed symbol | `user replays only the latest symbol after a busy account position check` ([Account lifecycle]); `user refuses a stale ladder when symbol changes during awaited exchange-rule bootstrap` ([Routes]). | +| 52 | close execution and close-ladder sizing reject display cache | `user keeps cached close display while refusing execution until both native quantities return` ([Panel]); `user cannot submit from a cached close display after both native quantity labels disappear` ([Submit]). | +| 53 | confirmed close-quantity mutations bypass the generic trade UI debounce | `user receives the first confirmed close quantities before the generic trade-form debounce can expire` ([Entry wiring]); `user sees that recognizes only close-quantity mutations as a confirmed close snapshot` ([Trade form]). | +| 54 | pending close actions report position confirmation without starting execution | `user cannot resolve a close action when native buttons and quantity labels are absent` ([Panel]); `user cannot submit from a cached close display after both native quantity labels disappear` ([Submit]). | +| 55 | cancel flow rechecks the captured symbol before destructive click and cleanup | `user stops the original cancellation workflow by changing symbol during confirmation` ([Cancel]); `user keeps the new symbol scope when a route switch interrupts delayed scroll restoration` ([Capacity]); `user cannot cancel when a checked symbol filter still exposes another symbol row` ([Cancel boundaries]). | +| 56 | multiplier edits retain their captured symbol, mode, and orderbook precision | `user discards a stale multiplier input after a symbol transition` ([Panel]); `user discards an old multiplier input after native mode changes` ([Panel]); `user discards an old multiplier input after native precision changes` ([Panel]) and all three blur variants. | +| 57 | panel numeric options wait for a complete mode-symbol-precision context | `user cannot edit numeric panel controls while native precision is unknown` ([Panel]) and the unknown-mode variant; `user stops while price precision is missing and its return cannot revive the session` ([Continuous]). | +| 58 | precision changes invalidate edits and immediately rerender the panel | `user restores a new precision promptly and uses its profile after a complete cooldown` ([Continuous]); `user discards an old multiplier input after native precision changes` ([Panel]). | +| 59 | ladder plans fail closed when orderbook precision changes | `user stops an active ordinary ladder after native price precision changes and keeps confirmed progress` ([Active context]); `user rebuilds a continuous ladder after active native price precision changes without resuming old remaining levels` ([Active context]); `user refuses a stale ladder when precision changes during awaited exchange-rule bootstrap` ([Routes]). | +| 60 | continuous close ladders recover only from tagged pre-submit transients | `user retries only failures covered by the continuous-close recovery policy` ([Continuous core]); `user rebuilds a continuous ladder after active saved ratio changes without resuming old remaining levels` ([Active context]) and the saved-order-count/saved-price-gap variants retain the acknowledged old progress, then rebuild after the full cooldown. | +| 61 | continuous close ladders continue after an explicitly tagged unconfirmed submission | `user advances an unconfirmed continuous order to a new round without counting a late success` ([Continuous]); `user ends a single close round on an unknown submission even when a late success arrives` ([Controls]). | +| 62 | continuous close defers temporary startup, position, capacity, and open-order failures | `user waits for a disabled close button and then receives a complete cooldown` ([Continuous]); `user does not cancel another capacity batch after a second confirmed rejection in the same round` ([Capacity]); `user honors the temporary position-server recovery interval before rechecking a blocked close session` ([Continuous]). | +| 63 | continuous close backs off rate limits and unconfirmed server responses without swallowing fatal rejections | `user honors an explicit HTTP 429 retry interval before rechecking a blocked close session` ([Continuous]) with the HTTP 418, HTTP 503, explicit-zero, missing, and invalid Retry-After cases; `user gets a terminal failure for an expired authentication response while the close button is blocked` ([Continuous]) and the permanent-client-error/malformed-payload cases. | +| 64 | confirmed directional flat state ends close ladders without masking uncertain outcomes | `user can finish continuous close on confirmed flat even when the native button becomes disabled` ([Close recovery]); `user ends the session when the authoritative position has no current-symbol short quantity` ([Continuous]). | +| 65 | single-order sizing and submission retain the captured orderbook precision | `user rejects an in-flight single-order draft when its captured precision changes before native submission` ([Submit]). | +| 66 | precision shortcut selection and refresh do not commit after a symbol switch | `user keeps current-symbol shortcuts when the previous symbol portal arrives after a pending read` ([Precision bootstrap]); `user recovers a missing precision menu only by refreshing after the failed automatic attempt` ([Precision bootstrap]) and its malformed-menu variant; `user restores symbol-specific precision shortcuts after switching from A to B and back` ([Precision]). | +| 67 | busy leverage reset retains and replays the latest symbol request | `user replays only the latest symbol after a busy account position check` ([Account lifecycle]); `user replays only the latest reset after switching symbols during its final position read` ([Account lifecycle]). | +| 68 | auto leverage reset is authorized by a fresh current-symbol position response | `user requires a fresh flat position response immediately before adjusting leverage` ([Account lifecycle]). | +| 69 | account position count changes schedule symbol-specific API checks | `user refreshes current-symbol position evidence when an account position count changes` ([Account lifecycle]); `user does not repeat account HTTP checks while counts and symbol remain unchanged` ([Account lifecycle]). | +| 70 | USDT rebalance waits for global flat stability and requires zero open orders | `user qualifies for account rebalance only after the full three-second flat window and its API response` ([Account lifecycle]); `user restarts the full rebalance window when a native open order reappears` ([Account lifecycle]) and the native-position/stale-response cases. | +| 71 | USDT rebalance uses direct Binance BAPI only after one explicit plan confirmation | `user completes exactly two USDT transfers only after confirming the complete account plan` ([Rebalance]); `user stops account transfers when the authoritative position changes during confirmation` ([Rebalance]) and the changed-balance case. | + +[Source]: ../test/unit/binance-orderbook-trade/source-regressions.test.js +[Routes]: ../e2e/binance-orderbook/specs/route-recovery-behavior.pw.js +[Panel]: ../e2e/binance-orderbook/specs/panel-lifecycle-behavior.pw.js +[Visual]: ../e2e/binance-orderbook/specs/panel-visual-contract.pw.js +[Controls]: ../e2e/binance-orderbook/specs/control-flows.pw.js +[Trade form]: ../test/unit/binance-orderbook-trade/trade-form.test.js +[Submit]: ../e2e/binance-orderbook/specs/order-submit-behavior.pw.js +[Replacement]: ../e2e/binance-orderbook/specs/ladder-replacement-behavior.pw.js +[Capacity]: ../e2e/binance-orderbook/specs/order-capacity-behavior.pw.js +[Close recovery]: ../e2e/binance-orderbook/specs/close-ladder-recovery.pw.js +[Continuous]: ../e2e/binance-orderbook/specs/continuous-readiness-behavior.pw.js +[Account lifecycle]: ../e2e/binance-orderbook/specs/account-lifecycle-behavior.pw.js +[Plan]: ../e2e/binance-orderbook/specs/ladder-plan-behavior.pw.js +[Interaction feedback]: ../test/unit/binance-orderbook-trade/interaction-feedback.test.js +[Chart saves]: ../test/unit/binance-orderbook-trade/chart-save-coalescer.test.js +[Cancel]: ../e2e/binance-orderbook/specs/cancel-current-symbol.pw.js +[Cancel boundaries]: ../e2e/binance-orderbook/specs/cancel-boundaries-behavior.pw.js +[Status symbol]: ../test/unit/binance-orderbook-trade/status-symbol.test.js +[Precision]: ../e2e/binance-orderbook/specs/precision-controls.pw.js +[Continuous core]: ../test/unit/binance-orderbook-trade/continuous-ladder.test.js +[Rebalance]: ../e2e/binance-orderbook/specs/account-rebalance-behavior.pw.js +[Active context]: ../e2e/binance-orderbook/specs/active-ladder-context-behavior.pw.js +[Precision bootstrap]: ../e2e/binance-orderbook/specs/precision-bootstrap-behavior.pw.js +[Continuous chart saves]: ../e2e/binance-orderbook/specs/continuous-chart-saves-behavior.pw.js +[Confirmed cancellation Stop]: ../e2e/binance-orderbook/specs/cancel-confirmed-stop-behavior.pw.js +[Quantity and reprice]: ../e2e/binance-orderbook/specs/quantity-and-reprice-boundaries.pw.js +[Entry wiring]: ../e2e/binance-orderbook/specs/order-entry-wiring-behavior.pw.js +[Rules and form]: ../e2e/binance-orderbook/specs/rules-and-form-boundaries.pw.js +[browser fixture]: ../e2e/binance-orderbook/fixtures/binance-futures.js + +## Other migrated suites + +- Orderbook pure logic and DOM suites execute real decimal, quantity, plan, + recovery, cancellation, precision, depth and controlled-form behavior. +- Strategy 27 and 29 suites execute live-client, chart-layer, annotation, + persistence and stale-response behavior through contract-tested external boundaries. +- Trading/CMC and media suites execute complete entrypoints for route/visibility + lifecycle, network responses, download jobs and Brooks export state. + +Clock advances, explicit response gates and observable mutation completion +replace elapsed real-time waits. Fake network, storage, crypto, chart, clipboard +and browser host behavior has independent contract tests. + +## Evidence limits + +All Binance, wallet, order and media operations in these suites use offline +fixtures. No test result certifies the current live site or grants financial +authorization. + +The confirmed-cancellation Stop cases observe the real published release +status after stable confirmation and accounting. The delayed native list mount +leaves a genuine asynchronous boundary before the next row action. They do not +claim to insert Stop into the unobservable private microtask between the +confirmation helper returning and its caller recording the cancellation. +The drawing-burst Stop case separately checks a successful native submit +whose final drawing capture is still pending; the late-acknowledgement case +checks the opposite event order. + +Complete source coverage and the passing branch gate are documented in +[Source Coverage](test-coverage.md). Migration verification and the required +independent reviews are complete; this mapping does not change coverage policy +or thresholds. Live-site and Tampermonkey validation remain separate from this +offline migration acceptance. diff --git a/docs/test-policy.md b/docs/test-policy.md index ff33e8b..4a4d8a7 100644 --- a/docs/test-policy.md +++ b/docs/test-policy.md @@ -15,7 +15,7 @@ Use the Node version pinned by `.nvmrc`. The relevant commands are: | `npm run test:ui` | Offline Playwright scenarios using the generated userscripts and controlled host fixtures. | | `npm run test:affected` | The repository's affected-test selector; consult its selection output before interpreting the result. | | `npm run test:coverage:node` | Production-source coverage from the Node layer. | -| `npm run test:coverage` | Complete Node and browser source coverage, with the staged aggregate and critical-module gates. | +| `npm run test:coverage` | Complete Node and browser source coverage, with the 90% aggregate and critical-module gates. | The specialized validation paths, builds, and any required live checks remain in [Userscript Validation](userscript-validation.md) and the linked script manuals. @@ -24,7 +24,7 @@ the current live Binance DOM or grant permission for financial actions. ## Behavior Names and Stages -New test files and all `e2e/**/specs/**/*.pw.js` scenarios use a title beginning with +All Node test files and `e2e/**/specs/**/*.pw.js` scenarios use a title beginning with `user `. Describe the observable behavior in the rest of the title. A parameterized title such as `` `user sees ${quantity} accepted orders` `` keeps a static `user ` prefix. @@ -114,53 +114,21 @@ such as `assert.match(source, /@downloadURL/)` remain valid. Runtime behavior should be checked through results and effects instead of merely searching for its implementation text. -## Staged Migration and Exact Allowances +## Completed Inventory and Exact Host Contracts -The first strict Node behavior group covers these orderbook suites: +The first migration inventory contained 83 existing Node files, seven method +replacement calls, and 31 fixed waits. Both legacy lists in +[`scripts/test-policy/migration-inventory.js`](../scripts/test-policy/migration-inventory.js) +are now empty. Every existing and new suite is subject to the same BDD, assertion, +mock, and wait rules; there is no legacy ESLint override. Policy tests require +the legacy lists to stay empty. -- `cancel`, `close-action`, and `close-ladder-recovery`; -- `continuous-ladder`, `order-feedback`, and `quantity`; -- `chart-save-coalescer`. - -The new policy, fixture-contract, coverage-report, and test-selection suites are -also strict. Every new `*.test.js` file is strict by default. All browser spec -files are strict. - -The remaining existing Node suites are individually listed, with migration -reasons, in -[`scripts/test-policy/migration-inventory.js`](../scripts/test-policy/migration-inventory.js). -Only their BDD organization is deferred. Focus/skip, empty-test, new mock, and new -fixed-wait rules still apply. This is explicit migration debt, not proof that the -legacy suites already satisfy the behavior policy. Add new scenarios in strict -files or migrate the whole existing file and remove its inventory entry. - -The two `source-regressions.test.js` files contain both useful source contracts -and behavioral checks awaiting migration. Neither file is classified wholesale -as an architectural test. Preserve the useful contracts while migrating the -behavioral assertions to executable scenarios. - -The remaining method replacements have exact file/target/count allowances: - -| File | Retained calls | Remaining work | -| --- | --- | --- | -| `test/dom/binance-trading-data-footer.test.js` | `Date.now`: 1 | Move the extracted footer's elapsed-time harness to a deterministic clock. | -| `test/dom/binance-strategy27-events/compound-candidate-controller.test.js` | `crypto.subtle.digest`: 1 | Move the lifecycle hash pause into a contract-tested crypto boundary. | -| `test/dom/binance-strategy27-events/strategy27-entrypoint.test.js` | `Date.now`, page `setInterval`/`clearInterval`, `querySelectorAll`, and `prompt`: 1 each | Replace the clock overrides and move query/prompt instrumentation into explicit fixture contracts. | - -The remaining fixed waits are likewise bounded: - -| File | Retained calls | Remaining work | -| --- | --- | --- | -| `test/unit/m3u8-downloader-course-export.test.js` | `setTimeout(20)`: 19; `650`: 1; `1100`: 1 | Introduce export/download completion signals and a virtual runtime clock. | -| `test/dom/binance-strategy29-bollinger/runtime.test.js` | `f.view.setTimeout(0)`: 5 | Expose remote-request and DOM-render completion signals. | -| `test/unit/binance-orderbook-trade/trade-form.test.js` | `dom.window.setTimeout(0)`: 3 | Await the observed request or mutation completion. | -| `test/unit/binance-orderbook-trade/cancel-all-dialog.test.js` | `dom.window.setTimeout(0)`: 1 | Observe delivery of unrelated mutations for the negative case. | -| `test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js` | `setTimeout(0)`: 1 | Preserve the actual render-task-yield contract through a controlled scheduling boundary. | - -Adding another occurrence or a different target fails lint. Removing an old -occurrence also fails until its allowance is reduced or removed. This makes the -inventory shrink as work is migrated. The complete reasons and counts are stored -in the executable inventory rather than a directory-wide ESLint disable. +Runtime source-text assertions were replaced with executable behavior tests. +Source tests retain metadata, generated-artifact identity, module boundaries, +and explicit CSS/markup contracts. The [migration map](test-migration-map.md) +tracks each of the 71 original orderbook source-contract titles to its replacement +and records any remaining verification separately. An empty lint inventory does +not by itself prove behavioral equivalence. One separate host contract allows exactly one `window.setTimeout(0)` inside `finishAfterPerformanceTail` in @@ -181,23 +149,23 @@ the complete production-source denominator, including unexecuted files, and maps generated artifacts back to their source. The denominator and target live in `scripts/test-coverage/config.mjs`. -The default complete run enforces a **66.5% aggregate floor** and **90% for each -of the seven migrated critical modules**. The initial complete baseline is -66.58% across 82 production files; the floor rounds that measured value down to -one decimal place. This preserves an explicit starting gate while the remaining -behavioral coverage is migrated. The executable threshold and file list live in +The default complete run enforces **90% across all production sources** and +**90% for each of the seven critical modules**. The temporary 66.5% migration +floor is no longer active. The executable threshold and file list live in [`branch-policy.json`](../scripts/test-coverage/branch-policy.json). -A successful staged run does not prove that the repository's final 90% target -was met. Inspect `gate`, `meetsBranchTarget`, `summary.branches.pct`, and the -recorded layers in `coverage-summary.json`. Use -`npm run test:coverage -- --require-target` to require the final aggregate target; -`--report-only` collects diagnostic evidence without enforcing thresholds. +Inspect `gate`, `meetsBranchTarget`, the exact covered/total counts, and the +recorded layers in `coverage-summary.json`. The default command requires the +aggregate target; `--report-only` collects diagnostic evidence without enforcing +thresholds. `metricInterpretation` and `blockEvidenceUnavailable` distinguish +retained detailed evidence from coarse Chromium teardown calls. Those coarse +calls receive no additional branch credit, so the gate may use a conservative +lower bound rather than an exact count of every actual execution. Node-only results must remain labeled Node-only and cannot satisfy the merged gate. See [Source Coverage](test-coverage.md) for the measured baseline and complete-source contract. -The enforced test policy, the explicit legacy migration inventory, and the +The enforced test policy, the completed migration inventory, and the coverage target are different facts. Report each separately. Do not shrink the coverage denominator or mark a legacy suite migrated merely to improve a number. diff --git a/e2e/binance-orderbook/fixtures/account-rebalance-api.js b/e2e/binance-orderbook/fixtures/account-rebalance-api.js new file mode 100644 index 0000000..172097a --- /dev/null +++ b/e2e/binance-orderbook/fixtures/account-rebalance-api.js @@ -0,0 +1,99 @@ +export const ACCOUNT_PATHS = Object.freeze({ + positions: '/bapi/futures/v6/private/future/user-data/user-position', + wallets: '/bapi/asset/v2/private/asset-service/wallet/balance', + withdrawable: '/bapi/futures/v1/private/future/user-data/getMaxWithdrawAmount', + transfer: '/bapi/asset/v1/private/asset-service/wallet/transfer', +}); + +const ACCOUNT_CODES = { CARD: 'FUNDING', MAIN: 'MAIN', FUTURE: 'UMFUTURE' }; + +function units(value) { + if (typeof value !== 'string' || !/^\d+(?:\.\d{1,8})?$/.test(value)) { + throw new Error('The account fixture requires non-negative decimals with at most eight places'); + } + const [whole, fraction = ''] = value.split('.'); + return BigInt(whole + fraction.padEnd(8, '0')); +} + +function amount(value) { + const digits = value.toString().padStart(9, '0'); + const fraction = digits.slice(-8).replace(/0+$/, ''); + return digits.slice(0, -8) + (fraction ? '.' + fraction : ''); +} + +/** Models an external wallet API, independently of the userscript's plan builder. */ +export function createAccountRebalanceApi(initialBalances, { commitTransfers = true } = {}) { + let balances = Object.fromEntries(['FUNDING', 'MAIN', 'UMFUTURE'].map(key => [key, units(initialBalances[key])])); + let positions = []; + const requests = []; + const failures = new Map(); + const pendingTransfers = []; + + function applyTransfer(transfer) { + if (balances[transfer.from] < transfer.value) throw new Error('Fixture account has insufficient funds'); + balances[transfer.from] -= transfer.value; + balances[transfer.to] += transfer.value; + } + + return { + supports: pathname => Object.values(ACCOUNT_PATHS).includes(pathname), + setPositions(value) { positions = structuredClone(value); }, + setBalances(value) { + balances = Object.fromEntries(['FUNDING', 'MAIN', 'UMFUTURE'].map(key => [key, units(value[key])])); + }, + failNext(pathname, response) { + if (!Object.values(ACCOUNT_PATHS).includes(pathname)) throw new Error('Unknown account fixture endpoint'); + if (!Number.isInteger(response.status)) throw new Error('The response must declare its HTTP status'); + const queue = failures.get(pathname) ?? []; + queue.push(structuredClone(response)); + failures.set(pathname, queue); + }, + commitPendingTransfers() { + pendingTransfers.splice(0).forEach(applyTransfer); + }, + snapshot() { + return { + balances: Object.fromEntries(Object.entries(balances).map(([key, value]) => [key, amount(value)])), + requests: structuredClone(requests), + pendingTransfers: pendingTransfers.length, + }; + }, + handle({ pathname, method, body }) { + if (!Object.values(ACCOUNT_PATHS).includes(pathname)) throw new Error('Unknown account fixture endpoint'); + const expectedMethod = pathname === ACCOUNT_PATHS.wallets ? 'GET' : 'POST'; + if (method !== expectedMethod) throw new Error('Account fixture received the wrong HTTP method'); + requests.push({ pathname, method, body: structuredClone(body) }); + const failed = failures.get(pathname)?.shift(); + if (failed) return failed; + let payload; + if (pathname === ACCOUNT_PATHS.positions) payload = { success: true, data: structuredClone(positions) }; + if (pathname === ACCOUNT_PATHS.wallets) { + payload = { + success: true, + data: Object.entries(ACCOUNT_CODES).map(([accountType, key]) => ({ + accountType, activate: true, + assetBalances: [{ asset: 'USDT', free: amount(balances[key]), locked: '0', freeze: '0', withdrawing: '0' }], + })), + }; + } + if (pathname === ACCOUNT_PATHS.withdrawable) { + if (body?.assetName !== 'USDT' || Object.keys(body).length !== 1) throw new Error('Unexpected withdrawable-balance request'); + payload = { success: true, data: amount(balances.UMFUTURE) }; + } + if (pathname === ACCOUNT_PATHS.transfer) { + if (body?.asset !== 'USDT' || Object.keys(body).sort().join(',') !== 'amount,asset,kindType') throw new Error('Unexpected transfer request'); + const [fromCode, toCode, extra] = body.kindType.split('_'); + const from = ACCOUNT_CODES[fromCode]; + const to = ACCOUNT_CODES[toCode]; + if (!from || !to || from === to || extra !== undefined) throw new Error('Unexpected transfer route'); + const value = units(body.amount); + if (value <= 0n) throw new Error('Transfer amount must be positive'); + const transfer = { from, to, value }; + if (commitTransfers) applyTransfer(transfer); + else pendingTransfers.push(transfer); + payload = { success: true }; + } + return { status: 200, body: payload }; + }, + }; +} diff --git a/e2e/binance-orderbook/fixtures/binance-futures.js b/e2e/binance-orderbook/fixtures/binance-futures.js index d076163..eb5ae1b 100644 --- a/e2e/binance-orderbook/fixtures/binance-futures.js +++ b/e2e/binance-orderbook/fixtures/binance-futures.js @@ -122,6 +122,7 @@ export function renderBinanceFuturesFixture(scenario) { tradeMode: scenario.ui.tradeMode, orderbookPrecision: scenario.ui.orderbookPrecision, leverage: scenario.ui.leverage, + openableQuantity: scenario.ui.openableQuantity, dialogOpen: false, events: [], }; @@ -139,6 +140,7 @@ export function renderBinanceFuturesFixture(scenario) { const selected = (value, expected) => String(value === expected); const scheduleCommit = (callback) => setTimeout(callback, scenario.host.mutationDelayMs); let orderSubmitSequence = 0; + let loadedOrderCount = scenario.host.orderRowsPageSize; const userscriptFetch = window.fetch; window.fetch = async (...args) => { const response = await userscriptFetch(...args); @@ -168,8 +170,8 @@ export function renderBinanceFuturesFixture(scenario) { orderEntry.innerHTML = '' + '' + - '
可开 10 HYPE
' + - '
可开 10 HYPE
'; + '
可开 ' + state.openableQuantity + ' HYPE
' + + '
可开 ' + state.openableQuantity + ' HYPE
'; } else { orderEntry.innerHTML = '' + @@ -208,6 +210,7 @@ export function renderBinanceFuturesFixture(scenario) { } orderSubmitSequence += 1; const submitSequence = orderSubmitSequence; + const submitSymbol = scenario.currentSymbol; record('order-submitted', { submitSequence, action: button.textContent.trim(), @@ -230,6 +233,7 @@ export function renderBinanceFuturesFixture(scenario) { const outcome = response.ok && payload.success === true ? 'success' : payload.success === false ? 'rejected' : 'unknown'; record('order-submit-api-' + outcome, { submitSequence, code: payload.code }); + if (outcome === 'success') publishSubmittedChartOrder(submitSequence, submitSymbol); if (outcome === 'unknown') return; if (scenario.host.submitFeedbackDelayMs > 0) { setTimeout(() => showFeedback(outcome), scenario.host.submitFeedbackDelayMs); @@ -346,7 +350,8 @@ export function renderBinanceFuturesFixture(scenario) { localStorage.setItem('jh_binance_orderbook_precision_samples_v3:' + scenario.currentSymbol, '["81.0","81.01","81.02","81.03","81.04","81.05"]'); function renderOrdersRows() { - const orders = visibleOrders(); + const allOrders = visibleOrders(); + const orders = loadedOrderCount === null ? allOrders : allOrders.slice(0, loadedOrderCount); if (!orders.length) return '
暂无当前委托。
'; return orders.map((item) => '
' + '2026-09-12 10:27:51' + @@ -374,13 +379,15 @@ export function renderBinanceFuturesFixture(scenario) { '
条件委托(' + conditionalCount + ')
' + '
' + '
隐藏其他合约
' + - '
' + renderOrdersRows() + '
' + + '
' + + (scenario.host.orderRowsMountDelayMs === 0 ? renderOrdersRows() : '
Loading orders
') + '
' + (visibleOrders().length ? '
全撤
' : '') + ''; accountWidget.querySelectorAll('[data-account-tab]').forEach((tab) => { tab.addEventListener('click', () => scheduleCommit(() => { state.accountTab = tab.dataset.accountTab; + loadedOrderCount = scenario.host.orderRowsPageSize; record('account-tab', { value: state.accountTab }); renderAccountWidget(); })); @@ -388,16 +395,87 @@ export function renderBinanceFuturesFixture(scenario) { accountWidget.querySelectorAll('[data-open-orders-sub-tab]').forEach((tab) => { tab.addEventListener('click', () => scheduleCommit(() => { state.openOrdersSubTab = tab.dataset.openOrdersSubTab; + loadedOrderCount = scenario.host.orderRowsPageSize; record('open-orders-sub-tab', { value: state.openOrdersSubTab }); renderAccountWidget(); })); }); accountWidget.querySelector('[name="hideOtherSymbol"]')?.addEventListener('click', () => scheduleCommit(() => { state.hideOtherSymbols = !state.hideOtherSymbols; + loadedOrderCount = scenario.host.orderRowsPageSize; record('hide-other-symbols', { value: state.hideOtherSymbols }); renderAccountWidget(); })); accountWidget.querySelector('[data-cancel-all]')?.addEventListener('click', openCancelDialog); + const content = accountWidget.querySelector('.orders-content'); + if (scenario.host.orderRowsMountDelayMs > 0) { + setTimeout(() => { + if (!content.isConnected) return; + content.innerHTML = renderOrdersRows(); + bindRowCancellation(content); + record('order-rows-mounted', { ids: Array.from(content.querySelectorAll('[data-order-id]'), row => row.dataset.orderId) }); + }, scenario.host.orderRowsMountDelayMs); + } else bindRowCancellation(content); + if (scenario.host.orderRowsPageSize !== null) { + content.addEventListener('scroll', () => { + if (content.scrollTop + content.clientHeight < content.scrollHeight - 2) return; + if (loadedOrderCount >= visibleOrders().length) return; + loadedOrderCount += scenario.host.orderRowsPageSize; + content.innerHTML = renderOrdersRows(); + bindRowCancellation(content); + record('order-rows-page-loaded', { ids: Array.from(content.querySelectorAll('[data-order-id]'), row => row.dataset.orderId) }); + }); + } + } + + function bindRowCancellation(content) { + content.querySelectorAll('.open-order-row').forEach((row) => { + row.querySelector('svg[aria-label="撤销挂单"]').addEventListener('click', () => { + const orderId = row.dataset.orderId; + record('row-cancel-requested', { orderId }); + const mode = scenario.host.rowCancelModesById[orderId] ?? scenario.host.rowCancelMode; + if (mode === 'unchanged') return; + if (mode === 'dialog') openRowCancelDialog(orderId); + else clearNativeOrder(orderId); + }); + }); + } + + /** A row action removes only its captured native ID, even for an unsafe caller. */ + function clearNativeOrder(orderId) { + setTimeout(() => { + const removed = state.orders.filter(order => order.id === orderId); + if (removed.length !== 1) throw new Error('Row cancellation requires one existing native order'); + state.orders = state.orders.filter(order => order.id !== orderId); + if (scenario.host.openableQuantityAfterRowCancel !== null) { + state.openableQuantity = scenario.host.openableQuantityAfterRowCancel; + if (state.tradeMode === 'OPEN') { + document.querySelectorAll('.order-entry [data-testid^="max-"]').forEach(element => { + element.textContent = '可开 ' + state.openableQuantity + ' HYPE'; + }); + } + } + scheduleChartOrderRemovals(removed); + record('row-cancel-cleared', { orderId }); + renderAccountWidget(); + }, scenario.host.rowCancelDelayMs); + } + + function openRowCancelDialog(orderId) { + if (state.dialogOpen) throw new Error('Fixture opened a duplicate row cancellation dialog'); + state.dialogOpen = true; + record('row-dialog-opened', { orderId }); + const root = document.createElement('div'); + root.className = 'bn-modal-root'; + root.innerHTML = '
取消此委托?
' + + '' + + '
'; + root.querySelector('[data-row-dialog-action="cancel"]').addEventListener('click', () => closeDialog('row-cancel')); + root.querySelector('[data-row-dialog-action="confirm"]').addEventListener('click', () => { + closeDialog('row-confirm'); + clearNativeOrder(orderId); + }); + document.body.append(root); } function closeDialog(action) { @@ -477,6 +555,10 @@ export function renderBinanceFuturesFixture(scenario) { const chartOrdersTrigger = document.querySelector('[data-testid="chart-orders-trigger"]'); const chartRoot = document.querySelector('.chart-widget-root'); const chartEventListeners = new Map(); + const chartOrderDrawings = new Map(currentOrders().map(order => [ + 'order-' + order.id, + { toolname: 'LineToolOrder', symbol: order.symbol }, + ])); const tradingViewApi = { saveChart(snapshot) { record('chart-saved', { snapshot }); @@ -497,11 +579,46 @@ export function renderBinanceFuturesFixture(scenario) { for (const listener of chartEventListeners.get(eventName) || []) listener(...args); }, }; + if (scenario.host.orderDrawingEvents) { + tradingViewApi.activeChart = () => ({ + getShapeById(drawingId) { + const drawing = chartOrderDrawings.get(drawingId); + if (!drawing) throw new Error('No native chart order drawing exists for ' + drawingId); + return { lineDataSource: () => drawing }; + }, + }); + } + + /** Native broker events request a complete chart serialization 100 ms later. */ + function publishNativeOrderDrawingEvent(drawingId, eventType) { + record('chart-drawing-event', { drawingId, eventType, toolname: 'LineToolOrder' }); + tradingViewApi.emit('drawing_event', drawingId, eventType); + setTimeout(() => { + const snapshot = { checked: state.showOrders, drawingIds: [...chartOrderDrawings.keys()] }; + record('chart-save-requested', { drawingId, eventType, snapshot }); + tradingViewApi.saveChart(snapshot); + }, 100); + } + + function publishSubmittedChartOrder(submitSequence, submitSymbol) { + if (!scenario.host.orderDrawingEvents || !state.showOrders || submitSymbol !== scenario.currentSymbol) return; + const drawingId = 'order-submitted-' + submitSequence; + if (chartOrderDrawings.has(drawingId)) throw new Error('A native submitted drawing cannot be created twice'); + chartOrderDrawings.set(drawingId, { toolname: 'LineToolOrder', symbol: submitSymbol }); + publishNativeOrderDrawingEvent(drawingId, 'create'); + } function scheduleChartOrderRemovals(orders) { if (!state.showOrders) return; orders.forEach((order, index) => { setTimeout(() => { + if (scenario.host.orderDrawingEvents) { + const drawingId = 'order-' + order.id; + // Off-chart order cancellations have no visible drawing to remove. + if (!chartOrderDrawings.delete(drawingId)) return; + publishNativeOrderDrawingEvent(drawingId, 'remove'); + return; + } tradingViewApi.emit('drawing_event', 'order-' + order.id, 'remove'); record('chart-save-requested', { checked: true, @@ -572,9 +689,47 @@ export function renderBinanceFuturesFixture(scenario) { window.__BINANCE_FIXTURE__ = { replacePrecisionControl, + /** Native account publications update counters without replacing their observer root. */ + setPositions(positions) { + if (!Array.isArray(positions)) throw new Error('Native positions must be an array'); + state.positions = positions.map(position => ({ ...position })); + accountWidget.querySelector('[data-account-tab="positions"]').textContent = '仓位(' + state.positions.length + ')'; + if (state.tradeMode === 'CLOSE') { + document.querySelector('[data-testid="max-sell-amount"]').textContent = '可平 ' + currentPositionQuantity('LONG') + ' HYPE'; + document.querySelector('[data-testid="max-buy-amount"]').textContent = '可平 ' + currentPositionQuantity('SHORT') + ' HYPE'; + } + record('native-positions-updated', { count: state.positions.length }); + }, + setOrders(orders) { + if (!Array.isArray(orders) || orders.some(order => !['basic', 'conditional'].includes(order.kind))) { + throw new Error('Native orders must be an array with explicit order kinds'); + } + state.orders = orders.map(order => ({ ...order })); + loadedOrderCount = scenario.host.orderRowsPageSize; + accountWidget.querySelector('[data-account-tab="openOrders"]').textContent = '当前委托(' + state.orders.length + ')'; + accountWidget.querySelector('[data-open-orders-sub-tab="basic"]').textContent = + '基础单(' + state.orders.filter(order => order.kind === 'basic').length + ')'; + accountWidget.querySelector('[data-open-orders-sub-tab="conditional"]').textContent = + '条件委托(' + state.orders.filter(order => order.kind === 'conditional').length + ')'; + const content = accountWidget.querySelector('.orders-content'); + content.innerHTML = renderOrdersRows(); + bindRowCancellation(content); + const cancelAll = accountWidget.querySelector('[data-cancel-all]'); + if (!visibleOrders().length && cancelAll) cancelAll.remove(); + if (visibleOrders().length && !cancelAll) { + const button = document.createElement('div'); + button.className = 'cursor-pointer'; + button.setAttribute('data-cancel-all', ''); + button.textContent = '全撤'; + button.addEventListener('click', openCancelDialog); + content.after(button); + } + record('native-orders-updated', { count: state.orders.length }); + }, switchSymbol(symbol) { if (typeof symbol !== 'string' || !symbol) throw new Error('A symbol is required'); scenario.currentSymbol = symbol; + loadedOrderCount = scenario.host.orderRowsPageSize; history.pushState({}, '', '/zh-CN/futures/' + symbol); renderTradeMode(); renderAccountWidget(); @@ -591,6 +746,7 @@ export function renderBinanceFuturesFixture(scenario) { tradeMode: state.tradeMode, orderbookPrecision: state.orderbookPrecision, leverage: state.leverage, + openableQuantity: state.openableQuantity, dialogOpen: state.dialogOpen, events: state.events, })), diff --git a/e2e/binance-orderbook/helpers/account-lifecycle-host.js b/e2e/binance-orderbook/helpers/account-lifecycle-host.js new file mode 100644 index 0000000..6c36234 --- /dev/null +++ b/e2e/binance-orderbook/helpers/account-lifecycle-host.js @@ -0,0 +1,188 @@ +import { expect } from '../test.js'; +import { ACCOUNT_PATHS, createAccountRebalanceApi } from '../fixtures/account-rebalance-api.js'; +import { CURRENT_SYMBOL, OTHER_SYMBOL, ORDER_SETS, createCancelScenario } from '../scenarios/cancel-current-symbol.js'; +import { openUserscriptScenario, readFixtureState } from './userscript-page.js'; +import { installScenarioClock, pauseScenarioClock } from './scenario-clock.js'; + +export const LEVERAGE_PATH = '/bapi/futures/v1/private/future/user-data/adjustLeverage'; +export const THIRD_SYMBOL = 'ETHUSDT'; + +/** + * Hold the native bootstrap before it reaches the installed userscript interceptor. + * Delaying its HTTP response alone would already have exposed the request headers. + */ +function installAccountLifecycleBoundary() { + const interceptedFetch = window.fetch; + const requests = []; + let bootstrap = null; + let released = false; + window.fetch = function (...args) { + const pathname = new URL(typeof args[0] === 'string' ? args[0] : args[0].url, location.href).pathname; + if (pathname === '/bapi/fixture-bootstrap') { + if (bootstrap || released) throw new Error('The account bootstrap may be captured only once'); + const completion = Promise.withResolvers(); + bootstrap = { args, completion, receiver: this }; + return completion.promise; + } + if (!pathname.startsWith('/bapi/')) return interceptedFetch.apply(this, args); + const record = { + pathname, + symbol: location.pathname.split('/').at(-1), + at: Date.now(), + settled: false, + status: null, + error: null, + }; + requests.push(record); + return interceptedFetch.apply(this, args).then( + response => { + record.settled = true; + record.status = response.status; + return response; + }, + error => { + record.settled = true; + record.error = error.name; + throw error; + }, + ); + }; + window.__ACCOUNT_LIFECYCLE_BOUNDARY__ = { + releaseHeaders() { + if (!bootstrap) throw new Error('No native account bootstrap is pending'); + const pending = bootstrap; + bootstrap = null; + released = true; + const request = interceptedFetch.apply(pending.receiver, pending.args); + request.then(pending.completion.resolve, pending.completion.reject); + return request.then(response => response.status); + }, + snapshot() { + return { bootstrapHeld: bootstrap !== null, released, requests: structuredClone(requests) }; + }, + }; +} + +/** Offline HTTP responses remain independent of native account counter mutations. */ +export async function openAccountLifecycleHost(page, { + positions = [], + orders = ORDER_SETS.current, + apiPositions = [], + leverage = 2, + balances = { FUNDING: '100', MAIN: '0', UMFUTURE: '0' }, +} = {}) { + await installScenarioClock(page); + const loaded = await openUserscriptScenario(page, createCancelScenario({ + positions, + orders, + ui: { leverage }, + }), { afterOrderbook: '(' + installAccountLifecycleBoundary.toString() + ')();' }); + await pauseScenarioClock(page); + const api = createAccountRebalanceApi(balances); + api.setPositions(apiPositions); + const plannedHolds = new Set(); + const held = new Map(); + const leverageRequests = []; + const leverageFailures = []; + let positionCount = 0; + await page.route('https://www.binance.com/bapi/**', async route => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + if (!api.supports(pathname) && pathname !== LEVERAGE_PATH) return route.fallback(); + const body = request.postData() === null ? undefined : request.postDataJSON(); + let response; + if (pathname === LEVERAGE_PATH) { + expect(request.method()).toBe('POST'); + expect(Object.keys(body).sort()).toEqual(['leverage', 'symbol']); + leverageRequests.push(structuredClone(body)); + response = leverageFailures.length > 0 + ? leverageFailures.shift() + : { status: 200, body: { success: true } }; + } else { + response = api.handle({ pathname, method: request.method(), body }); + } + if (pathname === ACCOUNT_PATHS.positions) { + expect(body).toEqual({}); + positionCount += 1; + if (plannedHolds.delete(positionCount)) { + const release = Promise.withResolvers(); + const delivered = Promise.withResolvers(); + held.set(positionCount, { release, delivered, response }); + response = await release.promise; + await route.fulfill({ status: response.status, contentType: 'application/json', body: JSON.stringify(response.body) }); + delivered.resolve(); + return; + } + } + await route.fulfill({ status: response.status, contentType: 'application/json', body: JSON.stringify(response.body) }); + }); + await page.route('https://fapi.binance.com/fapi/v1/exchangeInfo**', async route => { + const symbol = new URL(route.request().url()).searchParams.get('symbol'); + expect([CURRENT_SYMBOL, OTHER_SYMBOL, THIRD_SYMBOL]).toContain(symbol); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ symbols: [{ + 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' }, + ], + }] }) }); + }); + + return { + ...loaded, + api, + leverageRequests, + async releaseHeaders() { + return page.evaluate(() => window.__ACCOUNT_LIFECYCLE_BOUNDARY__.releaseHeaders()); + }, + async snapshot() { + return page.evaluate(() => window.__ACCOUNT_LIFECYCLE_BOUNDARY__.snapshot()); + }, + holdPositionResponse(sequence = positionCount + 1) { + if (!Number.isInteger(sequence) || sequence <= positionCount || plannedHolds.has(sequence)) { + throw new Error('A held position response requires an unused future sequence'); + } + plannedHolds.add(sequence); + return sequence; + }, + pendingPositionResponses: () => [...held.keys()], + async releasePositionResponse(sequence, replacement) { + const pending = held.get(sequence); + if (!pending) throw new Error('No position response is held for this sequence'); + held.delete(sequence); + pending.release.resolve(replacement === undefined ? pending.response : replacement); + await pending.delivered.promise; + }, + failNextLeverage(status) { + if (!Number.isInteger(status) || status < 400) throw new Error('Leverage failure requires an HTTP error status'); + leverageFailures.push({ status, body: { success: false, message: 'Declared leverage rejection' } }); + }, + async waitForPositionResponses(minimum) { + await expect.poll(async () => { + const snapshot = await page.evaluate(() => window.__ACCOUNT_LIFECYCLE_BOUNDARY__.snapshot()); + const positions = snapshot.requests.filter(request => request.pathname === ACCOUNT_PATHS.positions); + return { enough: positions.length >= minimum, pending: snapshot.requests.filter(request => !request.settled).length }; + }).toEqual({ enough: true, pending: 0 }); + }, + async setNativePositions(value) { + return page.evaluate(positions => { + window.__BINANCE_FIXTURE__.setPositions(positions); + return Date.now(); + }, value); + }, + async setNativeOrders(value) { + return page.evaluate(orders => { + window.__BINANCE_FIXTURE__.setOrders(orders); + return Date.now(); + }, value); + }, + async expectNoTradingActions() { + expect((await readFixtureState(page)).events.filter(event => ( + event.type === 'order-submitted' || /cancel-requested/.test(event.type) + ))).toEqual([]); + expect(api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.transfer)).toEqual([]); + expect(loaded.errors).toEqual([]); + }, + }; +} diff --git a/e2e/binance-orderbook/scenarios/cancel-current-symbol.js b/e2e/binance-orderbook/scenarios/cancel-current-symbol.js index bd5db60..10c293c 100644 --- a/e2e/binance-orderbook/scenarios/cancel-current-symbol.js +++ b/e2e/binance-orderbook/scenarios/cancel-current-symbol.js @@ -48,6 +48,7 @@ export function createCancelScenario(overrides = {}) { tradeMode: 'OPEN', orderbookPrecision: '0.1', leverage: 2, + openableQuantity: '10', ...overrides.ui, }, host: { @@ -57,10 +58,17 @@ export function createCancelScenario(overrides = {}) { dialogReplacementDelayMs: null, clearMode: 'capturedScope', chartOrdersPopoverCloseMode: 'normal', + orderDrawingEvents: false, submitFeedbackDelayMs: 0, submitButtonBusyMs: 0, submitButtonBusyAttribute: 'data-loading', submitButtonClearsInputsWhenReady: false, + rowCancelMode: 'clear', + rowCancelModesById: {}, + rowCancelDelayMs: 0, + orderRowsPageSize: null, + orderRowsMountDelayMs: 0, + openableQuantityAfterRowCancel: null, submitApiResponses: Array.from({ length: 5 }, () => ({ outcome: 'success', delivery: 'immediate', })), @@ -81,6 +89,35 @@ export function createCancelScenario(overrides = {}) { if (!Number.isInteger(scenario.ui.leverage) || scenario.ui.leverage <= 0) { throw new Error('Leverage must be a positive integer'); } + const quantities = [scenario.ui.openableQuantity]; + if (scenario.host.openableQuantityAfterRowCancel !== null) quantities.push(scenario.host.openableQuantityAfterRowCancel); + for (const quantity of quantities) { + if (typeof quantity !== 'string' || !/^\d+(?:\.\d+)?$/.test(quantity)) { + throw new Error('Openable quantity must be an explicit non-negative decimal string'); + } + } + if (!['clear', 'unchanged', 'dialog'].includes(scenario.host.rowCancelMode)) { + throw new Error('Row cancellation must declare clear, unchanged, or dialog'); + } + if (!Number.isInteger(scenario.host.rowCancelDelayMs) || scenario.host.rowCancelDelayMs < 0) { + throw new Error('Row cancellation delay must be a non-negative integer'); + } + if (!Number.isInteger(scenario.host.orderRowsMountDelayMs) || scenario.host.orderRowsMountDelayMs < 0) { + throw new Error('Native row mount delay must be a non-negative integer'); + } + if (scenario.host.orderRowsPageSize !== null + && (!Number.isInteger(scenario.host.orderRowsPageSize) || scenario.host.orderRowsPageSize <= 0)) { + throw new Error('Native row page size must be a positive integer or null'); + } + if (!scenario.host.rowCancelModesById || Array.isArray(scenario.host.rowCancelModesById) + || typeof scenario.host.rowCancelModesById !== 'object') { + throw new Error('Native row outcomes require an explicit order-ID map'); + } + for (const [orderId, mode] of Object.entries(scenario.host.rowCancelModesById)) { + if (!scenario.orders.some(order => order.id === orderId) || !['clear', 'unchanged', 'dialog'].includes(mode)) { + throw new Error('Native row outcomes require existing order IDs and declared modes'); + } + } if (!scenario.host.precisionOptions.includes(scenario.ui.orderbookPrecision)) { throw new Error('Current orderbook precision must be one of the native options'); } @@ -101,6 +138,9 @@ export function createCancelScenario(overrides = {}) { `Unsupported chart-orders popover close mode: ${scenario.host.chartOrdersPopoverCloseMode}`, ); } + if (typeof scenario.host.orderDrawingEvents !== 'boolean') { + throw new Error('Native order drawing events must be explicitly enabled or disabled'); + } for (const key of ['submitFeedbackDelayMs', 'submitButtonBusyMs']) { if (!Number.isInteger(scenario.host[key]) || scenario.host[key] < 0) { throw new Error(`${key} must be a non-negative integer`); diff --git a/e2e/binance-orderbook/specs/account-lifecycle-behavior.pw.js b/e2e/binance-orderbook/specs/account-lifecycle-behavior.pw.js new file mode 100644 index 0000000..850241e --- /dev/null +++ b/e2e/binance-orderbook/specs/account-lifecycle-behavior.pw.js @@ -0,0 +1,388 @@ +import { test, expect } from '../test.js'; +import { ACCOUNT_PATHS } from '../fixtures/account-rebalance-api.js'; +import { CURRENT_SYMBOL, OTHER_SYMBOL, ORDER_SETS } from '../scenarios/cancel-current-symbol.js'; +import { LEVERAGE_PATH, THIRD_SYMBOL, openAccountLifecycleHost } from '../helpers/account-lifecycle-host.js'; +import { readFixtureState } from '../helpers/userscript-page.js'; + +const ACTION = '[data-usdt-rebalance]'; +const POSITION_TAB = '[data-account-tab="positions"]'; +const OPEN_TAB = '#position-direction [data-trade-mode="OPEN"]'; +const CLOSE_TAB = '#position-direction [data-trade-mode="CLOSE"]'; +const CURRENT_POSITION = { symbol: CURRENT_SYMBOL, side: 'LONG', quantity: '1' }; +const OTHER_POSITION = { symbol: OTHER_SYMBOL, side: 'LONG', quantity: '2' }; +const API_CURRENT_POSITION = { symbol: CURRENT_SYMBOL, positionSide: 'LONG', positionAmount: '1' }; +const API_OTHER_POSITION = { symbol: OTHER_SYMBOL, positionSide: 'LONG', positionAmount: '2' }; + +async function openSettledAccount(page, options) { + const host = await openAccountLifecycleHost(page, options); + await host.releaseHeaders(); + await host.waitForPositionResponses(2); + await page.clock.runFor(32); + return host; +} + +async function expectEligible(page) { + await page.clock.runFor(32); + await expect(page.locator(ACTION)).toBeVisible(); + await expect(page.locator(ACTION)).toBeEnabled(); +} + +async function switchSymbol(page, symbol) { + await page.evaluate(value => window.__BINANCE_FIXTURE__.switchSymbol(value), symbol); + await page.clock.runFor(32); +} + +test('user receives independently declared account API data after native counters change', async ({ page }) => { + // Given bootstrap headers are held and the offline API declares an empty account. + const host = await openAccountLifecycleHost(page); + const sequence = host.holdPositionResponse(); + await host.setNativePositions([CURRENT_POSITION]); + + // When a native account request is captured before a later server-side position update. + const response = page.evaluate(async pathname => { + const result = await fetch(pathname, { method: 'POST', body: JSON.stringify({}) }); + return result.json(); + }, ACCOUNT_PATHS.positions); + await expect.poll(host.pendingPositionResponses).toEqual([sequence]); + host.api.setPositions([API_OTHER_POSITION]); + + // Then the DOM mutation cannot rewrite the already captured HTTP response. + await expect(page.locator(POSITION_TAB)).toHaveText('仓位(1)'); + expect((await host.snapshot()).bootstrapHeld).toBe(true); + expect((await host.snapshot()).requests.map(request => request.settled)).toEqual([false]); + + // When the captured response is deliberately released. + await host.releasePositionResponse(sequence); + + // Then the caller receives the captured empty account and no trading action occurs. + expect(await response).toEqual({ success: true, data: [] }); + expect((await readFixtureState(page)).positions).toEqual([CURRENT_POSITION]); + await host.expectNoTradingActions(); +}); + +for (const elapsed of [1000, 5500]) { + test(`user wakes a pending leverage check when native headers arrive after ${elapsed} milliseconds`, async ({ page }) => { + // Given the native bootstrap has not reached the real interceptor and leverage starts at five. + const host = await openAccountLifecycleHost(page, { leverage: 5 }); + + // When the page advances while no captured request headers are available. + await page.clock.runFor(elapsed); + const releaseAt = await page.evaluate(() => Date.now()); + + // Then neither position requests nor leverage adjustments can begin from missing headers. + expect((await host.snapshot()).requests).toEqual([]); + expect((await readFixtureState(page)).leverage).toBe(5); + expect(host.leverageRequests).toEqual([]); + + // When the actual native bootstrap is released without advancing the page clock. + expect(await host.releaseHeaders()).toBe(200); + await expect.poll(async () => (await readFixtureState(page)).leverage).toBe(2); + + // Then the header event immediately wakes fresh position checks and one current-symbol reset. + expect(host.leverageRequests).toEqual([{ symbol: CURRENT_SYMBOL, leverage: 2 }]); + const requests = (await host.snapshot()).requests; + expect(requests.filter(request => request.pathname === ACCOUNT_PATHS.positions).map(request => request.at)) + .toEqual(expect.arrayContaining([releaseAt])); + expect(requests.find(request => request.pathname === LEVERAGE_PATH).at).toBe(releaseAt); + await host.expectNoTradingActions(); + }); +} + +test('user keeps close-mode leverage unchanged when previously missing native headers arrive', async ({ page }) => { + // Given an open-mode check waits for native headers with leverage five. + const host = await openAccountLifecycleHost(page, { leverage: 5 }); + + // When the native user selects Close before the bootstrap is released. + await page.locator(CLOSE_TAB).evaluate(tab => tab.click()); + await page.clock.runFor(32); + await host.releaseHeaders(); + await host.waitForPositionResponses(1); + await page.clock.runFor(1000); + + // Then the old open request cannot adjust the leverage in the new mode. + expect((await readFixtureState(page)).tradeMode).toBe('CLOSE'); + expect((await readFixtureState(page)).leverage).toBe(5); + expect(host.leverageRequests).toEqual([]); + await host.expectNoTradingActions(); +}); + +test('user requires a fresh flat position response immediately before adjusting leverage', async ({ page }) => { + // Given the first position response is flat but the next native read is held. + const host = await openAccountLifecycleHost(page, { leverage: 5 }); + const finalRead = host.holdPositionResponse(2); + await host.releaseHeaders(); + await expect.poll(host.pendingPositionResponses).toContain(finalRead); + + // When the authoritative second response reports a position that the native DOM has not published. + await host.releasePositionResponse(finalRead, { status: 200, body: { success: true, data: [API_CURRENT_POSITION] } }); + await host.waitForPositionResponses(2); + + // Then a stale flat observation never authorizes a leverage request. + await expect(page.locator(POSITION_TAB)).toHaveText('仓位(0)'); + expect((await readFixtureState(page)).leverage).toBe(5); + expect(host.leverageRequests).toEqual([]); + await host.expectNoTradingActions(); +}); + +test('user replays only the latest symbol after a busy account position check', async ({ page }) => { + // Given the first symbol has one held native position response. + const host = await openAccountLifecycleHost(page, { leverage: 5 }); + const firstRead = host.holdPositionResponse(1); + await host.releaseHeaders(); + await expect.poll(host.pendingPositionResponses).toEqual([firstRead]); + + // When the native route visits B and then C before the original response arrives. + await switchSymbol(page, OTHER_SYMBOL); + await switchSymbol(page, THIRD_SYMBOL); + await host.releasePositionResponse(firstRead); + await expect.poll(async () => (await readFixtureState(page)).leverage).toBe(2); + + // Then the superseded B request is not fetched and only C receives a reset. + const positionSymbols = (await host.snapshot()).requests + .filter(request => request.pathname === ACCOUNT_PATHS.positions).map(request => request.symbol); + expect(positionSymbols[0]).toBe(CURRENT_SYMBOL); + expect(positionSymbols.slice(1).every(symbol => symbol === THIRD_SYMBOL)).toBe(true); + expect(positionSymbols.slice(1).length).toBeGreaterThanOrEqual(2); + expect(host.leverageRequests).toEqual([{ symbol: THIRD_SYMBOL, leverage: 2 }]); + await host.expectNoTradingActions(); +}); + +test('user replays only the latest reset after switching symbols during its final position read', async ({ page }) => { + // Given the original reset is busy on its final authoritative read. + const host = await openAccountLifecycleHost(page, { leverage: 5 }); + const finalRead = host.holdPositionResponse(2); + await host.releaseHeaders(); + await expect.poll(host.pendingPositionResponses).toContain(finalRead); + + // When separate B and C account observations request a reset while the original task remains busy. + await switchSymbol(page, OTHER_SYMBOL); + await expect.poll(async () => (await host.snapshot()).requests + .filter(request => request.pathname === ACCOUNT_PATHS.positions && request.symbol === OTHER_SYMBOL && request.settled).length) + .toBeGreaterThanOrEqual(1); + await switchSymbol(page, THIRD_SYMBOL); + await expect.poll(async () => (await host.snapshot()).requests + .filter(request => request.pathname === ACCOUNT_PATHS.positions && request.symbol === THIRD_SYMBOL && request.settled).length) + .toBeGreaterThanOrEqual(1); + await host.releasePositionResponse(finalRead); + await expect.poll(async () => (await readFixtureState(page)).leverage).toBe(2); + + // Then neither the original symbol nor the overwritten pending symbol can adjust leverage. + expect(host.leverageRequests).toEqual([{ symbol: THIRD_SYMBOL, leverage: 2 }]); + expect((await readFixtureState(page)).currentSymbol).toBe(THIRD_SYMBOL); + await host.expectNoTradingActions(); +}); + +test('user refreshes current-symbol position evidence when an account position count changes', async ({ page }) => { + // Given the current and another symbol both have native and authoritative positions. + const host = await openSettledAccount(page, { + leverage: 5, + positions: [CURRENT_POSITION, OTHER_POSITION], + apiPositions: [API_CURRENT_POSITION, API_OTHER_POSITION], + }); + const before = host.api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.positions).length; + + // When the current position closes and the account counter falls from two to one. + host.api.setPositions([API_OTHER_POSITION]); + await host.setNativePositions([OTHER_POSITION]); + await expect.poll(async () => (await readFixtureState(page)).leverage).toBe(2); + + // Then fresh current-symbol API checks permit one reset despite the other open position. + await expect(page.locator(POSITION_TAB)).toHaveText('仓位(1)'); + const reads = host.api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.positions); + expect(reads.length).toBeGreaterThanOrEqual(before + 2); + expect(reads.slice(before).map(request => request.body)).toEqual(Array(reads.length - before).fill({})); + expect(host.leverageRequests).toEqual([{ symbol: CURRENT_SYMBOL, leverage: 2 }]); + await host.expectNoTradingActions(); +}); + +test('user does not repeat account HTTP checks while counts and symbol remain unchanged', async ({ page }) => { + // Given a held position has been observed and an open order prevents rebalance qualification. + const host = await openSettledAccount(page, { + leverage: 5, positions: [CURRENT_POSITION], apiPositions: [API_CURRENT_POSITION], + }); + const before = host.api.snapshot().requests; + + // When unrelated native counter text is redelivered and five seconds of page time elapse. + await host.setNativePositions([CURRENT_POSITION]); + await page.clock.runFor(5000); + + // Then stable observations and the route watchdog create no periodic account requests. + expect(host.api.snapshot().requests).toEqual(before); + expect(host.leverageRequests).toEqual([]); + expect((await readFixtureState(page)).leverage).toBe(5); + await host.expectNoTradingActions(); +}); + +test('user preserves the existing leverage after an HTTP rejection and can retry from a later native mode change', async ({ page }) => { + // Given a flat current symbol receives a declared HTTP failure from its first reset. + const host = await openAccountLifecycleHost(page, { leverage: 5 }); + host.failNextLeverage(503); + await host.releaseHeaders(); + await host.waitForPositionResponses(2); + await expect.poll(async () => (await host.snapshot()).requests + .filter(request => request.pathname === LEVERAGE_PATH && request.status === 503).length).toBe(1); + + // When the user observes the failed reset before any new native context transition. + const rejected = await readFixtureState(page); + + // Then the HTTP rejection never changes native leverage or reports a successful adjustment. + expect(rejected.leverage).toBe(5); + expect(rejected.events.filter(event => event.type === 'leverage-adjusted')).toEqual([]); + + // When the user returns from Close to Open after the declared reset deduplication window. + await page.locator(CLOSE_TAB).evaluate(tab => tab.click()); + await page.clock.runFor(1300); + await page.locator(OPEN_TAB).evaluate(tab => tab.click()); + await page.clock.runFor(32); + await expect.poll(async () => (await readFixtureState(page)).leverage).toBe(2); + + // Then the new transition obtains fresh evidence and exactly one successful native adjustment. + expect(host.leverageRequests).toEqual(Array(2).fill({ symbol: CURRENT_SYMBOL, leverage: 2 })); + expect((await readFixtureState(page)).events.filter(event => event.type === 'leverage-adjusted')) + .toEqual([expect.objectContaining({ symbol: CURRENT_SYMBOL, leverage: 2 })]); + await host.expectNoTradingActions(); +}); + +test('user qualifies for account rebalance only after the full three-second flat window and its API response', async ({ page }) => { + // Given a settled flat account still has one native open order. + const host = await openSettledAccount(page); + const sequence = host.holdPositionResponse(); + const startedAt = await host.setNativeOrders([]); + + // When the stable no-order window advances to one millisecond before its deadline. + await page.clock.runFor(2999); + + // Then no qualification request or rebalance action is exposed early. + expect(host.pendingPositionResponses()).toEqual([]); + await expect(page.locator(ACTION)).toBeHidden(); + + // When the last millisecond expires but the authoritative response is still held. + await page.clock.runFor(1); + await expect.poll(host.pendingPositionResponses).toEqual([sequence]); + + // Then exactly the full window precedes the API request and HTTP completion is still required. + const latest = (await host.snapshot()).requests.filter(request => request.pathname === ACCOUNT_PATHS.positions).at(-1); + expect(latest.at).toBe(startedAt + 3000); + await expect(page.locator(ACTION)).toBeHidden(); + + // When the explicit flat response arrives. + await host.releasePositionResponse(sequence); + await host.waitForPositionResponses(sequence); + + // Then the eligible action appears without any transfer or order request. + await expectEligible(page); + await host.expectNoTradingActions(); +}); + +for (const changed of ['position', 'open order']) { + test(`user restarts the full rebalance window when a native ${changed} reappears`, async ({ page }) => { + // Given the account has already spent fifteen hundred milliseconds with no positions or orders. + const host = await openSettledAccount(page); + await host.setNativeOrders([]); + await page.clock.runFor(1500); + + // When an account activity arrives and clears again before the original deadline. + if (changed === 'position') { + host.api.setPositions([API_CURRENT_POSITION]); + await host.setNativePositions([CURRENT_POSITION]); + host.api.setPositions([]); + await host.setNativePositions([]); + } else { + await host.setNativeOrders(ORDER_SETS.other); + await host.setNativeOrders([]); + } + await page.clock.runFor(2999); + + // Then the original flat time is discarded and the new window remains incomplete. + await expect(page.locator(ACTION)).toBeHidden(); + + // When the new activity-free window reaches three full seconds. + await page.clock.runFor(1); + await host.waitForPositionResponses(3); + + // Then fresh authoritative flat evidence permits the action without side effects. + await expectEligible(page); + await host.expectNoTradingActions(); + }); +} + +for (const changed of ['position', 'open order']) { + test(`user discards a stale flat qualification response after a native ${changed} arrives`, async ({ page }) => { + // Given the full flat window has started an authoritative request that is still pending. + const host = await openSettledAccount(page); + const sequence = host.holdPositionResponse(); + await host.setNativeOrders([]); + await page.clock.runFor(3000); + await expect.poll(host.pendingPositionResponses).toEqual([sequence]); + + // When native account activity invalidates qualification before the captured flat response returns. + if (changed === 'position') { + host.api.setPositions([API_CURRENT_POSITION]); + await host.setNativePositions([CURRENT_POSITION]); + } else { + await host.setNativeOrders(ORDER_SETS.other); + } + await host.releasePositionResponse(sequence); + await host.waitForPositionResponses(sequence); + await page.clock.runFor(32); + + // Then the stale flat response cannot expose an action for the changed account. + await expect(page.locator(ACTION)).toBeHidden(); + expect((await readFixtureState(page))[changed === 'position' ? 'positions' : 'orders'].length).toBe(1); + await host.expectNoTradingActions(); + }); +} + +for (const [name, response] of [ + ['other-symbol position', { status: 200, body: { success: true, data: [API_OTHER_POSITION] } }], + ['malformed position payload', { status: 200, body: { success: true, data: null } }], + ['HTTP service failure', { status: 503, body: { success: false } }], +]) { + test(`user cannot qualify from zero DOM counts when the API reports ${name}`, async ({ page }) => { + // Given native counters are ready to become empty after initial account observation. + const host = await openSettledAccount(page); + host.api.failNext(ACCOUNT_PATHS.positions, response); + + // When the empty account completes its window and receives non-flat or invalid authoritative evidence. + await host.setNativeOrders([]); + await page.clock.runFor(3000); + await host.waitForPositionResponses(3); + await page.clock.runFor(32); + + // Then DOM zero alone cannot authorize the account action or any financial request. + await expect(page.locator(POSITION_TAB)).toHaveText('仓位(0)'); + await expect(page.locator('[data-account-tab="openOrders"]')).toHaveText('当前委托(0)'); + await expect(page.locator(ACTION)).toBeHidden(); + await host.expectNoTradingActions(); + }); +} + +test('user previews balances published during qualification instead of an earlier account snapshot', async ({ page }) => { + // Given the account begins its flat window with one hundred USDT in Funding. + const host = await openSettledAccount(page); + await host.setNativeOrders([]); + await page.clock.runFor(1500); + + // When a native wallet update moves twenty USDT to Spot before qualification completes. + host.api.setBalances({ FUNDING: '80', MAIN: '20', UMFUTURE: '0' }); + await page.clock.runFor(1500); + await host.waitForPositionResponses(3); + await expectEligible(page); + + // Then qualification has not cached balances or sent an account transfer. + expect(host.api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.wallets)).toEqual([]); + await host.expectNoTradingActions(); + + // When the user opens the now-eligible account preview. + await page.locator(ACTION).evaluate(button => button.click()); + const dialog = page.getByRole('dialog', { name: '账户再平衡' }); + + // Then the reviewed plan uses the newly published balances and remains unexecuted. + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText('20 USDT'); + await expect(dialog).toContainText('10 USDT'); + await expect(dialog).not.toContainText('40 USDT'); + expect(host.api.snapshot().balances).toEqual({ FUNDING: '80', MAIN: '20', UMFUTURE: '0' }); + await dialog.getByRole('button', { name: '取消', exact: true }).evaluate(button => button.click()); + await host.expectNoTradingActions(); +}); diff --git a/e2e/binance-orderbook/specs/account-rebalance-behavior.pw.js b/e2e/binance-orderbook/specs/account-rebalance-behavior.pw.js new file mode 100644 index 0000000..4cee469 --- /dev/null +++ b/e2e/binance-orderbook/specs/account-rebalance-behavior.pw.js @@ -0,0 +1,216 @@ +import { test, expect } from '../test.js'; +import { ACCOUNT_PATHS, createAccountRebalanceApi } from '../fixtures/account-rebalance-api.js'; +import { createCancelScenario } from '../scenarios/cancel-current-symbol.js'; +import { openUserscriptScenario, readFixtureState } from '../helpers/userscript-page.js'; +import { installScenarioClock, pauseScenarioClock } from '../helpers/scenario-clock.js'; + +async function openRebalance(page, balances, options) { + await installScenarioClock(page); + const { errors } = await openUserscriptScenario(page, createCancelScenario()); + const api = createAccountRebalanceApi(balances, options); + await page.route('https://www.binance.com/bapi/**', async route => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + if (!api.supports(pathname)) return route.fallback(); + const body = request.postData() === null ? undefined : request.postDataJSON(); + const response = api.handle({ pathname, method: request.method(), body }); + await route.fulfill({ status: response.status, contentType: 'application/json', body: JSON.stringify(response.body) }); + }); + await page.clock.runFor(3000); + const action = page.locator('[data-usdt-rebalance]'); + await expect(action).toBeVisible(); + await expect(action).toBeEnabled(); + return { api, errors, action, status: page.locator('#jh-binance-ladder-status') }; +} + +test('user completes exactly two USDT transfers only after confirming the complete account plan', async ({ page }) => { + // Given a globally flat account holds all 100 USDT in Funding and satisfies the stability window. + const { api, errors, action, status } = await openRebalance(page, { FUNDING: '100', MAIN: '0', UMFUTURE: '0' }); + + // When the user opens the account plan. + await action.evaluate(button => button.click()); + const dialog = page.getByRole('dialog', { name: '账户再平衡' }); + await expect(dialog).toBeVisible(); + + // Then the preview contains both transfers while the account remains unchanged. + await expect(dialog).toContainText('40 USDT'); + await expect(dialog).toContainText('10 USDT'); + expect(api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.transfer)).toEqual([]); + expect(api.snapshot().balances).toEqual({ FUNDING: '100', MAIN: '0', UMFUTURE: '0' }); + + // When the user confirms the reviewed plan once. + await dialog.getByRole('button', { name: '确认再平衡', exact: true }).click(); + + // Then the two explicit native requests reach the 5:4:1 target and ordinary trading is untouched. + await expect(status).toHaveText('账户再平衡已完成 · 2/2 笔'); + expect(api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.transfer).map(request => request.body)) + .toEqual([ + { asset: 'USDT', amount: '40', kindType: 'CARD_MAIN' }, + { asset: 'USDT', amount: '10', kindType: 'CARD_FUTURE' }, + ]); + expect(api.snapshot().balances).toEqual({ FUNDING: '50', MAIN: '40', UMFUTURE: '10' }); + expect((await readFixtureState(page)).events.filter(({ type }) => /order-submitted|cancel-requested/.test(type))).toEqual([]); + 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' }); + await action.evaluate(button => button.click()); + const dialog = page.getByRole('dialog', { name: '账户再平衡' }); + await expect(dialog).toBeVisible(); + + // When the user declines the preview. + await dialog.getByRole('button', { name: '取消', exact: true }).evaluate(button => button.click()); + + // Then both the user-visible result and the native account retain the cancelled decision. + await expect(status).toHaveText('账户再平衡已取消'); + expect(api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.transfer)).toEqual([]); + expect(api.snapshot().balances).toEqual({ FUNDING: '100', MAIN: '0', UMFUTURE: '0' }); + expect(errors).toEqual([]); +}); + +test('user with already balanced wallets receives a no-transfer result', async ({ page }) => { + // Given a globally flat account already holds the exact desired ratio. + const { api, errors, action, status } = await openRebalance(page, { FUNDING: '50', MAIN: '40', UMFUTURE: '10' }); + + // When the user requests an account rebalance. + await action.evaluate(button => button.click()); + + // Then no confirmation or transfer is needed and the result states the existing ratio. + await expect(status).toHaveText('USDT 已按 5:4:1 分配'); + await expect(page.getByRole('dialog')).toHaveCount(0); + expect(api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.transfer)).toEqual([]); + expect(api.snapshot().balances).toEqual({ FUNDING: '50', MAIN: '40', UMFUTURE: '10' }); + expect(errors).toEqual([]); +}); + +for (const changed of ['balance', 'position']) { + test(`user stops account transfers when the authoritative ${changed} changes during confirmation`, async ({ page }) => { + // Given a preview is open for an initially flat account with a known wallet snapshot. + const { api, errors, action, status } = await openRebalance(page, { FUNDING: '100', MAIN: '0', UMFUTURE: '0' }); + await action.evaluate(button => button.click()); + const dialog = page.getByRole('dialog', { name: '账户再平衡' }); + await expect(dialog).toBeVisible(); + + // When external account activity changes the snapshot before confirmation. + if (changed === 'balance') api.setBalances({ FUNDING: '101', MAIN: '0', UMFUTURE: '0' }); + else api.setPositions([{ symbol: 'BTCUSDT', positionAmount: '1' }]); + await dialog.getByRole('button', { name: '确认再平衡', exact: true }).click(); + + // Then the fresh account response blocks every transfer with a specific explanation. + await expect(status).toContainText(changed === 'balance' ? '账户余额已变化' : '全账户仍有持仓'); + expect(api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.transfer)).toEqual([]); + expect(api.snapshot().balances).toEqual({ FUNDING: changed === 'balance' ? '101' : '100', MAIN: '0', UMFUTURE: '0' }); + expect(errors).toEqual([]); + }); +} + +for (const [endpoint, response, message] of [ + ['wallets', { status: 401, body: { success: false } }, 'Binance 登录态已失效'], + ['wallets', { status: 503, body: { success: false } }, '钱包余额接口异常:HTTP 503'], + ['wallets', { status: 200, body: { success: false, message: 'Wallet temporarily locked' } }, 'Wallet temporarily locked'], + ['withdrawable', { status: 403, body: { success: false } }, 'U本位可划转余额接口异常:HTTP 403'], + ['withdrawable', { status: 200, body: { success: false, message: 'Futures balance unavailable' } }, 'Futures balance unavailable'], +]) { + test(`user sees the ${endpoint} rejection ${message} before any account transfer`, async ({ page }) => { + // Given the account qualifies but its next authoritative balance response fails. + const { api, errors, action, status } = await openRebalance(page, { FUNDING: '100', MAIN: '0', UMFUTURE: '0' }); + api.failNext(ACCOUNT_PATHS[endpoint], response); + + // When the user requests a fresh account plan. + await action.evaluate(button => button.click()); + + // Then the specific failure is shown without offering an unverified transfer plan. + await expect(status).toContainText('账户再平衡失败'); + await expect(status).toContainText(message); + await expect(page.getByRole('dialog')).toHaveCount(0); + expect(api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.transfer)).toEqual([]); + expect(api.snapshot().balances).toEqual({ FUNDING: '100', MAIN: '0', UMFUTURE: '0' }); + expect(errors).toEqual([]); + }); +} + +for (const [response, message] of [ + [{ status: 401, body: { success: false } }, 'Binance 登录态已失效'], + [{ status: 403, body: { success: false } }, 'USDT 划转接口异常:HTTP 403'], + [{ status: 200, body: { success: false, message: 'Transfer permission denied' } }, 'Transfer permission denied'], + [{ status: 200, body: null }, 'USDT 划转失败'], +]) { + test(`user stops after the first account transfer is rejected with ${message}`, async ({ page }) => { + // Given the first transfer will be rejected after the user reviews a complete plan. + const { api, errors, action, status } = await openRebalance(page, { FUNDING: '100', MAIN: '0', UMFUTURE: '0' }); + api.failNext(ACCOUNT_PATHS.transfer, response); + await action.evaluate(button => button.click()); + const dialog = page.getByRole('dialog', { name: '账户再平衡' }); + await expect(dialog).toBeVisible(); + + // When the user confirms the plan and the native endpoint returns its rejection. + await dialog.getByRole('button', { name: '确认再平衡', exact: true }).click(); + + // Then exactly the first request was sent and the remaining transfer is not attempted. + await expect(status).toHaveText('账户再平衡失败 · ' + message); + expect(api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.transfer).map(request => request.body)) + .toEqual([{ asset: 'USDT', amount: '40', kindType: 'CARD_MAIN' }]); + expect(api.snapshot().balances).toEqual({ FUNDING: '100', MAIN: '0', UMFUTURE: '0' }); + expect(errors).toEqual([]); + }); +} + +test('user sees a partial account result when an acknowledged transfer never appears in the balances', async ({ page }) => { + // Given the account acknowledges transfers but has not published their balance changes. + const { api, errors, action, status } = await openRebalance(page, + { FUNDING: '100', MAIN: '0', UMFUTURE: '0' }, { commitTransfers: false }); + await action.evaluate(button => button.click()); + const dialog = page.getByRole('dialog', { name: '账户再平衡' }); + await expect(dialog).toBeVisible(); + await pauseScenarioClock(page); + + // When the user confirms and the first balance observation still has the old values. + await dialog.getByRole('button', { name: '确认再平衡', exact: true }).evaluate(button => button.click()); + await expect.poll(() => api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.wallets).length).toBe(3); + await page.clock.runFor(1); + + // Then the second transfer remains blocked while the first one awaits balance confirmation. + expect(api.snapshot().pendingTransfers).toBe(1); + expect(api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.transfer)).toHaveLength(1); + await expect(status).toHaveText('账户再平衡中 · 1/2 笔'); + + // When the exact balance-confirmation deadline expires without a changed account snapshot. + await page.clock.runFor(5000); + + // Then the visible result reports the acknowledged partial completion without sending the second transfer. + await expect(status).toHaveText('账户再平衡部分完成 · 1/2 笔 · 划转后账户余额未及时更新'); + expect(api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.transfer)).toHaveLength(1); + expect(api.snapshot().balances).toEqual({ FUNDING: '100', MAIN: '0', UMFUTURE: '0' }); + expect(errors).toEqual([]); +}); + +test('user sees one completed account transfer when the next transfer fails after balance confirmation', async ({ page }) => { + // Given balance publication is held independently of the first transfer acknowledgement. + const { api, errors, action, status } = await openRebalance(page, + { FUNDING: '100', MAIN: '0', UMFUTURE: '0' }, { commitTransfers: false }); + await action.evaluate(button => button.click()); + const dialog = page.getByRole('dialog', { name: '账户再平衡' }); + await expect(dialog).toBeVisible(); + await pauseScenarioClock(page); + await dialog.getByRole('button', { name: '确认再平衡', exact: true }).evaluate(button => button.click()); + await expect.poll(() => api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.wallets).length).toBe(3); + await page.clock.runFor(1); + + // When the account publishes the first transfer and explicitly rejects the next request. + api.failNext(ACCOUNT_PATHS.transfer, { status: 200, body: { success: false, message: 'Second transfer denied' } }); + api.commitPendingTransfers(); + await page.clock.runFor(1000); + + // Then the first exact balance change remains, two requests were attempted, and the partial count is one. + await expect(status).toHaveText('账户再平衡部分完成 · 1/2 笔 · Second transfer denied'); + expect(api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.transfer).map(request => request.body)) + .toEqual([ + { asset: 'USDT', amount: '40', kindType: 'CARD_MAIN' }, + { asset: 'USDT', amount: '10', kindType: 'CARD_FUTURE' }, + ]); + expect(api.snapshot().balances).toEqual({ FUNDING: '60', MAIN: '40', UMFUTURE: '0' }); + expect(api.snapshot().pendingTransfers).toBe(0); + expect(errors).toEqual([]); +}); diff --git a/e2e/binance-orderbook/specs/active-ladder-context-behavior.pw.js b/e2e/binance-orderbook/specs/active-ladder-context-behavior.pw.js new file mode 100644 index 0000000..a773df6 --- /dev/null +++ b/e2e/binance-orderbook/specs/active-ladder-context-behavior.pw.js @@ -0,0 +1,289 @@ +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'; + +const PANEL = '#jh-binance-close-qty-multiplier-panel'; +const STATUS = '#jh-binance-ladder-status'; +const ORIGINAL_ORDERS = [ + { price: '80.9', quantity: '0.1' }, + { price: '80.4', quantity: '0.1' }, + { price: '79.9', quantity: '0.1' }, +]; +const FIVE_ORDER_PROFILE = [ + { price: '80.9', quantity: '0.06' }, + { price: '80.4', quantity: '0.06' }, + { price: '79.9', quantity: '0.06' }, + { price: '79.4', quantity: '0.06' }, + { price: '78.9', quantity: '0.06' }, +]; +const CHANGES = [ + { + label: 'native price precision', group: 'precision', value: '0.01', + failure: '执行中价格精度已变化,已停止', + recovery: '价格精度已变化,下一轮按新精度继续', + nextOrders: FIVE_ORDER_PROFILE, + }, + { + label: 'saved ratio', group: 'percent', value: '1', + failure: '执行中比例、笔数或间距已变化', + recovery: '比例、笔数或间距已变化,下一轮按新设置继续', + nextOrders: [ + { price: '80.9', quantity: '0.33' }, + { price: '80.4', quantity: '0.33' }, + { price: '79.9', quantity: '0.34' }, + ], + }, + { + label: 'saved order count', group: 'levels', value: '5', + failure: '执行中比例、笔数或间距已变化', + recovery: '比例、笔数或间距已变化,下一轮按新设置继续', + nextOrders: FIVE_ORDER_PROFILE, + }, + { + label: 'saved price gap', group: 'step', value: '1', + failure: '执行中比例、笔数或间距已变化', + recovery: '比例、笔数或间距已变化,下一轮按新设置继续', + nextOrders: [ + { price: '80.9', quantity: '0.1' }, + { price: '80.8', quantity: '0.1' }, + { price: '80.7', quantity: '0.1' }, + ], + }, +]; + +test.afterEach(async ({ page }, testInfo) => { + const phases = await page.evaluate(() => { + const observation = window.__ACTIVE_LADDER_CONTEXT_PHASES__; + if (!observation) return []; + observation.observer.disconnect(); + delete window.__ACTIVE_LADDER_CONTEXT_PHASES__; + return observation.events; + }); + if (testInfo.status !== testInfo.expectedStatus) { + await testInfo.attach('active-context-phases.json', { + body: Buffer.from(JSON.stringify(phases, null, 2)), + contentType: 'application/json', + }); + } +}); + +function expectedSubmissions(orders) { + return orders.map((order, index) => ({ + submitSequence: index + 1, + action: '平空', + ...order, + })); +} + +async function readSubmissions(page) { + return (await readFixtureState(page)).events + .filter(({ type }) => type === 'order-submitted') + .map(({ submitSequence, action, price, quantity }) => ({ + submitSequence, action, price, quantity, + })); +} + +async function readAcknowledgements(page) { + return (await readFixtureState(page)).events + .filter(({ type }) => type === 'order-submit-api-success') + .map(({ submitSequence }) => submitSequence); +} + +async function expectNoCancellation(page) { + expect((await readFixtureState(page)).events.filter(({ type }) => [ + 'cancel-requested', 'row-cancel-requested', 'cancel-cleared', 'row-cancel-cleared', + ].includes(type))).toEqual([]); +} + +/** Observe the rendered recovery phase so the one-second deadline has an exact origin. */ +async function observePhases(page) { + await page.locator(STATUS).evaluate((status) => { + const events = []; + const observer = new MutationObserver(() => { + events.push({ at: performance.now(), text: status.textContent }); + }); + observer.observe(status, { childList: true, characterData: true, subtree: true }); + window.__ACTIVE_LADDER_CONTEXT_PHASES__ = { events, observer }; + }); +} + +async function advanceToPhaseAge(page, phaseText, elapsed) { + const remaining = await page.evaluate(({ phaseText, elapsed }) => { + const phase = window.__ACTIVE_LADDER_CONTEXT_PHASES__.events + .find(({ text }) => text === phaseText); + if (!phase) throw new Error(`The rendered recovery phase was not observed: ${phaseText}`); + return phase.at + elapsed - performance.now(); + }, { phaseText, elapsed }); + expect(remaining).toBeGreaterThanOrEqual(0); + await page.clock.runFor(remaining); +} + +async function openPendingLadder(page, { continuous, pendingSequence, nextOrders }) { + await installScenarioClock(page); + const responses = continuous + ? [ + { outcome: 'success', delivery: 'immediate' }, + { outcome: 'success', delivery: 'manual' }, + ...nextOrders.map((order, index) => ({ + outcome: 'success', + delivery: index === 0 || index === nextOrders.length - 1 ? 'manual' : 'immediate', + })), + ] + : ORIGINAL_ORDERS.map((order, index) => ({ + outcome: 'success', delivery: index + 1 === pendingSequence ? 'manual' : 'immediate', + })); + const host = await openUserscriptScenario(page, createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: '100' }], + ui: { tradeMode: 'CLOSE', orderbookPrecision: '0.1' }, + host: { submitApiResponses: responses }, + })); + const panel = page.locator(PANEL); + await panel.locator('[data-ladder-group="levels"][data-ladder-value="3"]').click(); + await observePhases(page); + await panel.getByRole('button', { name: '阶梯平空', exact: true }).click( + continuous ? { modifiers: ['Alt'] } : {}, + ); + await expect.poll(host.pendingSubmitSequences).toEqual([pendingSequence]); + await pauseScenarioClock(page); + // The native response stays gated while the minimum action-feedback interval expires. + await page.clock.runFor(300); + expect(await readSubmissions(page)).toEqual(expectedSubmissions(ORIGINAL_ORDERS.slice(0, pendingSequence))); + expect(await readAcknowledgements(page)).toEqual( + Array.from({ length: pendingSequence - 1 }, (value, index) => index + 1), + ); + return { ...host, panel, status: panel.locator(STATUS) }; +} + +async function changeActiveContext(page, change) { + if (change.group === 'precision') { + await page.locator('#futuresOrderbook .bn-select-trigger').click(); + await page.getByRole('option', { name: change.value, exact: true }).click(); + await expect(page.locator('#futuresOrderbook .tick-content')).toHaveText(change.value); + expect((await readFixtureState(page)).orderbookPrecision).toBe(change.value); + } else { + const option = page.locator(PANEL).locator( + `[data-ladder-group="${change.group}"][data-ladder-value="${change.value}"]`, + ); + await expect(option).toBeEnabled(); + await option.click(); + } + await page.clock.runFor(64); +} + +for (const change of CHANGES) { + const pendingSequence = change.group === 'precision' ? 1 : 2; + test(`user stops an active ordinary ladder after ${change.label} changes and keeps confirmed progress`, async ({ page }) => { + // Given a three-order close-short ladder has an in-flight native request and unsent levels. + const host = await openPendingLadder(page, { + continuous: false, pendingSequence, nextOrders: [], + }); + + // When the user changes the real native precision or an enabled saved option before acknowledgement. + await changeActiveContext(page, change); + + // Then the existing request remains pending without inventing confirmation or submitting another level. + expect(host.pendingSubmitSequences()).toEqual([pendingSequence]); + expect(await readSubmissions(page)).toEqual(expectedSubmissions(ORIGINAL_ORDERS.slice(0, pendingSequence))); + expect(await readAcknowledgements(page)).toEqual( + Array.from({ length: pendingSequence - 1 }, (value, index) => index + 1), + ); + await expect(host.status).toContainText(`挂单 ${pendingSequence}/3 确认中`); + + // When the native response confirms the order that was already sent. + await host.releaseSubmitResponse(pendingSequence); + await expect.poll(() => readAcknowledgements(page)).toEqual( + Array.from({ length: pendingSequence }, (value, index) => index + 1), + ); + + // Then the next context check stops the old plan and retains exactly its confirmed count. + const failure = `阶梯平空失败:已挂 ${pendingSequence}/3 笔 · ${change.failure}`; + await expect(host.status).toHaveText(failure); + await page.clock.runFor(32); + await expect(host.panel.getByRole('button', { name: '阶梯平空', exact: true })).toBeEnabled(); + await expect(host.panel.getByRole('button', { name: '停止平空', exact: true })).toHaveCount(0); + expect(await readSubmissions(page)).toEqual(expectedSubmissions(ORIGINAL_ORDERS.slice(0, pendingSequence))); + expect(host.pendingSubmitSequences()).toEqual([]); + await expectNoCancellation(page); + + // When additional business time passes after the ordinary task has ended. + await page.clock.runFor(3000); + + // Then no old remaining level is retried and the exact terminal result is stable. + await expect(host.status).toHaveText(failure); + expect(await readSubmissions(page)).toEqual(expectedSubmissions(ORIGINAL_ORDERS.slice(0, pendingSequence))); + expect(host.pendingSubmitSequences()).toEqual([]); + expect(host.errors).toEqual([]); + }); + + test(`user rebuilds a continuous ladder after active ${change.label} changes without resuming old remaining levels`, async ({ page }) => { + // Given the first close-short round has one confirmed order, one pending order, and one unsent level. + const host = await openPendingLadder(page, { + continuous: true, pendingSequence: 2, nextOrders: change.nextOrders, + }); + + // When the user changes the active context before the second native response is delivered. + await changeActiveContext(page, change); + + // Then the pending order is not counted and the old third level is not submitted. + await expect(host.status).toHaveText('连续阶梯平空 · 第 2 笔确认中 · 0/1 轮 · 本轮 1/3 笔 · 累计 1 笔'); + expect(await readAcknowledgements(page)).toEqual([1]); + expect(await readSubmissions(page)).toEqual(expectedSubmissions(ORIGINAL_ORDERS.slice(0, 2))); + + // When the native response confirms that second order and recovery approaches its one-second deadline. + await host.releaseSubmitResponse(2); + await expect.poll(() => readAcknowledgements(page)).toEqual([1, 2]); + const recovery = `连续阶梯平空 · 1s 后继续 · 0/1 轮 · 本轮 2/3 笔 · 累计 2 笔 · ${change.recovery}`; + await expect(host.status).toHaveText(recovery); + await advanceToPhaseAge(page, recovery, 999); + + // Then all confirmed progress survives but neither the old third order nor a new round starts early. + await expect(host.status).toHaveText(recovery); + expect(await readSubmissions(page)).toEqual(expectedSubmissions(ORIGINAL_ORDERS.slice(0, 2))); + expect(host.pendingSubmitSequences()).toEqual([]); + await expectNoCancellation(page); + + // When the full recovery interval expires and the rebuilt round reaches its first native response gate. + await page.clock.runFor(1); + await page.clock.resume(); + await expect.poll(host.pendingSubmitSequences).toEqual([3]); + await pauseScenarioClock(page); + + // Then a fresh plan starts from its first level with the changed profile and the original close direction. + const orderCount = change.nextOrders.length; + await expect(host.status).toHaveText(`连续阶梯平空 · 第 1 笔确认中 · 0/2 轮 · 本轮 0/${orderCount} 笔 · 累计 2 笔`); + expect(await readSubmissions(page)).toEqual(expectedSubmissions([ + ...ORIGINAL_ORDERS.slice(0, 2), change.nextOrders[0], + ])); + + // When the rebuilt round receives its own acknowledgements through its final response gate. + await host.releaseSubmitResponse(3); + await page.clock.resume(); + const lastSequence = 2 + orderCount; + await expect.poll(host.pendingSubmitSequences).toEqual([lastSequence]); + await pauseScenarioClock(page); + await page.clock.runFor(300); + const expectedOrders = expectedSubmissions([...ORIGINAL_ORDERS.slice(0, 2), ...change.nextOrders]); + expect(await readSubmissions(page)).toEqual(expectedOrders); + await host.releaseSubmitResponse(lastSequence); + await expect.poll(() => readAcknowledgements(page)).toEqual( + Array.from({ length: lastSequence }, (value, index) => index + 1), + ); + + // Then exactly one rebuilt round completes with its new prices, quantities, order count, and cumulative total. + const completed = `连续阶梯平空 · 1s 后继续 · 1/2 轮 · 本轮 ${orderCount}/${orderCount} 笔 · 累计 ${lastSequence} 笔`; + await expect(host.status).toHaveText(completed); + expect(await readSubmissions(page)).toEqual(expectedOrders); + await expectNoCancellation(page); + + // When the user stops the session during the following cooldown. + await host.panel.getByRole('button', { name: '停止平空', exact: true }).click(); + await page.clock.runFor(3000); + + // Then the session preserves both rounds' confirmed total without starting a third round. + await expect(host.status).toHaveText(`连续阶梯平空 · 已停止 · 1/2 轮 · 本轮 ${orderCount}/${orderCount} 笔 · 累计 ${lastSequence} 笔`); + expect(await readSubmissions(page)).toEqual(expectedOrders); + expect(host.pendingSubmitSequences()).toEqual([]); + expect(host.errors).toEqual([]); + }); +} diff --git a/e2e/binance-orderbook/specs/cancel-boundaries-behavior.pw.js b/e2e/binance-orderbook/specs/cancel-boundaries-behavior.pw.js new file mode 100644 index 0000000..7b3c897 --- /dev/null +++ b/e2e/binance-orderbook/specs/cancel-boundaries-behavior.pw.js @@ -0,0 +1,355 @@ +import { test, expect } from '../test.js'; +import { + ORDER_SETS, + POSITION_SETS, + createCancelScenario, +} from '../scenarios/cancel-current-symbol.js'; +import { openUserscriptScenario, readFixtureState } from '../helpers/userscript-page.js'; +import { installScenarioClock, pauseScenarioClock } from '../helpers/scenario-clock.js'; + +const CANCEL = '[data-ladder-cancel-symbol="true"]'; +const STATUS = '#jh-binance-ladder-status'; +const FILTER = '[role="checkbox"][name="hideOtherSymbol"]'; +const ALL_ORDERS = [ + ...ORDER_SETS.both, + ...ORDER_SETS.both.map((order) => ({ + ...order, + id: `conditional-${order.id}`, + kind: 'conditional', + })), +]; + +function expectOrdersUntouched(state, scenario) { + expect(state.orders).toEqual(scenario.orders); + expect(state.events.filter(({ type }) => [ + 'cancel-requested', + 'cancel-cleared', + 'row-cancel-requested', + 'row-cancel-cleared', + 'order-submitted', + ].includes(type))).toEqual([]); + expect(state.events.filter(({ type }) => [ + 'chart-orders-checked', + 'chart-save-requested', + 'chart-saved', + ].includes(type))).toEqual([]); + expect(state.showOrders).toBe(scenario.ui.showOrders); +} + +function expectOriginalUi(state, scenario) { + expect({ + accountTab: state.accountTab, + openOrdersSubTab: state.openOrdersSubTab, + hideOtherSymbols: state.hideOtherSymbols, + showOrders: state.showOrders, + }).toEqual({ + accountTab: scenario.ui.accountTab, + openOrdersSubTab: scenario.ui.openOrdersSubTab, + hideOtherSymbols: scenario.ui.hideOtherSymbols, + showOrders: scenario.ui.showOrders, + }); +} + +test('user keeps all orders when the current-orders tab is missing', async ({ page }) => { + // Given both symbols have Basic and conditional orders but the current-orders tab is absent. + const scenario = createCancelScenario({ positions: POSITION_SETS.both, orders: ALL_ORDERS }); + await installScenarioClock(page); + const { errors } = await openUserscriptScenario(page, scenario); + await pauseScenarioClock(page); + await page.locator('[data-account-tab="openOrders"]').evaluate((tab) => tab.remove()); + + // When the user requests cancellation and the bounded restoration lookup expires. + await page.locator(CANCEL).click(); + await page.clock.runFor(2300); + + // Then the panel reports the missing tab without opening confirmation or changing account state. + await expect(page.locator(STATUS)).toHaveText('未能打开当前委托'); + await expect(page.locator(CANCEL)).toBeEnabled(); + await expect(page.getByRole('dialog')).toHaveCount(0); + const state = await readFixtureState(page); + expectOrdersUntouched(state, scenario); + expectOriginalUi(state, scenario); + expect(state.events.filter(({ type }) => type === 'account-tab')).toEqual([]); + expect(state.events.filter(({ type }) => type === 'dialog-opened')).toEqual([]); + expect(errors).toEqual([]); +}); + +test('user sees a bounded refusal when the current-orders tab never becomes selected', async ({ page }) => { + // Given the visible current-orders tab has not acquired its native click handler. + const scenario = createCancelScenario({ positions: POSITION_SETS.both, orders: ALL_ORDERS }); + await installScenarioClock(page); + const { errors } = await openUserscriptScenario(page, scenario); + await pauseScenarioClock(page); + await page.locator('[data-account-tab="openOrders"]').evaluate((tab) => { + tab.replaceWith(tab.cloneNode(true)); + }); + + // When the user requests cancellation before the tab-selection deadline. + await page.locator(CANCEL).click(); + await page.clock.runFor(2100); + + // Then selection remains pending and no confirmation or cancellation has occurred. + await expect(page.locator(CANCEL)).toBeDisabled(); + await expect(page.locator(STATUS)).not.toHaveText('未能打开当前委托'); + await expect(page.locator('[data-account-tab="positions"]')).toHaveAttribute('aria-selected', 'true'); + expectOrdersUntouched(await readFixtureState(page), scenario); + + // When the selection and restoration lookup deadlines both expire. + await page.clock.runFor(2400); + + // Then the action is ready again with an explicit failure and every original order preserved. + await expect(page.locator(STATUS)).toHaveText('未能打开当前委托'); + await expect(page.locator(CANCEL)).toBeEnabled(); + const state = await readFixtureState(page); + expectOrdersUntouched(state, scenario); + expectOriginalUi(state, scenario); + expect(state.events.filter(({ type }) => type === 'dialog-opened')).toEqual([]); + expect(state.events.filter(({ type }) => type === 'account-tab')).toEqual([]); + expect(errors).toEqual([]); +}); + +for (const scopeState of ['missing', 'duplicated']) { + test(`user cannot cancel orders when the selected current-orders panel is ${scopeState}`, async ({ page }) => { + // Given the selected tab has either no matching panel or two competing visible panels. + const scenario = createCancelScenario({ + positions: POSITION_SETS.both, + orders: ALL_ORDERS, + ui: { accountTab: 'openOrders', hideOtherSymbols: true, showOrders: false }, + }); + await installScenarioClock(page); + const { errors } = await openUserscriptScenario(page, scenario); + await pauseScenarioClock(page); + await page.locator('#OPEN_ORDERS').evaluate((scope, scopeState) => { + if (scopeState === 'missing') scope.remove(); + else scope.after(scope.cloneNode(true)); + }, scopeState); + + // When the user requests cancellation before the unique-panel discovery deadline. + await page.locator(CANCEL).click(); + await page.clock.runFor(2100); + + // Then the workflow remains pending without selecting an unverified cancellation scope. + await expect(page.locator(CANCEL)).toBeDisabled(); + await expect(page.locator(STATUS)).not.toHaveText('未找到当前委托面板'); + expectOrdersUntouched(await readFixtureState(page), scenario); + + // When the discovery and restoration lookup deadlines expire. + await page.clock.runFor(2400); + + // Then a missing-panel result preserves all orders and the original tab, filter, and chart setting. + await expect(page.locator(STATUS)).toHaveText('未找到当前委托面板'); + await expect(page.locator(CANCEL)).toBeEnabled(); + const state = await readFixtureState(page); + expectOrdersUntouched(state, scenario); + expectOriginalUi(state, scenario); + expect(state.events.filter(({ type }) => type === 'dialog-opened')).toEqual([]); + expect(errors).toEqual([]); + }); +} + +test('user preserves conditional orders when the Basic sub-tab is missing', async ({ page }) => { + // Given conditional orders are visible but the native Basic sub-tab has disappeared. + const scenario = createCancelScenario({ + positions: POSITION_SETS.both, + orders: ALL_ORDERS, + ui: { accountTab: 'openOrders', openOrdersSubTab: 'conditional' }, + }); + await installScenarioClock(page); + const { errors } = await openUserscriptScenario(page, scenario); + await pauseScenarioClock(page); + await page.locator('[data-open-orders-sub-tab="basic"]').evaluate((tab) => tab.remove()); + + // When the user requests cancellation from the userscript panel. + await page.locator(CANCEL).click(); + await page.clock.runFor(100); + + // Then Basic selection fails explicitly without using the conditional panel's cancellation control. + await expect(page.locator(STATUS)).toHaveText('未找到当前委托基础单'); + await expect(page.locator(CANCEL)).toBeEnabled(); + await expect(page.locator('[data-open-orders-sub-tab="conditional"]')).toHaveAttribute('aria-selected', 'true'); + const state = await readFixtureState(page); + expectOrdersUntouched(state, scenario); + expectOriginalUi(state, scenario); + expect(state.events.filter(({ type }) => type === 'dialog-opened')).toEqual([]); + expect(state.events.filter(({ type }) => type === 'open-orders-sub-tab')).toEqual([]); + expect(errors).toEqual([]); +}); + +test('user cannot cancel conditional orders when Basic selection never commits', async ({ page }) => { + // Given the visible Basic tab lacks its native handler while conditional orders remain selected. + const scenario = createCancelScenario({ + positions: POSITION_SETS.both, + orders: ALL_ORDERS, + ui: { accountTab: 'openOrders', openOrdersSubTab: 'conditional', showOrders: false }, + }); + await installScenarioClock(page); + const { errors } = await openUserscriptScenario(page, scenario); + await pauseScenarioClock(page); + await page.locator('[data-open-orders-sub-tab="basic"]').evaluate((tab) => { + tab.replaceWith(tab.cloneNode(true)); + }); + + // When the user requests cancellation and Basic selection is still within its deadline. + await page.locator(CANCEL).click(); + await page.clock.runFor(2100); + + // Then the action remains pending with conditional orders preserved and no confirmation. + await expect(page.locator(CANCEL)).toBeDisabled(); + await expect(page.locator('[data-open-orders-sub-tab="conditional"]')).toHaveAttribute('aria-selected', 'true'); + expectOrdersUntouched(await readFixtureState(page), scenario); + + // When the Basic-selection deadline expires. + await page.clock.runFor(200); + + // Then the script refuses cancellation and leaves the original conditional view intact. + await expect(page.locator(STATUS)).toHaveText('未找到当前委托基础单'); + await expect(page.locator(CANCEL)).toBeEnabled(); + const state = await readFixtureState(page); + expectOrdersUntouched(state, scenario); + expectOriginalUi(state, scenario); + expect(state.events.filter(({ type }) => type === 'dialog-opened')).toEqual([]); + expect(errors).toEqual([]); +}); + +for (const filterState of ['missing', 'indeterminate']) { + test(`user keeps both symbols' orders when the symbol filter is ${filterState}`, async ({ page }) => { + // Given both symbols are visible and the filter cannot report a usable checked state. + const scenario = createCancelScenario({ + positions: POSITION_SETS.both, + orders: ALL_ORDERS, + ui: { accountTab: 'openOrders', hideOtherSymbols: false }, + }); + await installScenarioClock(page); + const { errors } = await openUserscriptScenario(page, scenario); + await pauseScenarioClock(page); + await page.locator(FILTER).evaluate((filter, filterState) => { + if (filterState === 'missing') filter.remove(); + else filter.setAttribute('aria-checked', 'mixed'); + }, filterState); + + // When the user requests current-symbol cancellation. + await page.locator(CANCEL).click(); + await page.clock.runFor(100); + + // Then the unverified filter prevents confirmation and all orders and chart settings survive. + await expect(page.locator(STATUS)).toHaveText('未确认仅显示当前交易对挂单'); + await expect(page.locator(CANCEL)).toBeEnabled(); + const state = await readFixtureState(page); + expectOrdersUntouched(state, scenario); + expectOriginalUi(state, scenario); + expect(state.events.filter(({ type }) => type === 'dialog-opened')).toEqual([]); + expect(state.events.filter(({ type }) => type === 'hide-other-symbols')).toEqual([]); + expect(errors).toEqual([]); + }); +} + +test('user cannot cancel while the symbol checkbox ignores its requested change', async ({ page }) => { + // Given all-symbol orders are visible and the filter has not acquired its native click handler. + const scenario = createCancelScenario({ + positions: POSITION_SETS.both, + orders: ALL_ORDERS, + ui: { accountTab: 'openOrders', hideOtherSymbols: false }, + }); + await installScenarioClock(page); + const { errors } = await openUserscriptScenario(page, scenario); + await pauseScenarioClock(page); + await page.locator(FILTER).evaluate((filter) => filter.replaceWith(filter.cloneNode(true))); + + // When cancellation requests the filter but its one-second acknowledgement is still pending. + await page.locator(CANCEL).click(); + await page.clock.runFor(900); + + // Then no cancellation occurs while the checkbox still reports the original unchecked state. + await expect(page.locator(CANCEL)).toBeDisabled(); + await expect(page.locator(FILTER)).toHaveAttribute('aria-checked', 'false'); + expectOrdersUntouched(await readFixtureState(page), scenario); + + // When the checkbox acknowledgement deadline expires. + await page.clock.runFor(200); + + // Then the panel reports unconfirmed filtering without opening the native confirmation. + await expect(page.locator(STATUS)).toHaveText('未确认仅显示当前交易对挂单'); + await expect(page.locator(CANCEL)).toBeEnabled(); + const state = await readFixtureState(page); + expectOrdersUntouched(state, scenario); + expectOriginalUi(state, scenario); + expect(state.events.filter(({ type }) => type === 'dialog-opened')).toEqual([]); + expect(state.events.filter(({ type }) => type === 'hide-other-symbols')).toEqual([]); + expect(errors).toEqual([]); +}); + +test('user cannot cancel when a checked symbol filter still exposes another symbol row', async ({ page }) => { + // Given the checkbox is checked but a stale other-symbol row remains in the native Basic list. + const scenario = createCancelScenario({ + positions: POSITION_SETS.both, + orders: ALL_ORDERS, + ui: { accountTab: 'openOrders', hideOtherSymbols: true, showOrders: false }, + }); + await installScenarioClock(page); + const { errors } = await openUserscriptScenario(page, scenario); + await pauseScenarioClock(page); + await page.locator('#OPEN_ORDERS .orders-content').evaluate((content, otherOrder) => { + const staleRow = content.querySelector('.open-order-row').cloneNode(true); + staleRow.dataset.orderId = otherOrder.id; + const cells = staleRow.querySelectorAll('span'); + cells[1].textContent = `${otherOrder.symbol} 永续`; + cells[3].textContent = otherOrder.side; + cells[4].textContent = otherOrder.price; + cells[5].textContent = otherOrder.quantity; + content.append(staleRow); + }, ORDER_SETS.both[1]); + + // When the user requests cancellation while the filtered-row settling deadline is still pending. + await page.locator(CANCEL).click(); + await page.clock.runFor(1500); + + // Then the script waits for row evidence instead of trusting the checked box alone. + await expect(page.locator(CANCEL)).toBeDisabled(); + await expect(page.locator(FILTER)).toHaveAttribute('aria-checked', 'true'); + await expect(page.locator('#OPEN_ORDERS .open-order-row')).toHaveCount(2); + expectOrdersUntouched(await readFixtureState(page), scenario); + + // When the stale other-symbol row outlasts the filter-settling deadline. + await page.clock.runFor(200); + + // Then the mixed scope is refused and both Basic and conditional orders remain unchanged. + await expect(page.locator(STATUS)).toHaveText('未确认仅显示当前交易对挂单'); + await expect(page.locator(CANCEL)).toBeEnabled(); + const state = await readFixtureState(page); + expectOrdersUntouched(state, scenario); + expectOriginalUi(state, scenario); + expect(state.events.filter(({ type }) => type === 'dialog-opened')).toEqual([]); + expect(errors).toEqual([]); +}); + +for (const controlState of ['missing', 'duplicated', 'disabled']) { + test(`user receives a safe refusal when the native cancel control is ${controlState}`, async ({ page }) => { + // Given confirmed current-symbol Basic rows remain but their cancel control is not uniquely usable. + const scenario = createCancelScenario({ + positions: POSITION_SETS.both, + orders: ALL_ORDERS, + ui: { accountTab: 'openOrders', hideOtherSymbols: true }, + }); + await installScenarioClock(page); + const { errors } = await openUserscriptScenario(page, scenario); + await pauseScenarioClock(page); + await page.locator('[data-cancel-all]').evaluate((control, controlState) => { + if (controlState === 'missing') control.remove(); + else if (controlState === 'duplicated') control.after(control.cloneNode(true)); + else control.setAttribute('aria-disabled', 'true'); + }, controlState); + + // When the user requests cancellation from the userscript panel. + await page.locator(CANCEL).click(); + await page.clock.runFor(100); + + // Then a missing-control result preserves orders without clicking an ambiguous or disabled control. + await expect(page.locator(STATUS)).toHaveText('未找到当前委托的全撤按钮'); + await expect(page.locator(CANCEL)).toBeEnabled(); + const state = await readFixtureState(page); + expectOrdersUntouched(state, scenario); + expectOriginalUi(state, scenario); + expect(state.events.filter(({ type }) => type === 'dialog-opened')).toEqual([]); + expect(errors).toEqual([]); + }); +} diff --git a/e2e/binance-orderbook/specs/cancel-confirmed-stop-behavior.pw.js b/e2e/binance-orderbook/specs/cancel-confirmed-stop-behavior.pw.js new file mode 100644 index 0000000..d533243 --- /dev/null +++ b/e2e/binance-orderbook/specs/cancel-confirmed-stop-behavior.pw.js @@ -0,0 +1,175 @@ +import { test, expect } from '../test.js'; +import { CURRENT_SYMBOL, OTHER_SYMBOL, createCancelScenario } from '../scenarios/cancel-current-symbol.js'; +import { openUserscriptScenario, readFixtureState } from '../helpers/userscript-page.js'; +import { installScenarioClock, pauseScenarioClock } from '../helpers/scenario-clock.js'; + +const STATUS = '#jh-binance-ladder-status'; +const CANCELLATION_ORDER = ['farthest', 'middle', 'nearest']; + +function capacityOrders() { + return [ + { id: 'nearest', symbol: CURRENT_SYMBOL, kind: 'basic', side: '平空', price: '82', quantity: '1' }, + { id: 'middle', symbol: CURRENT_SYMBOL, kind: 'basic', side: '平空', price: '84', quantity: '1' }, + { id: 'farthest', symbol: CURRENT_SYMBOL, kind: 'basic', side: '平空', price: '86', quantity: '1' }, + { id: 'opposite', symbol: CURRENT_SYMBOL, kind: 'basic', side: '平多', price: '999', quantity: '1' }, + { id: 'other-symbol', symbol: OTHER_SYMBOL, kind: 'basic', side: '平空', price: '999', quantity: '1' }, + { id: 'conditional', symbol: CURRENT_SYMBOL, kind: 'conditional', side: '平空', price: '999', quantity: '1' }, + ]; +} + +function rowEvents(state, type) { + return state.events.filter(event => event.type === type).map(({ orderId }) => orderId); +} + +/** The rendered release status proves settlement; a delayed native mount leaves the next row pending. */ +async function stopOnConfirmedStatus(page, confirmedCount) { + await page.locator(STATUS).evaluate((status, count) => { + const observation = { phases: [], stop: null, observer: null }; + observation.observer = new MutationObserver(() => { + const text = status.textContent; + observation.phases.push({ at: performance.now(), text }); + if (observation.stop !== null || !text.includes(`释放挂单名额 ${count}/3`)) return; + const stopButton = document.querySelector('[data-ladder-stop]'); + if (!stopButton || stopButton.getClientRects().length === 0 || stopButton.disabled) { + throw new Error('The confirmed cancellation must expose an enabled native Stop button'); + } + observation.stop = { + at: performance.now(), + text, + loading: document.querySelector('[data-orders-loading]') !== null, + mountedOrderIds: Array.from(document.querySelectorAll('[data-order-id]'), row => row.dataset.orderId), + fixture: window.__BINANCE_FIXTURE__.snapshot(), + }; + stopButton.click(); + observation.stop.clickedAt = performance.now(); + observation.stop.afterText = status.textContent; + }); + observation.observer.observe(status, { childList: true, characterData: true, subtree: true }); + window.__CANCEL_CONFIRMED_STOP_OBSERVATION__ = observation; + }, confirmedCount); +} + +async function readObservation(page) { + return page.evaluate(() => { + const { phases, stop } = window.__CANCEL_CONFIRMED_STOP_OBSERVATION__; + return { phases, stop }; + }); +} + +/** Observe real fixture clearing without claiming that the userscript has confirmed it yet. */ +async function advanceToClearedCount(page, count) { + for (let elapsed = 0; elapsed <= 6000; elapsed += 20) { + const state = await readFixtureState(page); + const cleared = state.events.filter(({ type }) => type === 'row-cancel-cleared'); + if (cleared.length === count) return cleared.at(-1).at; + if (cleared.length > count) throw new Error('The native clock advanced beyond the requested cancellation'); + if (elapsed < 6000) await page.clock.runFor(20); + } + throw new Error('The native cancellation did not clear: ' + await page.locator(STATUS).textContent()); +} + +test.afterEach(async ({ page }, testInfo) => { + const evidence = await page.evaluate(() => { + const observation = window.__CANCEL_CONFIRMED_STOP_OBSERVATION__; + if (!observation) return null; + observation.observer.disconnect(); + delete window.__CANCEL_CONFIRMED_STOP_OBSERVATION__; + return { phases: observation.phases, stop: observation.stop }; + }); + if (testInfo.status !== testInfo.expectedStatus) { + await testInfo.attach('confirmed-cancellation-stop.json', { + body: Buffer.from(JSON.stringify(evidence, null, 2)), + contentType: 'application/json', + }); + } +}); + +for (const confirmedCount of [1, 2]) { + test(`user retains a confirmed cancellation count of ${confirmedCount} when Stop follows settlement before the next native row mounts`, async ({ page }) => { + // Given three eligible rows coexist with protected scopes and each native rerender takes longer than settlement. + await installScenarioClock(page); + const orders = capacityOrders(); + const scenario = createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: '100' }], + orders, + ui: { tradeMode: 'CLOSE', accountTab: 'openOrders', hideOtherSymbols: false }, + host: { + rowCancelDelayMs: 60, + orderRowsMountDelayMs: 320, + submitApiResponses: [ + { outcome: 'rejected', delivery: 'immediate', code: '90802025', message: 'Maximum open orders' }, + ...Array.from({ length: 5 }, () => ({ outcome: 'success', delivery: 'immediate' })), + ], + }, + }); + const { errors } = await openUserscriptScenario(page, scenario); + await pauseScenarioClock(page); + await stopOnConfirmedStatus(page, confirmedCount); + const cancelledIds = CANCELLATION_ORDER.slice(0, confirmedCount); + const remainingOrders = orders.filter(({ id }) => !cancelledIds.includes(id)); + + // When the actual continuous close receives a capacity rejection and its selected row disappears for 239 ms. + await page.locator('[data-ladder-action="CLOSE_SHORT"]').evaluate(button => { + button.dispatchEvent(new MouseEvent('click', { bubbles: true, altKey: true })); + }); + const clearedAt = await advanceToClearedCount(page, confirmedCount); + const beforeDeadline = await page.evaluate(at => at + 239 - performance.now(), clearedAt); + expect(beforeDeadline).toBeGreaterThanOrEqual(0); + await page.clock.runFor(beforeDeadline); + + // Then native removal alone has neither confirmed this row nor triggered Stop or a subsequent cancellation. + const unsettled = await readFixtureState(page); + expect(unsettled.orders).toEqual(remainingOrders); + expect(rowEvents(unsettled, 'row-cancel-requested')).toEqual(cancelledIds); + expect(rowEvents(unsettled, 'row-cancel-cleared')).toEqual(cancelledIds); + expect((await readObservation(page)).stop).toBeNull(); + await expect(page.locator(STATUS)).not.toContainText(`释放挂单名额 ${confirmedCount}/3`); + await expect(page.locator('[data-orders-loading]')).toBeVisible(); + expect(await page.locator('[data-order-id]').count()).toBe(0); + + // When the final millisecond settles the removal and its real status observer immediately clicks Stop. + await page.clock.runFor(1); + + // Then the same virtual instant preserves the confirmed count before the next native cancellation can start. + const { stop } = await readObservation(page); + const counts = `0/1 轮 · 本轮 0/5 笔 · 累计 0 笔 · 撤 ${confirmedCount} 笔`; + expect(stop.at - clearedAt).toBe(240); + expect(stop.clickedAt).toBe(stop.at); + expect(stop.text).toBe(`连续阶梯平空 · 释放挂单名额 ${confirmedCount}/3 · ${counts}`); + expect(stop.afterText).toBe(`连续阶梯平空 · 停止中 · ${counts}`); + expect(stop.loading).toBe(true); + expect(stop.mountedOrderIds).toEqual([]); + expect(stop.fixture.orders).toEqual(remainingOrders); + expect(rowEvents(stop.fixture, 'row-cancel-requested')).toEqual(cancelledIds); + expect(rowEvents(stop.fixture, 'row-cancel-cleared')).toEqual(cancelledIds); + expect(stop.fixture.events.filter(({ type }) => type === 'order-submitted') + .map(({ submitSequence, action }) => ({ submitSequence, action }))) + .toEqual([{ submitSequence: 1, action: '平空' }]); + + // When native rows finish mounting and every cancellation, submit, and continuous cooldown deadline passes. + await page.clock.runFor(10000); + + // Then the stopped session keeps exact confirmed progress, all untouched orders, and no recovery submit. + const state = await readFixtureState(page); + await expect(page.locator(STATUS)).toHaveText(`连续阶梯平空 · 已停止 · ${counts}`); + expect(state.orders).toEqual(remainingOrders); + expect(rowEvents(state, 'row-cancel-requested')).toEqual(cancelledIds); + expect(rowEvents(state, 'row-cancel-cleared')).toEqual(cancelledIds); + expect(state.events.filter(({ type }) => type === 'order-submitted') + .map(({ submitSequence, action }) => ({ submitSequence, action }))) + .toEqual([{ submitSequence: 1, action: '平空' }]); + expect(state.events.filter(({ type }) => type === 'order-submit-api-rejected') + .map(({ submitSequence, code }) => ({ submitSequence, code }))) + .toEqual([{ submitSequence: 1, code: '90802025' }]); + expect(state.events.filter(({ type }) => [ + 'order-submit-api-success', 'cancel-requested', 'cancel-cleared', 'dialog-opened', 'row-dialog-opened', + ].includes(type))).toEqual([]); + expect(state.accountTab).toBe('openOrders'); + expect(state.openOrdersSubTab).toBe('basic'); + expect(state.hideOtherSymbols).toBe(false); + expect(state.showOrders).toBe(true); + await expect(page.getByRole('button', { name: '停止平空', exact: true })).toHaveCount(0); + await expect(page.getByRole('button', { name: '阶梯平空', exact: true })).toBeEnabled(); + expect(errors).toEqual([]); + }); +} diff --git a/e2e/binance-orderbook/specs/cancel-observation-boundaries.pw.js b/e2e/binance-orderbook/specs/cancel-observation-boundaries.pw.js new file mode 100644 index 0000000..25d2607 --- /dev/null +++ b/e2e/binance-orderbook/specs/cancel-observation-boundaries.pw.js @@ -0,0 +1,118 @@ +import { test, expect } from '../test.js'; +import { CURRENT_SYMBOL, OTHER_SYMBOL, ORDER_SETS, POSITION_SETS, createCancelScenario } from '../scenarios/cancel-current-symbol.js'; +import { openUserscriptScenario, readFixtureState } from '../helpers/userscript-page.js'; +import { installScenarioClock, pauseScenarioClock } from '../helpers/scenario-clock.js'; + +const STATUS = '#jh-binance-ladder-status'; + +async function beginCancellation(page, options = null) { + await page.evaluate(options => { + window.__CANCEL_TEST_RESULT__ = null; + window.__TM_CLOSE_LONG_DEBUG__.cancelCurrentSymbolOpenOrders(options).then(result => { + window.__CANCEL_TEST_RESULT__ = result; + }); + }, options); +} + +async function cancelResult(page) { + return page.evaluate(() => window.__CANCEL_TEST_RESULT__); +} + +for (const changed of ['symbol', 'scope', 'filter']) { + test(`user stops cancellation observation when its ${changed} changes after native confirmation`, async ({ page }) => { + // Given the native host accepts one scoped cancellation but does not claim its orders are cleared. + await installScenarioClock(page); + const scenario = createCancelScenario({ + positions: POSITION_SETS.both, orders: ORDER_SETS.both, + ui: { accountTab: 'openOrders', hideOtherSymbols: true }, host: { clearMode: 'none' }, + }); + const host = await openUserscriptScenario(page, scenario); + await pauseScenarioClock(page); + await beginCancellation(page); + await page.clock.runFor(100); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.locator('[data-dialog-action="confirm"]').click(); + await page.clock.runFor(100); + await expect(page.locator(STATUS)).toHaveText('撤单已确认,等待挂单清空'); + + // When native navigation or UI replacement invalidates the observed cancellation scope. + await page.evaluate(changed => { + if (changed === 'symbol') window.__BINANCE_FIXTURE__.switchSymbol('BTCUSDT'); + if (changed === 'scope') document.querySelector('#OPEN_ORDERS').remove(); + if (changed === 'filter') document.querySelector('[role="checkbox"][name="hideOtherSymbol"]').click(); + }, changed); + await page.clock.runFor(9000); + + // Then the exact loss of evidence is reported without a second request or a fabricated cleared result. + const expected = { + symbol: { status: 'symbol_changed', message: '等待撤单完成时交易对已变化' }, + scope: { status: 'scope_not_found', message: '等待撤单完成时未找到当前委托面板' }, + filter: { status: 'symbol_filter_not_confirmed', message: '等待撤单完成时未确认仅显示当前交易对挂单' }, + }[changed]; + await expect.poll(() => cancelResult(page)).toEqual({ ok: false, ...expected }); + const state = await readFixtureState(page); + expect(state.orders).toEqual(scenario.orders); + expect(state.currentSymbol).toBe(changed === 'symbol' ? OTHER_SYMBOL : CURRENT_SYMBOL); + expect(state.events.filter(({ type }) => type === 'cancel-requested')).toHaveLength(1); + expect(state.events.filter(({ type }) => type === 'cancel-cleared' || type === 'order-submitted')).toEqual([]); + expect(host.errors).toEqual([]); + }); +} + +for (const clear of [true, false]) { + test(`user receives an explicit ${clear ? 'cleared' : 'still-open'} result from the cancellation completion contract`, async ({ page }) => { + // Given both symbols have orders and completion must be observed before any caller can continue. + await installScenarioClock(page); + const scenario = createCancelScenario({ + positions: POSITION_SETS.both, orders: ORDER_SETS.both, + ui: { accountTab: 'openOrders', hideOtherSymbols: true }, + host: { clearMode: clear ? 'capturedScope' : 'none' }, + }); + const host = await openUserscriptScenario(page, scenario); + await pauseScenarioClock(page); + + // When the completion-aware public action receives the user's native confirmation. + await beginCancellation(page, { waitUntilCleared: true }); + await page.clock.runFor(100); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.locator('[data-dialog-action="confirm"]').click(); + await page.clock.runFor(7000); + + // Then only confirmed clearing permits a successful result; another symbol is preserved in either case. + await expect.poll(() => cancelResult(page)).toEqual(clear + ? { ok: true, status: 'cleared' } + : { ok: false, status: 'not_cleared', message: '当前交易对挂单仍存在,已停止重新挂单' }); + await expect(page.locator(STATUS)).toHaveText(clear + ? '原挂单已撤,继续阶梯挂单' : '当前交易对挂单仍存在,已停止重新挂单'); + const state = await readFixtureState(page); + expect(state.orders).toEqual(clear ? scenario.orders.filter(order => order.symbol === OTHER_SYMBOL) : scenario.orders); + expect(state.events.filter(({ type }) => type === 'cancel-requested')).toHaveLength(1); + expect(state.events.filter(({ type }) => type === 'order-submitted')).toEqual([]); + expect(host.errors).toEqual([]); + }); +} + +test('user cannot open a cancellation dialog if navigation changes symbol while selecting current orders', async ({ page }) => { + // Given selecting the current-orders tab coincides with a native route change. + await installScenarioClock(page); + const scenario = createCancelScenario({ positions: POSITION_SETS.both, orders: ORDER_SETS.both }); + const host = await openUserscriptScenario(page, scenario); + await pauseScenarioClock(page); + await page.locator('[data-account-tab="openOrders"]').evaluate(tab => { + tab.addEventListener('click', () => window.__BINANCE_FIXTURE__.switchSymbol('BTCUSDT'), { once: true }); + }); + + // When the actual cancellation action tries to open the captured symbol's current orders. + await beginCancellation(page); + await page.clock.runFor(100); + + // Then the new route cannot inherit the pending financial action or its original order IDs. + await expect.poll(() => cancelResult(page)).toEqual({ + ok: false, status: 'symbol_changed', message: '打开当前委托时交易对已变化', + }); + const state = await readFixtureState(page); + expect(state.currentSymbol).toBe(OTHER_SYMBOL); + expect(state.orders).toEqual(scenario.orders); + expect(state.events.filter(({ type }) => type === 'dialog-opened' || type === 'cancel-requested')).toEqual([]); + expect(host.errors).toEqual([]); +}); diff --git a/e2e/binance-orderbook/specs/continuous-chart-saves-behavior.pw.js b/e2e/binance-orderbook/specs/continuous-chart-saves-behavior.pw.js new file mode 100644 index 0000000..f79c9fd --- /dev/null +++ b/e2e/binance-orderbook/specs/continuous-chart-saves-behavior.pw.js @@ -0,0 +1,275 @@ +import { test, expect } from '../test.js'; +import { CURRENT_SYMBOL, ORDER_SETS, createCancelScenario } from '../scenarios/cancel-current-symbol.js'; +import { openUserscriptScenario, readFixtureState } from '../helpers/userscript-page.js'; +import { installScenarioClock, pauseScenarioClock } from '../helpers/scenario-clock.js'; + +const STATUS = '#jh-binance-ladder-status'; +const EXISTING_ORDERS = [ + { ...ORDER_SETS.current[0], id: 'original-1' }, + { ...ORDER_SETS.current[0], id: 'original-2' }, +]; + +async function eventsOfType(page, type) { + return (await readFixtureState(page)).events.filter(event => event.type === type); +} + +async function savedSnapshots(page) { + return (await eventsOfType(page, 'chart-saved')).map(({ snapshot }) => snapshot); +} + +async function saveMethodIsNative(nativeSave) { + return nativeSave.evaluate(original => ( + document.querySelector('.chart-widget-root iframe').contentWindow.tradingViewApi.saveChart === original + )); +} + +/** Stop virtual time near the actual request, before its 250 ms drawing-discovery deadline. */ +async function advanceToSubmission(page, host, sequence) { + for (let step = 0; step < 120; step += 1) { + const pending = host.pendingSubmitSequences(); + if (pending.length > 0) { + expect(pending).toEqual([sequence]); + return; + } + await page.clock.runFor(25); + } + expect(host.pendingSubmitSequences()).toEqual([sequence]); +} + +async function advanceFromDrawing(page, drawingId, eventType, elapsed) { + const remaining = await page.evaluate(({ drawingId, eventType, elapsed }) => { + const event = window.__BINANCE_FIXTURE__.snapshot().events.find(entry => ( + entry.type === 'chart-drawing-event' + && entry.drawingId === drawingId + && entry.eventType === eventType + )); + if (!event) throw new Error(`No native ${eventType} event exists for ${drawingId}`); + return event.at + elapsed - performance.now(); + }, { drawingId, eventType, elapsed }); + expect(remaining).toBeGreaterThanOrEqual(0); + await page.clock.runFor(remaining); +} + +async function releaseAcceptedDrawing(page, host, sequence) { + await host.releaseSubmitResponse(sequence); + await expect.poll(async () => (await eventsOfType(page, 'chart-drawing-event')) + .filter(({ eventType }) => eventType === 'create').map(({ drawingId }) => drawingId)) + .toEqual(Array.from({ length: sequence }, (_, index) => `order-submitted-${index + 1}`)); +} + +async function openContinuousDrawingHost(page, { + orders = [], + responses = Array.from({ length: 3 }, () => ({ outcome: 'success', delivery: 'manual' })), +} = {}) { + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: '100' }], + orders, + ui: { tradeMode: 'CLOSE', accountTab: 'openOrders' }, + host: { + orderDrawingEvents: true, + submitApiResponses: responses, + }, + })); + await page.locator('[data-ladder-group="levels"][data-ladder-value="3"]').click(); + const nativeSave = await page.evaluateHandle(() => ( + document.querySelector('.chart-widget-root iframe').contentWindow.tradingViewApi.saveChart + )); + await pauseScenarioClock(page); + await page.locator('[data-ladder-action="CLOSE_SHORT"]').click({ modifiers: ['Alt'] }); + await advanceToSubmission(page, host, 1); + return { ...host, nativeSave }; +} + +test('user saves one complete chart per continuous round after every accepted order drawing settles', async ({ page }) => { + // Given the first native request is pending in a three-order continuous close round. + const host = await openContinuousDrawingHost(page); + expect(await saveMethodIsNative(host.nativeSave)).toBe(true); + expect(await savedSnapshots(page)).toEqual([]); + + // When the first accepted order creates its native drawing and approaches the 120 ms quiet deadline. + await releaseAcceptedDrawing(page, host, 1); + await advanceFromDrawing(page, 'order-submitted-1', 'create', 119); + + // Then its save is captured while confirmation progress waits for the complete drawing lifecycle. + expect(await saveMethodIsNative(host.nativeSave)).toBe(false); + expect((await eventsOfType(page, 'chart-save-requested')).map(({ snapshot }) => snapshot)).toEqual([ + { checked: true, drawingIds: ['order-submitted-1'] }, + ]); + expect(await savedSnapshots(page)).toEqual([]); + await expect(page.locator(STATUS)).toHaveText('连续阶梯平空 · 第 1 笔确认中 · 0/1 轮 · 本轮 0/3 笔 · 累计 0 笔'); + + // When the first two drawing bursts finish and the third native order is submitted. + await page.clock.runFor(1); + await advanceToSubmission(page, host, 2); + expect(await saveMethodIsNative(host.nativeSave)).toBe(true); + await releaseAcceptedDrawing(page, host, 2); + await advanceFromDrawing(page, 'order-submitted-2', 'create', 120); + await advanceToSubmission(page, host, 3); + + // Then no partial full-chart save escaped before the round's final order. + expect(await savedSnapshots(page)).toEqual([]); + expect((await eventsOfType(page, 'chart-save-requested')).map(({ snapshot }) => snapshot)).toEqual([ + { checked: true, drawingIds: ['order-submitted-1'] }, + { checked: true, drawingIds: ['order-submitted-1', 'order-submitted-2'] }, + ]); + await expect(page.locator(STATUS)).toContainText('本轮 2/3 笔 · 累计 2 笔'); + + // When the last order is accepted and its own quiet period completes. + await releaseAcceptedDrawing(page, host, 3); + await advanceFromDrawing(page, 'order-submitted-3', 'create', 119); + expect(await savedSnapshots(page)).toEqual([]); + await page.clock.runFor(1); + + // Then the round saves only its final three-drawing snapshot and restores the native save method. + await expect(page.locator(STATUS)).toHaveText('连续阶梯平空 · 1s 后继续 · 1/1 轮 · 本轮 3/3 笔 · 累计 3 笔'); + expect(await savedSnapshots(page)).toEqual([ + { checked: true, drawingIds: ['order-submitted-1', 'order-submitted-2', 'order-submitted-3'] }, + ]); + expect(await eventsOfType(page, 'chart-save-requested')).toHaveLength(3); + expect(await saveMethodIsNative(host.nativeSave)).toBe(true); + + // When the user stops during cooldown and several possible round deadlines pass. + await page.getByRole('button', { name: '停止平空', exact: true }).click(); + await page.clock.runFor(5_000); + + // Then the same saved snapshot remains final and no fourth order can begin. + await expect(page.locator(STATUS)).toContainText('已停止'); + expect(await eventsOfType(page, 'order-submitted')).toHaveLength(3); + expect(await eventsOfType(page, 'chart-saved')).toHaveLength(1); + expect(await saveMethodIsNative(host.nativeSave)).toBe(true); + await host.nativeSave.dispose(); + expect(host.errors).toEqual([]); +}); + +test('user stopping inside a drawing burst preserves the partial round and restores ordinary native removal saves', async ({ page }) => { + // Given one drawing is deferred and a second native submission is pending in the same round. + const host = await openContinuousDrawingHost(page, { orders: EXISTING_ORDERS }); + await releaseAcceptedDrawing(page, host, 1); + await advanceFromDrawing(page, 'order-submitted-1', 'create', 120); + await advanceToSubmission(page, host, 2); + + // When the second drawing requests a save and the user presses Stop before its quiet period ends. + await releaseAcceptedDrawing(page, host, 2); + await advanceFromDrawing(page, 'order-submitted-2', 'create', 100); + expect((await eventsOfType(page, 'order-submit-api-success')).map(({ submitSequence }) => submitSequence)) + .toEqual([1, 2]); + await expect(page.locator(STATUS)).toContainText('本轮 1/3 笔 · 累计 1 笔'); + await page.getByRole('button', { name: '停止平空', exact: true }).click(); + await advanceFromDrawing(page, 'order-submitted-2', 'create', 119); + + // Then Stop has not discarded the pending snapshot or falsely advanced the last confirmation. + expect(await savedSnapshots(page)).toEqual([]); + expect(await saveMethodIsNative(host.nativeSave)).toBe(false); + await expect(page.locator(STATUS)).toContainText('停止中'); + await expect(page.locator(STATUS)).toContainText('本轮 1/3 笔 · 累计 1 笔'); + + // When the final millisecond completes the active drawing burst and stopped-round cleanup. + await page.clock.runFor(1); + + // Then exactly the confirmed partial round is persisted and the native save function is restored. + await expect(page.locator(STATUS)).toHaveText('连续阶梯平空 · 已停止 · 0/1 轮 · 本轮 2/3 笔 · 累计 2 笔'); + expect(await savedSnapshots(page)).toEqual([ + { checked: true, drawingIds: ['order-original-1', 'order-original-2', 'order-submitted-1', 'order-submitted-2'] }, + ]); + expect(await saveMethodIsNative(host.nativeSave)).toBe(true); + + // When a later native row cancellation removes one original order after the session ended. + await page.locator('[data-order-id="original-1"] svg[aria-label="撤销挂单"]').click(); + await page.clock.runFor(1); + await advanceFromDrawing(page, 'order-original-1', 'remove', 100); + + // Then its native save runs at 100 ms without a leaked continuous listener retaining it until 120 ms. + expect(await savedSnapshots(page)).toEqual([ + { checked: true, drawingIds: ['order-original-1', 'order-original-2', 'order-submitted-1', 'order-submitted-2'] }, + { checked: true, drawingIds: ['order-original-2', 'order-submitted-1', 'order-submitted-2'] }, + ]); + expect(await saveMethodIsNative(host.nativeSave)).toBe(true); + await page.clock.runFor(5_000); + expect(await eventsOfType(page, 'order-submitted')).toHaveLength(2); + expect(await eventsOfType(page, 'chart-saved')).toHaveLength(2); + await host.nativeSave.dispose(); + expect(host.errors).toEqual([]); +}); + +test('user retains the last accepted chart snapshot when a later order ends the continuous round with a rejection', async ({ page }) => { + // Given one accepted order drawing is deferred and the second native response will reject the order. + const host = await openContinuousDrawingHost(page, { responses: [ + { outcome: 'success', delivery: 'manual' }, + { outcome: 'rejected', delivery: 'manual', code: '400123', message: 'Account restricted' }, + ] }); + await releaseAcceptedDrawing(page, host, 1); + await advanceFromDrawing(page, 'order-submitted-1', 'create', 120); + await advanceToSubmission(page, host, 2); + expect(await savedSnapshots(page)).toEqual([]); + + // When the rejection arrives and the unmatched drawing-discovery deadline completes. + await host.releaseSubmitResponse(2); + await expect.poll(async () => (await eventsOfType(page, 'order-submit-api-rejected')).length).toBe(1); + await page.clock.runFor(250); + + // Then failed-round cleanup saves the accepted drawing once and restores the original chart owner. + await expect(page.locator(STATUS)).toContainText('失败'); + await expect(page.locator(STATUS)).toContainText('错误码 400123'); + await expect(page.locator(STATUS)).toContainText('本轮 1/3 笔 · 累计 1 笔'); + expect(await savedSnapshots(page)).toEqual([{ checked: true, drawingIds: ['order-submitted-1'] }]); + expect((await eventsOfType(page, 'chart-drawing-event')).map(({ drawingId, eventType }) => ({ drawingId, eventType }))) + .toEqual([{ drawingId: 'order-submitted-1', eventType: 'create' }]); + expect(await saveMethodIsNative(host.nativeSave)).toBe(true); + + // When terminal cleanup receives its queued panel-rendering animation frame. + await page.clock.runFor(16); + + // Then the completed session releases Stop and permits a fresh close action. + await expect(page.locator('[data-ladder-stop]')).toHaveCount(0); + await expect(page.locator('[data-ladder-action="CLOSE_SHORT"]')).toBeEnabled(); + + // When several possible recovery intervals elapse after that terminal rejection. + await page.clock.runFor(5_000); + + // Then neither a duplicate snapshot nor a third order is created. + expect(await eventsOfType(page, 'chart-saved')).toHaveLength(1); + expect(await eventsOfType(page, 'order-submitted')).toHaveLength(2); + await host.nativeSave.dispose(); + expect(host.errors).toEqual([]); +}); + +test('user coalesces native order-removal saves while a continuous submit is pending', async ({ page }) => { + // Given the first submit remains unanswered while two original native orders are visible. + const host = await openContinuousDrawingHost(page, { orders: EXISTING_ORDERS }); + + // When native row actions remove both orders during one short drawing burst. + await page.locator('[data-order-id="original-1"] svg[aria-label="撤销挂单"]').click(); + await page.clock.runFor(1); + await page.locator('[data-order-id="original-2"] svg[aria-label="撤销挂单"]').click(); + await page.clock.runFor(1); + await advanceFromDrawing(page, 'order-original-2', 'remove', 119); + + // Then both native save requests are captured without prematurely serializing the chart. + expect((await eventsOfType(page, 'chart-drawing-event')).map(({ drawingId, eventType }) => ({ drawingId, eventType }))) + .toEqual([ + { drawingId: 'order-original-1', eventType: 'remove' }, + { drawingId: 'order-original-2', eventType: 'remove' }, + ]); + expect(await eventsOfType(page, 'chart-save-requested')).toHaveLength(2); + expect(await savedSnapshots(page)).toEqual([]); + expect(await saveMethodIsNative(host.nativeSave)).toBe(false); + + // When the final removal settles and the user stops the still pending submit. + await page.clock.runFor(1); + expect(await savedSnapshots(page)).toEqual([{ checked: true, drawingIds: [] }]); + await page.getByRole('button', { name: '停止平空', exact: true }).click(); + await page.clock.runFor(300); + await host.releaseSubmitResponse(1, { outcome: 'rejected', code: '400123', message: 'Account restricted' }); + await expect.poll(async () => (await eventsOfType(page, 'order-submit-api-rejected')).length).toBe(1); + await page.clock.runFor(5_000); + + // Then the final empty chart was saved once and stopped cleanup leaves the native method intact. + await expect(page.locator(STATUS)).toContainText('已停止'); + expect(await savedSnapshots(page)).toEqual([{ checked: true, drawingIds: [] }]); + expect(await saveMethodIsNative(host.nativeSave)).toBe(true); + expect(await eventsOfType(page, 'order-submitted')).toHaveLength(1); + expect((await readFixtureState(page)).orders).toEqual([]); + await host.nativeSave.dispose(); + expect(host.errors).toEqual([]); +}); diff --git a/e2e/binance-orderbook/specs/continuous-readiness-behavior.pw.js b/e2e/binance-orderbook/specs/continuous-readiness-behavior.pw.js new file mode 100644 index 0000000..812361b --- /dev/null +++ b/e2e/binance-orderbook/specs/continuous-readiness-behavior.pw.js @@ -0,0 +1,672 @@ +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'; + +const PANEL = '#jh-binance-close-qty-multiplier-panel'; +const STATUS = '#jh-binance-ladder-status'; +const POSITION_PATH = '**/bapi/futures/v6/private/future/user-data/user-position'; + +test.afterEach(async ({ page }, testInfo) => { + const observation = await page.evaluate(() => { + const probe = window.__CONTINUOUS_STATUS_PROBE__; + if (!probe) return null; + const snapshot = { + phases: probe.events, + nativeButtons: Array.from(document.querySelectorAll('.order-entry button'), (button) => ({ + text: button.textContent, + disabled: button.disabled, + display: getComputedStyle(button).display, + })), + precision: document.querySelector('#futuresOrderbook .tick-content')?.textContent, + stopButtons: document.querySelectorAll('[data-ladder-stop]').length, + }; + probe.observer.disconnect(); + delete window.__CONTINUOUS_STATUS_PROBE__; + return snapshot; + }); + if (testInfo.status !== testInfo.expectedStatus && observation !== null) { + await testInfo.attach('continuous-readiness.json', { + body: Buffer.from(JSON.stringify(observation, null, 2)), + contentType: 'application/json', + }); + } +}); + +/** Observe rendered phases so timing assertions start at the actual transition. */ +async function observeContinuousStatus(page) { + await page.locator(STATUS).evaluate((status) => { + const events = []; + const observer = new MutationObserver(() => { + events.push({ at: performance.now(), text: status.textContent }); + }); + observer.observe(status, { childList: true, characterData: true, subtree: true }); + window.__CONTINUOUS_STATUS_PROBE__ = { events, observer }; + }); +} + +async function advanceFromStatus(page, text, elapsed) { + const remaining = await page.evaluate(({ text, elapsed }) => { + const event = window.__CONTINUOUS_STATUS_PROBE__.events + .filter((entry) => entry.text.includes(text)).at(-1); + if (!event) throw new Error(`The rendered phase was not observed: ${text}`); + return event.at + elapsed - performance.now(); + }, { text, elapsed }); + expect(remaining).toBeGreaterThanOrEqual(0); + await page.clock.runFor(remaining); +} + +async function readSubmissions(page) { + return (await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted'); +} + +function observePositionRequests(page) { + let requests = 0; + page.on('request', (request) => { + if (new URL(request.url()).pathname === '/bapi/futures/v6/private/future/user-data/user-position') { + requests += 1; + } + }); + return () => requests; +} + +/** The last order of each round and the next round's first order are network gates. */ +async function openPendingFirstRound(page) { + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: '100' }], + ui: { tradeMode: 'CLOSE', orderbookPrecision: '0.1' }, + host: { + submitApiResponses: [ + { outcome: 'success', delivery: 'immediate' }, + { outcome: 'success', delivery: 'immediate' }, + { outcome: 'success', delivery: 'manual' }, + { outcome: 'success', delivery: 'manual' }, + { outcome: 'success', delivery: 'immediate' }, + { outcome: 'success', delivery: 'manual' }, + ], + }, + })); + const panel = page.locator(PANEL); + await panel.locator('[data-ladder-group="levels"][data-ladder-value="3"]').click(); + await observeContinuousStatus(page); + await panel.getByRole('button', { name: '阶梯平空', exact: true }).click({ modifiers: ['Alt'] }); + await expect.poll(host.pendingSubmitSequences).toEqual([3]); + await pauseScenarioClock(page); + return { + ...host, + panel, + status: panel.locator(STATUS), + nativeButton: page.locator('.order-entry').getByRole('button', { + name: '平空', exact: true, includeHidden: true, + }), + }; +} + +async function stopContinuousRound(page, host) { + await host.panel.getByRole('button', { name: '停止平空', exact: true }).click(); + await page.clock.runFor(100); + await expect(host.status).toContainText('已停止'); +} + +/** Hold the first position response; the second explicitly confirms flat. */ +async function gatePositionRecheck(page, firstResponse) { + const release = Promise.withResolvers(); + let requests = 0; + await page.route(POSITION_PATH, async (route) => { + requests += 1; + expect(requests).toBeLessThanOrEqual(2); + if (requests === 1) { + await release.promise; + await route.fulfill(firstResponse); + return; + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, data: [] }), + }); + }); + return { + requestCount: () => requests, + async releaseFirst() { + const received = page.waitForResponse(POSITION_PATH); + release.resolve(); + const response = await received; + await response.finished(); + }, + }; +} + +test('user completes two close-short rounds with a full cooldown and exact cumulative progress', async ({ page }) => { + // Given the first three-order round is waiting for its final acknowledgement. + const host = await openPendingFirstRound(page); + + // When that acknowledgement completes the first round. + await host.releaseSubmitResponse(3); + + // Then the confirmed total is three and the next round cannot start before one second. + await expect(host.status).toHaveText('连续阶梯平空 · 1s 后继续 · 1/1 轮 · 本轮 3/3 笔 · 累计 3 笔'); + await advanceFromStatus(page, '1s 后继续', 999); + expect(await readSubmissions(page)).toHaveLength(3); + expect(host.pendingSubmitSequences()).toEqual([]); + + // When the full cooldown passes and the second round receives its own acknowledgements. + await page.clock.resume(); + await expect.poll(host.pendingSubmitSequences).toEqual([4]); + await host.releaseSubmitResponse(4); + await expect.poll(host.pendingSubmitSequences).toEqual([6]); + await pauseScenarioClock(page); + await host.releaseSubmitResponse(6); + + // Then both rounds preserve their initial direction and only six confirmed orders are counted. + await expect(host.status).toHaveText('连续阶梯平空 · 1s 后继续 · 2/2 轮 · 本轮 3/3 笔 · 累计 6 笔'); + await stopContinuousRound(page, host); + const submissions = await readSubmissions(page); + expect(submissions.map(({ action, price, quantity }) => ({ action, price, quantity }))).toEqual([ + { action: '平空', price: '80.9', quantity: '0.1' }, + { action: '平空', price: '80.4', quantity: '0.1' }, + { action: '平空', price: '79.9', quantity: '0.1' }, + { action: '平空', price: '80.9', quantity: '0.1' }, + { action: '平空', price: '80.4', quantity: '0.1' }, + { action: '平空', price: '79.9', quantity: '0.1' }, + ]); + 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); + await host.nativeButton.evaluate((button) => { button.disabled = true; }); + + // When the final response succeeds and the button stays unavailable for two seconds. + await host.releaseSubmitResponse(3); + await expect(host.status).toContainText('等待按钮恢复'); + await page.clock.runFor(2_000); + + // Then the waiting runner preserves the three confirmed submissions. + expect(await readSubmissions(page)).toHaveLength(3); + await expect(host.status).toContainText('累计 3 笔'); + + // When the native host enables its close button again. + await host.nativeButton.evaluate((button) => { button.disabled = false; }); + await page.clock.runFor(50); + + // Then a new full second is required before the fourth order can begin. + await expect(host.status).toContainText('1s 后继续'); + await advanceFromStatus(page, '1s 后继续', 999); + expect(await readSubmissions(page)).toHaveLength(3); + await page.clock.resume(); + await expect.poll(host.pendingSubmitSequences).toEqual([4]); + await pauseScenarioClock(page); + await stopContinuousRound(page, host); + await host.releaseSubmitResponse(4); + await expect.poll(async () => (await readFixtureState(page)).events + .filter(({ type }) => type === 'order-submit-api-success').length).toBe(4); + await page.clock.runFor(5_000); + expect(await readSubmissions(page)).toHaveLength(4); + expect(host.errors).toEqual([]); +}); + +test('user stops during the inter-round cooldown before any next-round submission', async ({ page }) => { + // Given one close round has completed and its cooldown is active. + const host = await openPendingFirstRound(page); + await host.releaseSubmitResponse(3); + await expect(host.status).toContainText('1s 后继续'); + await advanceFromStatus(page, '1s 后继续', 500); + + // When the user presses Stop and several possible round intervals pass. + await stopContinuousRound(page, host); + await page.clock.runFor(5_000); + + // Then the completed round remains counted and no new order can restart it. + await expect(host.status).toHaveText('连续阶梯平空 · 已停止 · 1/1 轮 · 本轮 3/3 笔 · 累计 3 笔'); + await expect(host.panel.getByRole('button', { name: '阶梯平空', exact: true })).toBeEnabled(); + expect(await readSubmissions(page)).toHaveLength(3); + expect(host.pendingSubmitSequences()).toEqual([]); + expect(host.errors).toEqual([]); +}); + +test('user restarts the full cooldown when the close button becomes busy before it expires', async ({ page }) => { + // Given a completed round is halfway through its inter-round cooldown. + const host = await openPendingFirstRound(page); + await host.releaseSubmitResponse(3); + await expect(host.status).toContainText('1s 后继续'); + await advanceFromStatus(page, '1s 后继续', 500); + + // When the native button becomes busy and the original cooldown expires. + await host.nativeButton.evaluate((button) => { button.setAttribute('aria-busy', 'true'); }); + await advanceFromStatus(page, '1s 后继续', 1_050); + + // Then no next-round request is sent while readiness is lost. + await expect(host.status).toContainText('等待按钮恢复'); + expect(await readSubmissions(page)).toHaveLength(3); + + // When the host clears the busy state. + await host.nativeButton.evaluate((button) => { button.removeAttribute('aria-busy'); }); + await page.clock.runFor(50); + + // Then the recovered button must pass another complete cooldown before the next request. + await expect(host.status).toContainText('1s 后继续'); + await advanceFromStatus(page, '1s 后继续', 999); + expect(await readSubmissions(page)).toHaveLength(3); + await page.clock.resume(); + await expect.poll(host.pendingSubmitSequences).toEqual([4]); + await pauseScenarioClock(page); + await stopContinuousRound(page, host); + await host.releaseSubmitResponse(4); + await expect.poll(async () => (await readFixtureState(page)).events + .filter(({ type }) => type === 'order-submit-api-success').length).toBe(4); + await page.clock.runFor(5_000); + expect(await readSubmissions(page)).toHaveLength(4); + expect(host.errors).toEqual([]); +}); + +test('user stops while waiting for a busy button and readiness cannot revive the session', async ({ page }) => { + // Given one completed round is waiting for native loading to finish. + const host = await openPendingFirstRound(page); + await host.nativeButton.evaluate((button) => { button.setAttribute('data-loading', 'true'); }); + await host.releaseSubmitResponse(3); + await expect(host.status).toContainText('等待按钮恢复'); + + // When the user stops and the native host later clears its loading state. + await stopContinuousRound(page, host); + await host.nativeButton.evaluate((button) => { button.removeAttribute('data-loading'); }); + await page.clock.runFor(5_000); + + // Then the completed progress remains terminal and no next-round order appears. + await expect(host.status).toHaveText('连续阶梯平空 · 已停止 · 1/1 轮 · 本轮 3/3 笔 · 累计 3 笔'); + expect(await readSubmissions(page)).toHaveLength(3); + expect(host.pendingSubmitSequences()).toEqual([]); + expect(host.errors).toEqual([]); +}); + +test('user stops a pending continuous order without counting its late acknowledgement', async ({ page }) => { + // Given two orders are confirmed and the third response is still held by the native host. + const host = await openPendingFirstRound(page); + + // When the user stops before the third acknowledgement arrives. + await stopContinuousRound(page, host); + + // Then the stopped round counts only the two acknowledged orders. + await expect(host.status).toHaveText('连续阶梯平空 · 已停止 · 0/1 轮 · 本轮 2/3 笔 · 累计 2 笔'); + const terminalStatus = await host.status.textContent(); + + // When the third response succeeds after the stop and former recovery deadlines pass. + await host.releaseSubmitResponse(3); + await expect.poll(async () => (await readFixtureState(page)).events + .filter(({ type }) => type === 'order-submit-api-success').length).toBe(3); + await page.clock.runFor(15_000); + + // Then neither the confirmed total nor the request count is revived by the late response. + await expect(host.status).toHaveText(terminalStatus); + expect(await readSubmissions(page)).toHaveLength(3); + expect(host.pendingSubmitSequences()).toEqual([]); + expect(host.errors).toEqual([]); +}); + +test('user ends continuous closing when the native trade mode changes during cooldown', async ({ page }) => { + // Given one close round is complete and another has not yet started. + const host = await openPendingFirstRound(page); + await host.releaseSubmitResponse(3); + await expect(host.status).toContainText('1s 后继续'); + + // When the user switches the native form to opening positions. + await page.locator('#position-direction [data-trade-mode="OPEN"]').click(); + await page.clock.runFor(1_100); + + // Then the continuous session stops with the mode-change reason and preserves its confirmed total. + await expect(host.status).toContainText('已停止'); + await expect(host.status).toContainText('开仓/平仓模式已切换'); + await expect(host.status).toContainText('累计 3 笔'); + expect((await readFixtureState(page)).tradeMode).toBe('OPEN'); + expect(await readSubmissions(page)).toHaveLength(3); + expect(host.errors).toEqual([]); +}); + +test('user ends continuous closing when the symbol changes while waiting for readiness', async ({ page }) => { + // Given the original symbol has one completed round waiting for a disabled close button. + const host = await openPendingFirstRound(page); + await host.nativeButton.evaluate((button) => { button.disabled = true; }); + await host.releaseSubmitResponse(3); + await expect(host.status).toContainText('等待按钮恢复'); + + // When the native fixture navigates to another futures symbol. + await page.evaluate(() => window.__BINANCE_FIXTURE__.switchSymbol('BTCUSDT')); + await page.clock.runFor(100); + + // Then the original session stops with its symbol-change reason and creates no BTC order. + await expect(host.status).toContainText('已停止'); + await expect(host.status).toContainText('交易对已切换'); + expect((await readFixtureState(page)).currentSymbol).toBe('BTCUSDT'); + expect(await readSubmissions(page)).toHaveLength(3); + + // When the user returns to the original symbol after the stop. + await page.evaluate(symbol => window.__BINANCE_FIXTURE__.switchSymbol(symbol), CURRENT_SYMBOL); + await page.clock.runFor(5_000); + + // Then returning to the original route does not restart the former session. + expect(await readSubmissions(page)).toHaveLength(3); + await expect(host.panel.locator('[data-ladder-stop]')).toHaveCount(0); + expect(host.errors).toEqual([]); +}); + +test('user waits for an invisible close button before starting another round', async ({ page }) => { + // Given the last acknowledgement can arrive after the host hides its native close button. + const host = await openPendingFirstRound(page); + await host.nativeButton.evaluate((button) => { button.style.display = 'none'; }); + + // When the first round finishes while that button has no rendered geometry. + await host.releaseSubmitResponse(3); + await expect(host.status).toContainText('等待按钮恢复'); + await page.clock.runFor(2_000); + + // Then the runner holds the next round without fabricating a new submission. + expect(await readSubmissions(page)).toHaveLength(3); + + // When the native host renders its button again. + await host.nativeButton.evaluate((button) => { button.style.display = ''; }); + // The native-button lookup caches visible results for 250 milliseconds. + await page.clock.runFor(300); + + // Then readiness starts a new full cooldown that the user can still stop. + await expect(host.status).toContainText('1s 后继续'); + await advanceFromStatus(page, '1s 后继续', 999); + expect(await readSubmissions(page)).toHaveLength(3); + await expect(host.panel.getByRole('button', { name: '停止平空', exact: true })).toHaveCount(1); + await stopContinuousRound(page, host); + expect(host.errors).toEqual([]); +}); + +test('user ends the session when the authoritative position has no current-symbol short quantity', async ({ page }) => { + // Given the native button is unavailable while stale DOM still shows a short position. + const host = await openPendingFirstRound(page); + const position = await gatePositionRecheck(page, { + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: [ + { symbol: CURRENT_SYMBOL, positionSide: 'LONG', positionAmount: '100' }, + { symbol: 'BTCUSDT', positionSide: 'SHORT', positionAmount: '-2' }, + ], + }), + }); + await host.nativeButton.evaluate((button) => { button.disabled = true; }); + await page.clock.runFor(1_000); + await host.releaseSubmitResponse(3); + await expect.poll(position.requestCount).toBe(1); + + // When the authoritative response contains only the opposite direction and another symbol. + await position.releaseFirst(); + + // Then the current short session ends with its confirmed total instead of waiting forever. + await expect(host.status).toHaveText('连续阶梯平空 · 已结束 · 当前方向已无持仓 · 1/1 轮 · 累计 3 笔'); + await page.clock.runFor(10_000); + expect(position.requestCount()).toBe(1); + expect(await readSubmissions(page)).toHaveLength(3); + expect((await readFixtureState(page)).events.filter(({ type }) => type.includes('cancel'))).toEqual([]); + expect(host.errors).toEqual([]); +}); + +for (const { description, response, recheckAfterMs } of [ + { + description: 'an explicit HTTP 429 retry interval', + response: { status: 429, headers: { 'retry-after': '2' } }, + recheckAfterMs: 2_000, + }, + { + description: 'an explicit HTTP 418 retry interval', + response: { status: 418, headers: { 'retry-after': '4' } }, + recheckAfterMs: 4_000, + }, + { + description: 'the temporary position-server recovery interval', + response: { status: 503 }, + recheckAfterMs: 3_000, + }, + { + description: 'the one-second position-check cadence when Retry-After is explicitly zero', + response: { status: 429, headers: { 'retry-after': '0' } }, + recheckAfterMs: 1_000, + }, +]) { + test(`user honors ${description} before rechecking a blocked close session`, async ({ page }) => { + // Given the completed round has a disabled button and the next position response will require recovery. + const host = await openPendingFirstRound(page); + const position = await gatePositionRecheck(page, { + ...response, + contentType: 'application/json', + body: JSON.stringify({ success: false }), + }); + await host.nativeButton.evaluate((button) => { button.disabled = true; }); + await page.clock.runFor(1_000); + await host.releaseSubmitResponse(3); + await expect.poll(position.requestCount).toBe(1); + + // When the failed response arrives and the recovery interval has not quite elapsed. + await position.releaseFirst(); + await expect(host.status).toContainText('等待按钮恢复'); + await advanceFromStatus(page, '等待按钮恢复', recheckAfterMs - 1); + + // Then the runner holds both the position recheck and all further orders. + expect(position.requestCount()).toBe(1); + expect(await readSubmissions(page)).toHaveLength(3); + + // When the interval expires and the next readiness tick observes it. + await page.clock.runFor(51); + + // Then the second authoritative response ends the session on flat with no extra order. + await expect.poll(position.requestCount).toBe(2); + await expect(host.status).toHaveText('连续阶梯平空 · 已结束 · 当前方向已无持仓 · 1/1 轮 · 累计 3 笔'); + expect(await readSubmissions(page)).toHaveLength(3); + expect(host.errors).toEqual([]); + }); +} + +for (const { description, response, message } of [ + { + description: 'an expired authentication response', + response: { status: 401, body: JSON.stringify({ success: false }) }, + message: '持仓接口异常:HTTP 401', + }, + { + description: 'a permanent position client error', + response: { status: 400, body: JSON.stringify({ success: false }) }, + message: '持仓接口异常:HTTP 400', + }, + { + description: 'a malformed position payload', + response: { status: 200, body: JSON.stringify({ success: true, data: {} }) }, + message: '持仓接口数据格式异常', + }, +]) { + test(`user gets a terminal failure for ${description} while the close button is blocked`, async ({ page }) => { + // Given a completed close round needs an authoritative recheck while its native button is disabled. + const host = await openPendingFirstRound(page); + const position = await gatePositionRecheck(page, { + ...response, contentType: 'application/json', + }); + await host.nativeButton.evaluate((button) => { button.disabled = true; }); + await page.clock.runFor(1_000); + await host.releaseSubmitResponse(3); + await expect.poll(position.requestCount).toBe(1); + + // When the authoritative endpoint returns the unrecoverable response. + await position.releaseFirst(); + + // Then the failure reason is visible and all later recovery windows remain inactive. + await expect(host.status).toHaveText(`连续阶梯平空 · 失败 · 1/1 轮 · 本轮 3/3 笔 · 累计 3 笔 · ${message}`); + await page.clock.runFor(15_000); + expect(position.requestCount()).toBe(1); + expect(await readSubmissions(page)).toHaveLength(3); + await expect(host.panel.locator('[data-ladder-stop]')).toHaveCount(0); + expect(host.errors).toEqual([]); + }); +} + +test('user stops while price precision is missing and its return cannot revive the session', async ({ page }) => { + // Given the current round is pending and subsequent position requests can be observed. + const host = await openPendingFirstRound(page); + const positionReads = observePositionRequests(page); + const precision = page.locator('#futuresOrderbook .tick-content'); + + // When the native precision text disappears as the final order is acknowledged. + await precision.evaluate((element) => { element.textContent = ''; }); + await host.releaseSubmitResponse(3); + await expect(host.status).toContainText('等待按钮恢复'); + await page.clock.runFor(2_000); + + // Then missing market controls hold the next round while the running session remains stoppable. + expect(positionReads()).toBe(0); + expect(await readSubmissions(page)).toHaveLength(3); + await expect(host.panel.getByRole('button', { name: '停止平空', exact: true })).toHaveCount(1); + await expect(host.panel.getByRole('button', { name: '停止平空', exact: true })).toBeEnabled(); + + // When the user stops while precision is still missing and the native value returns later. + await stopContinuousRound(page, host); + const terminalStatus = await host.status.textContent(); + await precision.evaluate((element) => { element.textContent = '0.1'; }); + await page.clock.runFor(3_000); + + // Then recovered precision cannot revive submissions or position polling after the stop. + await expect(host.status).toHaveText(terminalStatus); + expect(await readSubmissions(page)).toHaveLength(3); + expect(positionReads()).toBe(0); + await expect(host.panel.locator('[data-ladder-stop]')).toHaveCount(0); + expect(host.errors).toEqual([]); +}); + +test('user restores a new precision promptly and uses its profile after a complete cooldown', async ({ page }) => { + // Given a completed three-order round is waiting on missing native precision. + const host = await openPendingFirstRound(page); + const positionReads = observePositionRequests(page); + const precision = page.locator('#futuresOrderbook .tick-content'); + await precision.evaluate((element) => { element.textContent = ''; }); + await host.releaseSubmitResponse(3); + await expect(host.status).toContainText('等待按钮恢复'); + await page.clock.runFor(2_000); + expect(positionReads()).toBe(0); + + // When the same native tick-size root publishes a replacement Select at precision 0.01. + const restoredAt = await page.evaluate((symbol) => { + window.__BINANCE_FIXTURE__.replacePrecisionControl({ + scope: 'select', symbol, value: '0.01', options: ['0.001', '0.01', '0.1', '1'], + }); + return performance.now(); + }, CURRENT_SYMBOL); + await page.clock.runFor(100); + + // Then controls and the new profile recover within 100 milliseconds, without waiting for the five-second watchdog. + await expect(host.panel.locator('[data-ladder-group]')).toHaveCount(14); + await expect(host.panel.getByRole('button', { name: '停止平空', exact: true })).toBeEnabled(); + await expect(host.panel.locator('[data-orderbook-precision-value="0.01"]')).toHaveAttribute('aria-pressed', 'true'); + await expect(host.panel.getByText('等待价格精度', { exact: true })).toHaveCount(0); + expect(await page.evaluate(() => performance.now()) - restoredAt).toBe(100); + await expect(host.status).toContainText('1s 后继续'); + await advanceFromStatus(page, '1s 后继续', 999); + expect(await readSubmissions(page)).toHaveLength(3); + + // When the full cooldown expires and the next round reaches its first pending request. + await page.clock.resume(); + await expect.poll(host.pendingSubmitSequences).toEqual([4]); + await pauseScenarioClock(page); + + // Then the new precision uses its default five-order profile instead of the prior three-order allocation. + await expect(host.status).toContainText('本轮 0/5 笔'); + expect((await readFixtureState(page)).orderbookPrecision).toBe('0.01'); + expect((await readSubmissions(page)).map(({ quantity }) => quantity)).toEqual(['0.1', '0.1', '0.1', '0.06']); + expect(positionReads()).toBe(0); + await stopContinuousRound(page, host); + await host.releaseSubmitResponse(4); + await expect.poll(async () => (await readFixtureState(page)).events + .filter(({ type }) => type === 'order-submit-api-success').length).toBe(4); + await page.clock.runFor(3_000); + expect(await readSubmissions(page)).toHaveLength(4); + expect(positionReads()).toBe(0); + expect(host.errors).toEqual([]); +}); + +test('user advances an unconfirmed continuous order to a new round without counting a late success', async ({ page }) => { + // Given two orders are confirmed and the third has no response before its twelve-second deadline. + const host = await openPendingFirstRound(page); + await advanceFromStatus(page, '第 3 笔确认中', 11_999); + await expect(host.status).not.toContainText('未确认'); + expect(await readSubmissions(page)).toHaveLength(3); + + // When the pending request crosses its response deadline. + await page.clock.runFor(51); + + // Then the continuous policy keeps the two confirmed orders and starts a three-second recovery cooldown. + await expect(host.status).toContainText('3s 后继续'); + await expect(host.status).toContainText('下单请求仍未返回'); + await expect(host.status).toContainText('累计 2 笔'); + await advanceFromStatus(page, '3s 后继续', 2_999); + expect(await readSubmissions(page)).toHaveLength(3); + + // When the recovery deadline passes and the old response arrives while the next round is pending. + await page.clock.resume(); + await expect.poll(host.pendingSubmitSequences).toEqual([3, 4]); + await pauseScenarioClock(page); + await host.releaseSubmitResponse(3); + await expect.poll(async () => (await readFixtureState(page)).events + .filter(({ type }) => type === 'order-submit-api-success').length).toBe(3); + + // Then the next round starts at its first price and the old acknowledgement cannot inflate its total. + await expect(host.status).toContainText('本轮 0/3 笔'); + await expect(host.status).toContainText('累计 2 笔'); + const submissions = await readSubmissions(page); + expect(submissions.map(({ action, price }) => ({ action, price }))).toEqual([ + { action: '平空', price: '80.9' }, + { action: '平空', price: '80.4' }, + { action: '平空', price: '79.9' }, + { action: '平空', price: '80.9' }, + ]); + await stopContinuousRound(page, host); + await host.releaseSubmitResponse(4); + await expect.poll(async () => (await readFixtureState(page)).events + .filter(({ type }) => type === 'order-submit-api-success').length).toBe(4); + await page.clock.runFor(15_000); + expect(await readSubmissions(page)).toHaveLength(4); + await expect(host.status).toContainText('累计 2 笔'); + expect(host.errors).toEqual([]); +}); + +for (const [description, headers] of [ + ['missing', {}], + ['invalid', { 'retry-after': 'not-a-delay' }], +]) { + test(`user waits the default ten seconds when position Retry-After is ${description}`, async ({ page }) => { + // Given a completed round has a disabled button and the first position response is rate limited. + const host = await openPendingFirstRound(page); + const position = await gatePositionRecheck(page, { + status: 429, + headers, + contentType: 'application/json', + body: JSON.stringify({ success: false }), + }); + await host.nativeButton.evaluate((button) => { button.disabled = true; }); + await page.clock.runFor(1_000); + await host.releaseSubmitResponse(3); + await expect.poll(position.requestCount).toBe(1); + + // When that response arrives without usable retry timing and 9,999 milliseconds pass. + await position.releaseFirst(); + await expect(host.status).toContainText('等待按钮恢复'); + await advanceFromStatus(page, '等待按钮恢复', 9_999); + + // Then no second position request or additional order is allowed before the default deadline. + expect(position.requestCount()).toBe(1); + expect(await readSubmissions(page)).toHaveLength(3); + await expect(host.status).toContainText('等待按钮恢复'); + + // When the ten-second deadline passes and the next readiness check runs. + await page.clock.runFor(51); + + // Then exactly one new recheck confirms flat and the continuous session ends. + await expect.poll(position.requestCount).toBe(2); + await expect(host.status).toHaveText('连续阶梯平空 · 已结束 · 当前方向已无持仓 · 1/1 轮 · 累计 3 笔'); + expect(await readSubmissions(page)).toHaveLength(3); + expect(host.errors).toEqual([]); + }); +} diff --git a/e2e/binance-orderbook/specs/coverage-merge.pw.js b/e2e/binance-orderbook/specs/coverage-merge.pw.js index 8d3c8ab..cca3b55 100644 --- a/e2e/binance-orderbook/specs/coverage-merge.pw.js +++ b/e2e/binance-orderbook/specs/coverage-merge.pw.js @@ -10,6 +10,12 @@ import { reportProofEntries, } from '../../../scripts/test-coverage/merge-proof.mjs'; import { splitCoverageEntry } from '../../../scripts/test-coverage/split-entries.mjs'; +import { + startBrowserCoverage, + checkpointBrowserCoverage, + stopBrowserCoverage, +} from '../../../scripts/test-coverage/collect-browser.mjs'; +import { mergeBrowserSnapshots } from '../../../scripts/test-coverage/browser-snapshots.mjs'; const cases = [ { @@ -94,3 +100,72 @@ for (const scenario of cases) { await testInfo.attach('coverage-merge-evidence', { path: evidencePath, contentType: 'application/json' }); }); } + +test('user retains a conservative branch report through a real reload without inventing teardown blocks', async ({ page }, testInfo) => { + // Given the real collector sees two left-branch calls in each document and three right-branch calls only during teardown. + const proof = await createMergeProof(testInfo.outputPath('reload-proof')); + const source = proof.registry.artifacts[0].code + + '\nCoverageProofMain(true); CoverageProofMain(true);\n' + + "addEventListener('pagehide', () => { CoverageProofMain(false); CoverageProofMain(false); CoverageProofMain(false); });\n"; + const url = 'https://coverage-proof.test/probe.js'; + await page.route('https://coverage-proof.test/**', route => route.fulfill({ + contentType: route.request().url() === url ? 'application/javascript' : 'text/html', + body: route.request().url() === url ? source : '', + })); + await startBrowserCoverage(page); + await page.goto('https://coverage-proof.test/'); + await checkpointBrowserCoverage(page, 'before-reload'); + + // When Chromium actually reloads and the collector captures the outgoing and incoming documents. + await page.reload(); + const raw = await stopBrowserCoverage(page); + raw.snapshots = raw.snapshots.map(snapshot => ({ ...snapshot, + entries: snapshot.entries.filter(entry => entry.url === url), + })); + await writeFile(resolve(proof.outputDirectory, 'raw-snapshots.json'), JSON.stringify(raw, null, 2)); + const original = structuredClone(raw); + const merged = mergeBrowserSnapshots(raw); + const mapped = mapProofEntries(merged.entries, proof, { split: true }); + const report = await reportProofEntries(proof, 'conservative-reload', mapped); + + // Then each real script root is one, detailed left calls total four, and the three coarse right calls receive no branch credit. + expect(raw).toEqual(original); + expect(new Set(merged.entries.map(entry => entry.scriptId)).size).toBe(2); + expect(merged.entries.map(entry => splitCoverageEntry(entry, proof.registry)[0].functions[0].ranges[0].count)).toEqual([1, 1]); + expect(report).toEqual({ sourcePath: proof.sourcePath, + branches: { covered: 1, total: 2, counts: [4, 0] }, functions: [{ name: 'chooseBranch', count: 4 }] }); + expect(merged.blockEvidenceUnavailable.filter(item => item.functionName === 'chooseBranch') + .map(item => ({ count: item.range.count, phase: item.phase, snapshotIndex: item.snapshotIndex }))) + .toEqual([{ count: 3, phase: 'finish', snapshotIndex: 1 }]); + await writeFile(resolve(proof.outputDirectory, 'reload-evidence.json'), JSON.stringify({ report, + metricInterpretation: 'retained-evidence-lower-bound', blockEvidenceUnavailable: merged.blockEvidenceUnavailable, + }, null, 2)); +}); + +test('user gets exact opposite-branch counts across ordinary browser checkpoints', async ({ page }, testInfo) => { + // Given one complete compiled source remains in the same document throughout both intervals. + const proof = await createMergeProof(testInfo.outputPath('checkpoint-proof')); + const source = proof.registry.artifacts[0].code; + await page.setContent('Incremental coverage'); + await startBrowserCoverage(page); + await page.addScriptTag({ content: source }); + await page.evaluate(() => { window.CoverageProofMain(true); window.CoverageProofMain(true); }); + await checkpointBrowserCoverage(page, 'first-calls'); + + // When the opposite branch runs three times after counters were reset by a checkpoint. + await page.evaluate(() => { + window.CoverageProofMain(false); window.CoverageProofMain(false); window.CoverageProofMain(false); + }); + const raw = await stopBrowserCoverage(page); + raw.snapshots = raw.snapshots.map(snapshot => ({ ...snapshot, + entries: snapshot.entries.filter(entry => entry.source === source), + })); + const merged = mergeBrowserSnapshots(raw); + const report = await reportProofEntries(proof, 'merged-checkpoints', mapProofEntries(merged.entries, proof, { split: true })); + + // Then counters sum to five and both branches are proven without any coarse or duplicate execution credit. + expect(merged.entries).toHaveLength(1); + expect(merged.blockEvidenceUnavailable).toEqual([]); + expect(report).toEqual({ sourcePath: proof.sourcePath, + branches: { covered: 2, total: 2, counts: [2, 3] }, functions: [{ name: 'chooseBranch', count: 5 }] }); +}); diff --git a/e2e/binance-orderbook/specs/draft-preparation-boundaries.pw.js b/e2e/binance-orderbook/specs/draft-preparation-boundaries.pw.js new file mode 100644 index 0000000..d468ca0 --- /dev/null +++ b/e2e/binance-orderbook/specs/draft-preparation-boundaries.pw.js @@ -0,0 +1,151 @@ +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'; + +async function beginPlan(page, action) { + await page.evaluate(action => { + window.__PLANNER_TEST_RESULT__ = null; + window.__TM_CLOSE_LONG_DEBUG__.buildLadderPlan(action).then( + plan => { window.__PLANNER_TEST_RESULT__ = { status: 'planned', orders: plan.orders }; }, + error => { window.__PLANNER_TEST_RESULT__ = { + status: 'refused', message: error.message, recoveryKind: error.continuousRecoveryKind, + }; }, + ); + }, action); +} + +async function planResult(page) { + return page.evaluate(() => window.__PLANNER_TEST_RESULT__); +} + +async function noOrderActions(page) { + expect((await readFixtureState(page)).events.filter(({ type }) => /order-submitted|cancel-requested/.test(type))).toEqual([]); +} + +for (const mode of ['OPEN', 'CLOSE']) { + for (const availability of ['missing', 'uncommitted']) { + test(`user cannot prepare ${mode} orders while its native mode tab is ${availability}`, async ({ page }) => { + // Given the opposite native mode is active and the requested mode cannot commit. + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: 'LONG', quantity: '100' }], + ui: { tradeMode: mode === 'OPEN' ? 'CLOSE' : 'OPEN' }, + })); + await pauseScenarioClock(page); + await page.locator(`[data-trade-mode="${mode}"]`).evaluate((tab, availability) => { + if (availability === 'missing') tab.remove(); + else tab.addEventListener('click', event => event.stopImmediatePropagation(), true); + }, availability); + + // When the production planner requests that mode and its observed-state deadline elapses. + await beginPlan(page, `${mode}_LONG`); + await page.clock.runFor(1100); + + // Then the precise missing mode is reported as a controls readiness failure without a proposed order. + await expect.poll(() => planResult(page)).toEqual({ + status: 'refused', message: `未能切换至${mode === 'OPEN' ? '开仓' : '平仓'}`, recoveryKind: 'controls_not_ready', + }); + expect((await readFixtureState(page)).tradeMode).toBe(mode === 'OPEN' ? 'CLOSE' : 'OPEN'); + await noOrderActions(page); + expect(host.errors).toEqual([]); + }); + } +} + +for (const unavailable of ['missing precision', 'missing Post Only tab', 'uncommitted Post Only tab']) { + test(`user cannot prepare a ladder with ${unavailable}`, async ({ page }) => { + // Given native form readiness loses one explicitly required prerequisite. + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario()); + await pauseScenarioClock(page); + await page.evaluate(unavailable => { + if (unavailable === 'missing precision') document.querySelector('#futuresOrderbook .tick-content').remove(); + else if (unavailable === 'missing Post Only tab') document.querySelector('.order-type-tabs').replaceChildren(); + else document.querySelector('[data-tab-key="POST_ONLY"]').setAttribute('aria-selected', 'false'); + }, unavailable); + + // When the real planner attempts to establish a maker-only order draft. + await beginPlan(page, 'OPEN_LONG'); + await page.clock.runFor(1100); + + // Then the missing prerequisite prevents any plan or order from being accepted. + const precision = unavailable === 'missing precision'; + await expect.poll(() => planResult(page)).toEqual({ + status: 'refused', + message: precision ? '未识别价格精度' : '只做 Maker 未生效,请刷新页面后重试', + recoveryKind: precision ? 'market_data_not_ready' : 'controls_not_ready', + }); + await noOrderActions(page); + expect(host.errors).toEqual([]); + }); +} + +for (const changed of [ + { name: 'symbol', message: '读取可开数量时交易对已变化,已停止', recoveryKind: undefined }, + { name: 'mode', message: '读取可开数量时下单模式已变化,已停止', recoveryKind: undefined }, + { name: 'precision', message: '读取可开数量时价格精度已变化,已停止', recoveryKind: 'precision_changed' }, + { name: 'order type', message: '读取可开数量时只做 Maker 已失效,请刷新页面后重试', recoveryKind: undefined }, + { name: 'allocation', message: '读取下单数量时比例、笔数或间距已变化', recoveryKind: 'options_changed' }, +]) { + test(`user refuses a ladder draft if its ${changed.name} changes during the native quantity calculation`, async ({ page }) => { + // Given a native price write triggers a separate, observable form-context update. + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario()); + await pauseScenarioClock(page); + await page.locator('#limitPrice-open').evaluate((input, change) => { + input.addEventListener('input', () => { + if (change === 'symbol') window.__BINANCE_FIXTURE__.switchSymbol('BTCUSDT'); + if (change === 'mode') { + document.querySelector('[data-trade-mode="OPEN"]').setAttribute('aria-selected', 'false'); + document.querySelector('[data-trade-mode="CLOSE"]').setAttribute('aria-selected', 'true'); + } + if (change === 'precision') window.__BINANCE_FIXTURE__.replacePrecisionControl({ + scope: 'root', value: '0.01', symbol: 'HYPEUSDT', options: ['0.001', '0.01', '0.1', '1'], + }); + if (change === 'order type') document.querySelector('[data-tab-key="POST_ONLY"]').setAttribute('aria-selected', 'false'); + if (change === 'allocation') document.querySelector('[data-ladder-group="percent"][data-ladder-value="10"]').click(); + }, { once: true }); + }, changed.name); + + // When the planner writes its reference price before reading the available quantity. + await beginPlan(page, 'OPEN_LONG'); + await page.clock.runFor(100); + + // Then the captured context is checked again and its exact mismatch prevents an executable order list. + await expect.poll(() => planResult(page)).toEqual({ + status: 'refused', message: changed.message, recoveryKind: changed.recoveryKind, + }); + await noOrderActions(page); + expect(host.errors).toEqual([]); + }); +} + +test('user ignores programmatic and unreadable price clicks before accepting one real valid price', async ({ page }) => { + // Given the complete entrypoint is ready beside a native price whose original text is known. + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario()); + const price = page.locator('#futuresOrderbook .bid-light').first(); + await pauseScenarioClock(page); + + // When page code clicks a price and a subsequent trusted click sees a temporary placeholder. + await price.evaluate(node => node.click()); + await price.evaluate(node => { node.textContent = '--'; }); + await price.click(); + await page.clock.runFor(100); + + // Then neither event can produce a native order. + await noOrderActions(page); + + // When the native price becomes readable and the user clicks it again. + await price.evaluate(node => { node.textContent = '81.0'; }); + await price.click(); + await page.clock.runFor(100); + + // Then exactly the real valid click is acknowledged with its concrete fields. + await expect(page.locator('#jh-binance-ladder-status')).toHaveText('单击开多已提交 · 81.0 × 0.07'); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted') + .map(({ action, price, quantity }) => ({ action, price, quantity }))) + .toEqual([{ action: '开多', price: '81.0', quantity: '0.07' }]); + expect(host.errors).toEqual([]); +}); diff --git a/e2e/binance-orderbook/specs/ladder-plan-behavior.pw.js b/e2e/binance-orderbook/specs/ladder-plan-behavior.pw.js new file mode 100644 index 0000000..1d86aab --- /dev/null +++ b/e2e/binance-orderbook/specs/ladder-plan-behavior.pw.js @@ -0,0 +1,194 @@ +import { test, expect } from '../test.js'; +import { CURRENT_SYMBOL, createCancelScenario } from '../scenarios/cancel-current-symbol.js'; +import { openUserscriptScenario, readFixtureState } from '../helpers/userscript-page.js'; + +const PLAN_DIRECTIONS = [ + { action: 'OPEN_LONG', mode: 'OPEN', side: 'LONG', orderSide: 'BUY', prices: ['80.9', '80.4', '79.9', '79.4', '78.9'] }, + { action: 'OPEN_SHORT', mode: 'OPEN', side: 'SHORT', orderSide: 'SELL', prices: ['81.03', '81.08', '81.13', '81.18', '81.23'] }, + { action: 'CLOSE_LONG', mode: 'CLOSE', side: 'LONG', orderSide: 'SELL', prices: ['81.03', '81.08', '81.13', '81.18', '81.23'] }, + { action: 'CLOSE_SHORT', mode: 'CLOSE', side: 'SHORT', orderSide: 'BUY', prices: ['80.9', '80.4', '79.9', '79.4', '78.9'] }, +]; + +/** Exercise the production planner without sending its proposed orders. */ +async function buildPlan(page, action, context = null) { + return page.evaluate(async ({ action, context }) => { + try { + const plan = await window.__TM_CLOSE_LONG_DEBUG__.buildLadderPlan(action, context); + return { + status: 'planned', symbol: plan.symbol, precision: plan.precision, + mode: plan.spec.mode, side: plan.spec.side, orderSide: plan.spec.orderSide, + baseQty: plan.baseQty, totalQty: plan.totalQty, minRequiredQty: plan.minRequiredQty, + percent: plan.percent, autoFitPercent: plan.autoFitPercent, autoFitLevels: plan.autoFitLevels, + levels: plan.levels, optionContext: plan.optionContext, orders: plan.orders, + }; + } catch (error) { + return { + status: 'refused', message: error.message, title: error.statusTitle, + safeNoSubmit: error.safeNoSubmit, recoveryKind: error.continuousRecoveryKind, + replacement: error.openOrdersReplacementPlan && { + symbol: error.openOrdersReplacementPlan.symbol, + precision: error.openOrdersReplacementPlan.precision, + mode: error.openOrdersReplacementPlan.spec.mode, + side: error.openOrdersReplacementPlan.spec.side, + totalQty: error.openOrdersReplacementPlan.totalQty, + optionContext: error.openOrdersReplacementPlan.optionContext, + }, + }; + } + }, { action, context }); +} + +for (const direction of PLAN_DIRECTIONS) { + test(`user previews ${direction.action} with exact prices and quantities from the live form`, async ({ page }) => { + // Given the current form has sufficient directional quantity and the native book has six prices per side. + const scenario = createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: direction.side, quantity: '100' }], + ui: { tradeMode: direction.mode }, + }); + const { errors } = await openUserscriptScenario(page, scenario); + const optionButtons = page.locator('[data-ladder-group]'); + await expect(optionButtons).toHaveCount(14); + const originalOptions = await optionButtons.evaluateAll(elements => elements.map(element => element.outerHTML)); + + // When the actual entrypoint builds the selected directional plan. + const plan = await buildPlan(page, direction.action); + + // Then the plan preserves context and obeys the per-mode minimum without submitting or changing the saved controls. + const opening = direction.mode === 'OPEN'; + expect(plan).toEqual({ + status: 'planned', symbol: CURRENT_SYMBOL, precision: '0.1', + mode: direction.mode, side: direction.side, orderSide: direction.orderSide, + baseQty: opening ? '10' : '100', totalQty: opening ? '0.35' : '0.3', + minRequiredQty: opening ? '0.07' : '0.01', percent: opening ? '3.5' : 0.3, + autoFitPercent: opening ? '3.5' : null, autoFitLevels: opening ? 5 : null, + levels: 5, + optionContext: { percent: opening ? 2 : 0.3, levels: 5, ladderStep: 5 }, + orders: direction.prices.map(price => ({ price, qty: opening ? '0.07' : '0.06' })), + }); + expect(await optionButtons.evaluateAll(elements => elements.map(element => element.outerHTML))) + .toEqual(originalOptions); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted')).toEqual([]); + expect(errors).toEqual([]); + }); +} + +for (const [label, action, context, message] of [ + ['unknown action', 'UNKNOWN', null, '未知阶梯动作'], + ['captured symbol', 'OPEN_LONG', { symbol: 'BTCUSDT' }, '重挂前交易对已变化,已停止'], + ['captured mode', 'OPEN_LONG', { mode: 'CLOSE' }, '重挂前开仓/平仓模式已变化,已停止'], + ['captured precision', 'OPEN_LONG', { precision: '1' }, '重挂前价格精度已变化,已停止'], + ['captured options', 'OPEN_LONG', { optionContext: { percent: 10, levels: 5, ladderStep: 5 } }, 'Ladder settings changed during reduce-only recovery'], +]) { + test(`user gets a precise refusal for a changed ${label} before a replacement plan can submit`, async ({ page }) => { + // Given the production planner is attached to the current HYPE open form. + const { errors } = await openUserscriptScenario(page, createCancelScenario()); + + // When the caller presents the stale captured context or an unsupported action. + const result = await buildPlan(page, action, context); + + // Then the matching contract fails and no order or cancellation is issued. + expect(result.status).toBe('refused'); + expect(result.message).toBe(message); + expect((await readFixtureState(page)).events.filter(({ type }) => /order-submitted|cancel-requested/.test(type))) + .toEqual([]); + expect(errors).toEqual([]); + }); +} + +for (const direction of PLAN_DIRECTIONS) { + test(`user receives safe minimum-quantity guidance when ${direction.action} cannot fit even one order`, async ({ page }) => { + // Given the actual quantity is below the exchange minimum even at a full allocation. + const opening = direction.mode === 'OPEN'; + const { errors } = await openUserscriptScenario(page, createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: direction.side, quantity: '0.001' }], + ui: { tradeMode: direction.mode }, + })); + if (opening) await page.locator('[data-testid^="max-"]').evaluateAll(elements => { + elements.forEach(element => { element.textContent = '可开 0.04 HYPE'; }); + }); + + // When the real planner tries its documented ratio and order-count adjustment. + const result = await buildPlan(page, direction.action); + + // Then refusal explains the minimum and manual choices without changing orders. + expect(result.status).toBe('refused'); + expect(result.message).toContain(opening ? '数量低于最小下单量 0.07' : '数量低于最小下单量 0.01'); + expect(result.title).toContain('自动上限 100%'); + expect(result.title).toContain('已尝试自动提高比例和自动降档'); + expect(result.title).toContain(opening ? '同向开仓基础单,不会自动全撤' : '脚本不会自动撤单'); + if (opening) { + expect(result.replacement).toEqual({ + symbol: CURRENT_SYMBOL, precision: '0.1', mode: 'OPEN', side: direction.side, + totalQty: '0.35', optionContext: { percent: 2, levels: 5, ladderStep: 5 }, + }); + } else { + expect(result.safeNoSubmit).toBe(true); + expect(result.recoveryKind).toBe('position_quantity_not_ready'); + expect(result.replacement).toBeUndefined(); + } + expect((await readFixtureState(page)).events.filter(({ type }) => /order-submitted|cancel-requested/.test(type))) + .toEqual([]); + expect(errors).toEqual([]); + }); +} + +test('user caps a recovered close plan at the freshly confirmed position and retains the original form quantity', async ({ page }) => { + // Given the page still displays 100 long units while a fresh position response confirmed only 2. + const { errors } = await openUserscriptScenario(page, createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: 'LONG', quantity: '100' }], + ui: { tradeMode: 'CLOSE' }, + })); + + // When the real close planner receives that confirmed recovery cap. + const plan = await buildPlan(page, 'CLOSE_LONG', { + symbol: CURRENT_SYMBOL, mode: 'CLOSE', precision: '0.1', closePositionQty: '2', + optionContext: { percent: 0.3, levels: 5, ladderStep: 5 }, + }); + + // Then five minimum-sized orders use the smaller position and the displayed source remains unchanged. + expect(plan.status).toBe('planned'); + expect(plan.baseQty).toBe('2'); + expect(plan.totalQty).toBe('0.05'); + expect(plan.orders.map(order => order.qty)).toEqual(['0.01', '0.01', '0.01', '0.01', '0.01']); + await expect(page.locator('[data-testid="max-sell-amount"]')).toHaveText('可平 100 HYPE'); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted')).toEqual([]); + expect(errors).toEqual([]); +}); + +for (const [side, selector, message] of [ + ['LONG', '.bid-light', '订单簿买盘不足 5 档,档幅 5'], + ['SHORT', '.ask-light', '订单簿卖盘不足 5 档,档幅 5'], +]) { + test(`user cannot plan OPEN_${side} while the corresponding native book is empty`, async ({ page }) => { + // Given the form is ready but the relevant price rows have disappeared during a native refresh. + const { errors } = await openUserscriptScenario(page, createCancelScenario()); + await page.locator('#futuresOrderbook ' + selector).evaluateAll(elements => elements.forEach(element => element.remove())); + + // When the production planner reads the updated native book. + const result = await buildPlan(page, 'OPEN_' + side); + + // Then it reports missing market data and never invents a price or submits an order. + expect(result.status).toBe('refused'); + expect(result.message).toBe(message); + expect(result.recoveryKind).toBe('market_data_not_ready'); + expect(result.safeNoSubmit).toBe(true); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted')).toEqual([]); + expect(errors).toEqual([]); + }); +} + +test('user cannot plan after leaving the futures route', async ({ page }) => { + // Given a working futures panel before the SPA navigates away. + const { errors } = await openUserscriptScenario(page, createCancelScenario()); + + // When navigation changes the route before a new plan is requested. + await page.evaluate(() => history.pushState({}, '', '/zh-CN/markets')); + const result = await buildPlan(page, 'OPEN_LONG'); + + // Then no symbol is inferred from the old page and the panel is removed. + expect(result.status).toBe('refused'); + expect(result.message).toBe('未识别当前交易对'); + await expect(page.locator('#jh-binance-close-qty-multiplier-panel')).toHaveCount(0); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted')).toEqual([]); + expect(errors).toEqual([]); +}); diff --git a/e2e/binance-orderbook/specs/ladder-replacement-behavior.pw.js b/e2e/binance-orderbook/specs/ladder-replacement-behavior.pw.js new file mode 100644 index 0000000..3174439 --- /dev/null +++ b/e2e/binance-orderbook/specs/ladder-replacement-behavior.pw.js @@ -0,0 +1,158 @@ +import { test, expect } from '../test.js'; +import { CURRENT_SYMBOL, OTHER_SYMBOL, createCancelScenario } from '../scenarios/cancel-current-symbol.js'; +import { openUserscriptScenario, readFixtureState } from '../helpers/userscript-page.js'; +import { installScenarioClock, pauseScenarioClock } from '../helpers/scenario-clock.js'; + +const ACTIONS = [ + { action: 'OPEN_LONG', label: '开多', opposite: '开空', mode: 'OPEN', side: 'LONG' }, + { action: 'OPEN_SHORT', label: '开空', opposite: '开多', mode: 'OPEN', side: 'SHORT' }, + { action: 'CLOSE_LONG', label: '平多', opposite: '平空', mode: 'CLOSE', side: 'LONG' }, + { action: 'CLOSE_SHORT', label: '平空', opposite: '平多', mode: 'CLOSE', side: 'SHORT' }, +]; + +function replacementOrders(direction, quantity = '0.2') { + const order = (id, side, extra = {}) => ({ + id, symbol: CURRENT_SYMBOL, kind: 'basic', side, price: '82', quantity, ...extra, + }); + return [ + order('target-1', direction.label, { price: '83' }), + order('target-2', direction.label, { price: '84' }), + order('same-direction-extra', direction.label, { price: '85' }), + order('opposite-direction', direction.opposite), + order('other-symbol', direction.label, { symbol: OTHER_SYMBOL }), + order('conditional-current', direction.label, { kind: 'conditional' }), + ]; +} + +function scenarioFor(direction, overrides = {}) { + return createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: direction.side, quantity: '100' }], + orders: replacementOrders(direction), + ...overrides, + ui: { + tradeMode: direction.mode, accountTab: 'history', openOrdersSubTab: 'conditional', + hideOtherSymbols: false, openableQuantity: direction.mode === 'OPEN' ? '0.04' : '10', + ...overrides.ui, + }, + host: { + openableQuantityAfterRowCancel: '10', + submitApiResponses: [ + ...(direction.mode === 'CLOSE' ? [{ + outcome: 'rejected', delivery: 'immediate', code: '90802022', message: 'Reduce only order rejected', + }] : []), + ...Array.from({ length: 5 }, () => ({ outcome: 'success', delivery: 'immediate' })), + ], + ...overrides.host, + }, + }); +} + +for (const direction of ACTIONS) { + test(`user replaces only the required current-symbol basic ${direction.label} rows before completing the ladder`, async ({ page }) => { + // Given matching, opposite, unrelated, and conditional rows coexist behind a different account tab. + const scenario = scenarioFor(direction); + const { errors } = await openUserscriptScenario(page, scenario); + const status = page.locator('#jh-binance-ladder-status'); + + // When a minimum-quantity shortage or explicit reduce-only rejection triggers the real replacement workflow. + await page.locator('[data-ladder-action="' + direction.action + '"]').click(); + + // Then only enough matching SVG rows are cancelled, the complete ladder succeeds, and the original view returns. + await expect(status).toContainText('已完成', { timeout: 12000 }); + await expect(status).toContainText('已挂 5/5'); + await expect(status).toContainText('已撤 2'); + const state = await readFixtureState(page); + expect(state.events.filter(({ type }) => type === 'row-cancel-cleared').map(({ orderId }) => orderId)) + .toEqual(['target-1', 'target-2']); + expect(state.orders).toEqual(scenario.orders.slice(2)); + expect(state.accountTab).toBe('history'); + expect(state.openOrdersSubTab).toBe('conditional'); + expect(state.hideOtherSymbols).toBe(false); + expect(state.showOrders).toBe(true); + expect(state.events.filter(({ type }) => type === 'cancel-requested' || type === 'dialog-opened')).toEqual([]); + expect(state.events.filter(({ type }) => type === 'order-submit-api-success')).toHaveLength(5); + expect(state.events.filter(({ type }) => type === 'order-submitted').map(({ action }) => action)) + .toEqual(Array(direction.mode === 'OPEN' ? 5 : 6).fill(direction.label)); + expect(errors).toEqual([]); + }); +} + +for (const [name, orders, reason] of [ + ['insufficient matching quantity', replacementOrders(ACTIONS[0], '0.01'), '同向可撤挂单总量不足本轮目标'], + ['opposite-direction orders only', replacementOrders(ACTIONS[0]).filter(order => order.id === 'opposite-direction'), '未找到开多方向的可撤基础单'], + ['no basic orders', replacementOrders(ACTIONS[0]).filter(order => order.kind === 'conditional'), '未找到开多方向的可撤基础单'], +]) { + test(`user keeps existing orders when replacement finds ${name}`, async ({ page }) => { + // Given the visible native order scope cannot cover the captured replacement quantity. + const scenario = scenarioFor(ACTIONS[0], { orders }); + const { errors } = await openUserscriptScenario(page, scenario); + + // When the actual open-long ladder reaches its minimum-quantity replacement preflight. + await page.locator('[data-ladder-action="OPEN_LONG"]').click(); + + // Then the specific refusal preserves every order and restores the original account scope. + await expect(page.locator('#jh-binance-ladder-status')).toContainText(reason); + const state = await readFixtureState(page); + expect(state.orders).toEqual(scenario.orders); + expect(state.accountTab).toBe('history'); + expect(state.openOrdersSubTab).toBe('conditional'); + expect(state.hideOtherSymbols).toBe(false); + expect(state.events.filter(({ type }) => type === 'row-cancel-requested' || type === 'order-submitted')).toEqual([]); + expect(errors).toEqual([]); + }); +} + +test('user can stop replacement while a native row decision is pending without another cancellation or submit', async ({ page }) => { + // Given native row cancellation needs a user decision and cannot complete automatically. + await installScenarioClock(page); + const scenario = scenarioFor(ACTIONS[0], { host: { rowCancelMode: 'dialog' } }); + const { errors } = await openUserscriptScenario(page, scenario); + await page.locator('[data-ladder-action="OPEN_LONG"]').click(); + await expect(page.locator('[data-row-dialog-action="confirm"]')).toBeVisible(); + const before = await readFixtureState(page); + expect(before.orders).toEqual(scenario.orders); + expect(before.events.filter(({ type }) => type === 'row-cancel-cleared')).toEqual([]); + + // When the user stops the ladder and declines the outstanding native cancellation. + await page.locator('[data-ladder-stop]').evaluate(button => button.click()); + await page.locator('[data-row-dialog-action="cancel"]').click(); + await pauseScenarioClock(page); + await page.clock.runFor(10000); + + // Then the stopped workflow has one unconfirmed request, no cancellations, and no submitted order. + await expect(page.locator('#jh-binance-ladder-status')).toContainText('阶梯开多已停止'); + const state = await readFixtureState(page); + expect(state.orders).toEqual(scenario.orders); + expect(state.events.filter(({ type }) => type === 'row-cancel-requested')).toHaveLength(1); + expect(state.events.filter(({ type }) => type === 'row-cancel-cleared' || type === 'order-submitted')).toEqual([]); + expect(errors).toEqual([]); +}); + +test('user must confirm each native row dialog before replacement can continue', async ({ page }) => { + // Given enough matching rows exist but each native row action requires explicit confirmation. + const scenario = scenarioFor(ACTIONS[0], { host: { rowCancelMode: 'dialog' } }); + const { errors } = await openUserscriptScenario(page, scenario); + + // When the ladder opens the first native row dialog. + await page.locator('[data-ladder-action="OPEN_LONG"]').click(); + const confirm = page.locator('[data-row-dialog-action="confirm"]'); + await expect(confirm).toBeVisible(); + + // Then the script leaves the decision to the user with all orders still present. + expect((await readFixtureState(page)).orders).toEqual(scenario.orders); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted')).toEqual([]); + + // When the user confirms the two required native cancellations in sequence. + await confirm.click(); + await expect.poll(async () => (await readFixtureState(page)).events + .filter(({ type }) => type === 'row-dialog-opened').length).toBe(2); + await confirm.click(); + + // Then only those two rows are removed and the real planner submits five acknowledged replacements. + await expect(page.locator('#jh-binance-ladder-status')).toContainText('已挂 5/5', { timeout: 12000 }); + const state = await readFixtureState(page); + expect(state.orders).toEqual(scenario.orders.slice(2)); + expect(state.events.filter(({ type }) => type === 'order-submit-api-success')).toHaveLength(5); + expect(state.events.filter(({ type }) => type === 'cancel-requested')).toEqual([]); + expect(errors).toEqual([]); +}); diff --git a/e2e/binance-orderbook/specs/native-submit-boundaries.pw.js b/e2e/binance-orderbook/specs/native-submit-boundaries.pw.js new file mode 100644 index 0000000..3c6ba86 --- /dev/null +++ b/e2e/binance-orderbook/specs/native-submit-boundaries.pw.js @@ -0,0 +1,173 @@ +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 { installNativeInputRollbackHost, installNativeSubmitFeedbackHost } from '../../../test/helpers/native-submit-feedback-host.js'; + +const STATUS = '#jh-binance-ladder-status'; + +async function submissions(page) { + return (await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted'); +} + +for (const feedback of [ + { label: 'a new client rejection', initial: '', text: '订单提交失败', markup: false }, + { label: 'a reused toast with updated text', initial: '订单已提交成功', text: '订单提交失败', markup: false }, + { label: 'a reused toast with fresh markup', initial: '订单提交失败', text: '订单提交失败', markup: true }, +]) { + test(`user stops immediately when native validation emits ${feedback.label}`, async ({ page }) => { + // Given native validation owns submission and its feedback surface may contain an older message. + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario()); + const native = await page.evaluateHandle(installNativeSubmitFeedbackHost, { initialText: feedback.initial }); + await pauseScenarioClock(page); + + // When a trusted price click reaches native validation and a new rejection is published. + await page.locator('#futuresOrderbook .bid-light').first().click(); + await page.clock.runFor(100); + await expect.poll(() => native.evaluate(boundary => boundary.snapshot().attempts.length)).toBe(1); + await native.evaluate((boundary, feedback) => boundary.publish(feedback.text, { replaceMarkup: feedback.markup }), feedback); + + // Then the rejection is associated with this single attempt without inventing an API error or sending a request. + await expect(page.locator(STATUS)).toHaveText('单击开多失败:订单提交失败(未捕获错误码)'); + expect(await native.evaluate(boundary => boundary.snapshot().attempts)).toEqual([ + { action: '开多', price: '81.0', quantity: '0.07' }, + ]); + expect(await submissions(page)).toEqual([]); + expect(host.errors).toEqual([]); + await native.evaluate(boundary => boundary.dispose()); + await native.dispose(); + }); +} + +for (const boundary of [ + { label: 'an unchanged old failure', initial: '订单提交失败', text: null, busy: false, hint: '下单请求仍未返回' }, + { label: 'a success toast without an API acknowledgement', initial: '', text: '订单已提交成功', busy: false, hint: '下单请求仍未返回' }, + { label: 'a native busy state without an API acknowledgement', initial: '', text: null, busy: true, hint: '下单按钮已恢复,但下单请求仍未返回' }, +]) { + test(`user treats ${boundary.label} as an unconfirmed order`, async ({ page }) => { + // Given the native form can produce UI evidence without sending an exchange request. + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario()); + const native = await page.evaluateHandle(installNativeSubmitFeedbackHost, { initialText: boundary.initial, busy: boundary.busy }); + await pauseScenarioClock(page); + + // When a real single-order click receives only the declared native UI evidence. + await page.locator('#futuresOrderbook .bid-light').first().click(); + await page.clock.runFor(100); + await expect.poll(() => native.evaluate(host => host.snapshot().attempts.length)).toBe(1); + if (boundary.text !== null) await native.evaluate((host, text) => host.publish(text), boundary.text); + await page.clock.runFor(3000); + + // Then the original attempt remains pending before the full request-start deadline. + await expect(page.locator(STATUS)).toHaveText('单击开多确认中 · 81.0 × 0.07'); + expect(await submissions(page)).toEqual([]); + + // When the remaining request-start deadline expires without a captured request. + await page.clock.runFor(500); + + // Then neither a stale failure nor UI success can acknowledge the order or authorize another attempt. + await expect(page.locator(STATUS)).toContainText(`未确认单击开多成功(${boundary.hint})`); + await expect(page.locator(STATUS)).toContainText('请在当前委托和历史成交中核对'); + expect(await native.evaluate(host => host.snapshot().attempts)).toEqual([ + { action: '开多', price: '81.0', quantity: '0.07' }, + ]); + expect(await submissions(page)).toEqual([]); + expect(host.errors).toEqual([]); + await native.evaluate(host => host.dispose()); + await native.dispose(); + }); +} + +test('user receives a close-maker rejection from native validation without automatically retrying the single order', async ({ page }) => { + // Given one long position can be closed and the native host may reject the maker price locally. + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: 'LONG', quantity: '1' }], + ui: { tradeMode: 'CLOSE' }, + })); + const native = await page.evaluateHandle(installNativeSubmitFeedbackHost); + await pauseScenarioClock(page); + + // When a trusted close price reaches validation and receives a Post Only maker rejection. + await page.locator('#futuresOrderbook .bid-light').first().click(); + await page.clock.runFor(100); + await expect.poll(() => native.evaluate(boundary => boundary.snapshot().attempts.length)).toBe(1); + await native.evaluate(boundary => boundary.publish('Post Only order rejected: could not be executed as a maker')); + await page.clock.runFor(1500); + + // Then the reason remains visible and the one-click action never turns into a retrying ladder. + await expect(page.locator(STATUS)).toHaveText('单击平多失败:Post Only order rejected: could not be executed as a maker'); + expect(await native.evaluate(boundary => boundary.snapshot().attempts)).toEqual([ + { action: '平多', price: '81.0', quantity: '0.01' }, + ]); + expect(await submissions(page)).toEqual([]); + expect(host.errors).toEqual([]); + await native.evaluate(boundary => boundary.dispose()); + await native.dispose(); +}); + +for (const native of [ + { label: 'missing', kind: 'remove', expected: '下单按钮 3 秒内未渲染完成' }, + { label: 'disabled', kind: 'disabled', expected: '下单按钮 3 秒内未恢复可点击' }, + { label: 'showing a loading class', kind: 'class', expected: '下单按钮 3 秒内未恢复可点击' }, + { label: 'showing a nested spinner', kind: 'spinner', expected: '下单按钮 3 秒内未恢复可点击' }, +]) { + test(`user stops a ladder after its native submit button remains ${native.label} for the readiness deadline`, async ({ page }) => { + // Given the quantity labels remain valid while the native open-long action becomes unavailable. + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario()); + await pauseScenarioClock(page); + await page.locator('.order-entry button').first().evaluate((button, kind) => { + if (kind === 'remove') button.remove(); + if (kind === 'disabled') button.disabled = true; + if (kind === 'class') button.classList.add('loading'); + if (kind === 'spinner') { + const spinner = document.createElement('span'); + spinner.className = 'spinner'; + button.append(spinner); + } + }, native.kind); + + // When the ordinary ladder exhausts the native button's three-second readiness window. + await page.locator('[data-ladder-action="OPEN_LONG"]').evaluate(button => button.click()); + await page.clock.runFor(3500); + + // Then the matching readiness reason stops the ladder with zero confirmed or sent orders. + await expect(page.locator(STATUS)).toContainText(native.expected); + await expect(page.locator(STATUS)).toContainText('已挂 0/5'); + expect(await submissions(page)).toEqual([]); + expect(host.errors).toEqual([]); + }); +} + +for (const field of [ + { name: 'price', selector: '#limitPrice-open', expected: '80.9', message: '价格框未同步,点击价 80.9,当前提交价 ' }, + { name: 'quantity', selector: '#unitAmount-open', expected: '0.07', message: '数量框未同步,目标量 0.07,当前提交量 ' }, +]) { + for (const rollbackValue of ['', 'unavailable', '42']) { + test(`user cannot submit when native ${field.name} remains ${rollbackValue || 'empty'} after controlled input writes`, async ({ page }) => { + // Given the native field consistently rejects proposed values and retains its declared committed state. + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario()); + const native = await page.evaluateHandle(installNativeInputRollbackHost, { selector: field.selector, rollbackValue }); + await pauseScenarioClock(page); + + // When a real book click exercises the rejected field and the synchronization deadline expires. + await page.locator('#futuresOrderbook .bid-light').nth(field.name === 'price' ? 1 : 0).click(); + await page.clock.runFor(600); + + // Then the mismatch is explicit, writes remain bounded, and no partially synchronized order is submitted. + await expect(page.locator(STATUS)).toHaveText('单击开多失败:' + field.message + (rollbackValue || '-')); + const state = await native.evaluate(boundary => boundary.snapshot()); + expect(state.current).toBe(rollbackValue); + expect(state.proposed.length).toBeGreaterThanOrEqual(1); + expect(state.proposed.length).toBeLessThanOrEqual(2); + expect([...new Set(state.proposed)]).toEqual([field.expected]); + expect(await submissions(page)).toEqual([]); + expect(host.errors).toEqual([]); + await native.evaluate(boundary => boundary.dispose()); + await native.dispose(); + }); + } +} diff --git a/e2e/binance-orderbook/specs/order-capacity-behavior.pw.js b/e2e/binance-orderbook/specs/order-capacity-behavior.pw.js new file mode 100644 index 0000000..0c86d84 --- /dev/null +++ b/e2e/binance-orderbook/specs/order-capacity-behavior.pw.js @@ -0,0 +1,252 @@ +import { test, expect } from '../test.js'; +import { CURRENT_SYMBOL, OTHER_SYMBOL, createCancelScenario } from '../scenarios/cancel-current-symbol.js'; +import { openUserscriptScenario, readFixtureState } from '../helpers/userscript-page.js'; +import { installScenarioClock, pauseScenarioClock } from '../helpers/scenario-clock.js'; + +const STATUS = '#jh-binance-ladder-status'; +const capacityRejection = { outcome: 'rejected', delivery: 'immediate', code: '90802025', message: 'Maximum open orders' }; +const success = { outcome: 'success', delivery: 'immediate' }; + +function capacityOrders(count) { + return [ + ...Array.from({ length: count }, (_, index) => ({ + id: 'same-' + index, symbol: CURRENT_SYMBOL, kind: 'basic', side: '平空', + price: String(81 + index), quantity: '1', + })), + { id: 'opposite', symbol: CURRENT_SYMBOL, kind: 'basic', side: '平多', price: '999', quantity: '1' }, + { id: 'other', symbol: OTHER_SYMBOL, kind: 'basic', side: '平空', price: '999', quantity: '1' }, + { id: 'conditional', symbol: CURRENT_SYMBOL, kind: 'conditional', side: '平空', price: '999', quantity: '1' }, + ]; +} + +async function openCapacity(page, orders, host = {}, ui = {}) { + await installScenarioClock(page); + const scenario = createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: '100' }], + orders, + ui: { tradeMode: 'CLOSE', accountTab: 'openOrders', hideOtherSymbols: false, ...ui }, + host: { + submitApiResponses: [capacityRejection, success, success, success, success, { ...success, delivery: 'manual' }], + ...host, + }, + }); + const context = await openUserscriptScenario(page, scenario); + await pauseScenarioClock(page); + return { scenario, ...context }; +} + +/** Advance only the modeled host clock; every pass observes actual page state. */ +async function advanceTo(page, description, readReached, { step = 250, limit = 25000 } = {}) { + for (let elapsed = 0; elapsed <= limit; elapsed += step) { + if (await readReached()) return; + if (elapsed < limit) await page.clock.runFor(step); + } + throw new Error('The native scenario did not reach ' + description + ': ' + await page.locator(STATUS).textContent()); +} + +async function startContinuous(page) { + await page.locator('[data-ladder-action="CLOSE_SHORT"]').evaluate(button => { + button.dispatchEvent(new MouseEvent('click', { bubbles: true, altKey: true })); + }); +} + +async function finishFirstRound(page, context) { + await advanceTo(page, 'the final pending response in the first round', async () => context.pendingSubmitSequences().includes(6)); + await context.releaseSubmitResponse(6); + await advanceTo(page, 'five acknowledged orders', async () => (await page.locator(STATUS).textContent()).includes('累计 5 笔'), + { step: 50, limit: 1000 }); + await page.locator('[data-ladder-stop]').evaluate(button => button.click()); + await page.clock.runFor(1000); +} + +for (const mountDelay of [0, 120]) { + test(`user frees only the fifty farthest same-direction slots with a native row mount delay of ${mountDelay} ms`, async ({ page }) => { + // Given sixty matching orders span native pages and unrelated farther orders must remain untouched. + const orders = capacityOrders(60); + const context = await openCapacity(page, orders, { orderRowsPageSize: 8, orderRowsMountDelayMs: mountDelay }); + const initialScroll = await page.locator('.orders-content').evaluate(element => { + element.scrollTop = 12; + return element.scrollTop; + }); + + // When the continuous close round receives a capacity rejection and the user stops after its completed recovery round. + await startContinuous(page); + await finishFirstRound(page, context); + + // Then the full list determines exactly fifty farthest cancellations and preserves all other orders and confirmed progress. + const state = await readFixtureState(page); + expect(state.events.filter(({ type }) => type === 'row-cancel-cleared').map(({ orderId }) => orderId)) + .toEqual(Array.from({ length: 50 }, (_, index) => 'same-' + (59 - index))); + expect(state.orders).toEqual([...orders.slice(0, 10), ...orders.slice(60)]); + expect(state.events.filter(({ type }) => type === 'order-rows-page-loaded').some(({ ids }) => ids.includes('same-59'))).toBe(true); + expect(state.events.filter(({ type }) => type === 'order-submit-api-success')).toHaveLength(5); + expect(state.events.filter(({ type }) => type === 'order-submitted')).toHaveLength(6); + expect(state.events.filter(({ type }) => type === 'cancel-requested')).toEqual([]); + expect(state.accountTab).toBe('openOrders'); + expect(state.hideOtherSymbols).toBe(false); + expect(state.showOrders).toBe(true); + expect(await page.locator('.orders-content').evaluate(element => element.scrollTop)).toBe(initialScroll); + await expect(page.locator(STATUS)).toContainText('累计 5 笔'); + await expect(page.locator(STATUS)).toContainText('撤 50 笔'); + expect(context.errors).toEqual([]); +}); +} + +test('user waits for the native order list to mount before choosing capacity cancellations', async ({ page }) => { + // Given the Basic scope appears before any of its delayed native rows are rendered. + const orders = capacityOrders(3); + const context = await openCapacity(page, orders, { orderRowsMountDelayMs: 900 }); + expect(await page.locator('.open-order-row').count()).toBe(0); + + // When the real continuous close workflow receives its first capacity rejection. + await startContinuous(page); + await advanceTo(page, 'a capacity-rejected request', async () => (await readFixtureState(page)).events + .some(({ type }) => type === 'order-submit-api-rejected'), { step: 50, limit: 1500 }); + + // Then a still-unmounted list has not been treated as permission to submit or cancel. + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'row-cancel-requested')).toEqual([]); + + // When the delayed native rows arrive and the captured round completes. + await finishFirstRound(page, context); + + // Then the actual three same-direction rows are cancelled in distance order before five successful replacements. + const state = await readFixtureState(page); + expect(state.events.filter(({ type }) => type === 'row-cancel-cleared').map(({ orderId }) => orderId)) + .toEqual(['same-2', 'same-1', 'same-0']); + expect(state.orders).toEqual(orders.slice(3)); + expect(state.events.filter(({ type }) => type === 'order-submitted')).toHaveLength(6); + expect(context.errors).toEqual([]); +}); + +test('user retains one confirmed released slot when the next native cancellation remains unconfirmed', async ({ page }) => { + // Given the farthest cancellation succeeds but the next row stays present past its deadline. + const orders = capacityOrders(3); + const context = await openCapacity(page, orders, { rowCancelModesById: { 'same-1': 'unchanged' } }); + + // When the actual continuous close round recovers from capacity and then completes its pending orders. + await startContinuous(page); + await finishFirstRound(page, context); + + // Then only the one confirmed removal is counted; the unresolved row and the unattempted row remain. + const state = await readFixtureState(page); + expect(state.events.filter(({ type }) => type === 'row-cancel-requested').map(({ orderId }) => orderId)) + .toEqual(['same-2', 'same-1']); + expect(state.events.filter(({ type }) => type === 'row-cancel-cleared').map(({ orderId }) => orderId)) + .toEqual(['same-2']); + expect(state.orders).toEqual(orders.filter(({ id }) => id !== 'same-2')); + await expect(page.locator(STATUS)).toContainText('累计 5 笔'); + await expect(page.locator(STATUS)).toContainText('撤 1 笔'); + expect(context.errors).toEqual([]); +}); + +test('user receives a bounded capacity recovery failure when no matching native row can be found', async ({ page }) => { + // Given only opposite-direction, other-symbol, and conditional orders exist. + const orders = capacityOrders(0); + const context = await openCapacity(page, orders, { submitApiResponses: [capacityRejection] }); + + // When a capacity-rejected continuous close round searches the fully observed native scope. + await startContinuous(page); + await advanceTo(page, 'the precise no-matching-row reason', async () => (await page.locator(STATUS).textContent()) + .includes('未找到平空方向的可撤基础单'), { step: 50, limit: 2500 }); + await page.locator('[data-ladder-stop]').evaluate(button => button.click()); + await page.clock.runFor(1000); + + // Then no unrelated cancellation or second submit is issued and all orders are retained. + const state = await readFixtureState(page); + expect(state.orders).toEqual(orders); + expect(state.events.filter(({ type }) => type === 'row-cancel-requested')).toEqual([]); + expect(state.events.filter(({ type }) => type === 'order-submitted')).toHaveLength(1); + await expect(page.locator(STATUS)).toContainText('已停止'); + expect(context.errors).toEqual([]); +}); + +for (const [name, retainedOrders, restoredSubTab] of [ + ['a short opposite-direction list', [capacityOrders(0)[0]], 'basic'], + ['only another symbol', [capacityOrders(0)[1]], 'basic'], + ['an explicit empty list', [], 'basic'], + ['the original Conditional list', Array.from({ length: 10 }, (_, index) => ({ + ...capacityOrders(0)[2], id: 'conditional-' + index, + })), 'conditional'], +]) { + test(`user finishes delayed scroll restoration with ${name} after freeing capacity`, async ({ page }) => { + // Given the original native scroll position belongs to a list that will change after cancellations. + const orders = [...capacityOrders(10).slice(0, 10), ...retainedOrders]; + const context = await openCapacity(page, orders, { orderRowsPageSize: 8, orderRowsMountDelayMs: 120 }, + { openOrdersSubTab: restoredSubTab }); + await advanceTo(page, 'the original visible rows', async () => await page.locator('.open-order-row').count() === 8, + { step: 20, limit: 200 }); + await page.locator('.orders-content').evaluate(element => { element.scrollTop = 12; }); + + // When the user completes one capacity recovery round through the actual continuous action. + await startContinuous(page); + await finishFirstRound(page, context); + + // Then mounted short and empty lists complete immediately and a still-scrollable Conditional list restores its position. + const state = await readFixtureState(page); + expect(state.orders).toEqual(retainedOrders); + expect(state.openOrdersSubTab).toBe(restoredSubTab); + expect(state.hideOtherSymbols).toBe(false); + expect(await page.locator('.orders-content').evaluate(element => element.scrollTop)) + .toBe(restoredSubTab === 'conditional' ? 12 : 0); + const filterRestoredAt = state.events.filter(event => event.type === 'hide-other-symbols' && event.value === false).at(-1).at; + const resumedAt = state.events.filter(event => event.type === 'order-submitted')[1].at; + expect(resumedAt - filterRestoredAt).toBeLessThan(1000); + expect(state.events.filter(({ type }) => type === 'row-cancel-cleared')).toHaveLength(10); + expect(state.events.filter(({ type }) => type === 'order-submit-api-success')).toHaveLength(5); + expect(state.events.filter(({ type }) => type === 'cancel-requested')).toEqual([]); + expect(context.errors).toEqual([]); + }); +} + +test('user keeps the new symbol scope when a route switch interrupts delayed scroll restoration', async ({ page }) => { + // Given capacity recovery has an original scroll position and restoration rows arrive after their filter. + const context = await openCapacity(page, capacityOrders(10), { orderRowsPageSize: 8, orderRowsMountDelayMs: 300 }); + await advanceTo(page, 'the original visible rows', async () => await page.locator('.open-order-row').count() === 8, + { step: 20, limit: 400 }); + await page.locator('.orders-content').evaluate(element => { element.scrollTop = 12; }); + await startContinuous(page); + // A 100 ms probe still observes the 300 ms native mount window while keeping + // ten-row cancellation progress bounded under precise coverage instrumentation. + await advanceTo(page, 'the filter restored before its rows', async () => (await readFixtureState(page)).events + .some(event => event.type === 'hide-other-symbols' && event.value === false), { step: 100, limit: 25000 }); + expect(await page.locator('[data-orders-loading]').count()).toBe(1); + + // When Binance changes symbol while the old restoration is waiting for the native rows. + await page.evaluate(symbol => window.__BINANCE_FIXTURE__.switchSymbol(symbol), OTHER_SYMBOL); + await page.clock.runFor(3000); + + // Then old cleanup cannot change the new scope, and the rejected order is never retried for the new symbol. + const state = await readFixtureState(page); + expect(new URL(page.url()).pathname).toBe('/zh-CN/futures/' + OTHER_SYMBOL); + expect(state.events.filter(({ type }) => type === 'order-submitted')).toHaveLength(1); + expect(state.events.filter(({ type }) => type === 'row-cancel-cleared')).toHaveLength(10); + expect(state.events.filter(({ type }) => type === 'cancel-requested')).toEqual([]); + expect(await page.locator('.orders-content').evaluate(element => element.scrollTop)).toBe(0); + expect(context.errors).toEqual([]); +}); + +test('user does not cancel another capacity batch after a second confirmed rejection in the same round', async ({ page }) => { + // Given sixty matching rows exist and the native API rejects both the initial and resumed submission. + const orders = capacityOrders(60); + const context = await openCapacity(page, orders, { + orderRowsPageSize: 8, submitApiResponses: [capacityRejection, capacityRejection], + }); + + // When one full recovery still cannot satisfy capacity and the user stops in the declared recovery wait. + await startContinuous(page); + await advanceTo(page, 'the second rejected response', async () => (await readFixtureState(page)).events + .filter(({ type }) => type === 'order-submit-api-rejected').length === 2); + await page.clock.runFor(100); + await page.locator('[data-ladder-stop]').evaluate(button => button.click()); + await page.clock.runFor(3000); + + // Then only the first fifty cancellations are counted and no duplicated or third submission occurs. + const state = await readFixtureState(page); + expect(state.orders).toEqual([...orders.slice(0, 10), ...orders.slice(60)]); + expect(state.events.filter(({ type }) => type === 'row-cancel-cleared')).toHaveLength(50); + expect(state.events.filter(({ type }) => type === 'order-submitted')).toHaveLength(2); + expect(state.events.filter(({ type }) => type === 'order-submit-api-success')).toEqual([]); + await expect(page.locator(STATUS)).toContainText('撤 50 笔'); + await expect(page.locator(STATUS)).toContainText('累计 0 笔'); + expect(context.errors).toEqual([]); +}); diff --git a/e2e/binance-orderbook/specs/order-entry-wiring-behavior.pw.js b/e2e/binance-orderbook/specs/order-entry-wiring-behavior.pw.js new file mode 100644 index 0000000..e29bc0d --- /dev/null +++ b/e2e/binance-orderbook/specs/order-entry-wiring-behavior.pw.js @@ -0,0 +1,163 @@ +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 { + installNativeCloseQuantityTransition, + installNativePostOnlyTransition, + installOrderEntryReadProbe, +} from '../../../test/helpers/order-entry-host-boundaries.js'; + +const PANEL = '#jh-binance-close-qty-multiplier-panel'; +const STATUS = '#jh-binance-ladder-status'; +const LONG = '#jh-binance-close-side-long'; +const SHORT = '#jh-binance-close-side-short'; + +async function orderSubmissions(page) { + return (await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted'); +} + +test('user waits for the native Post Only selection before the ladder can submit its exact orders', async ({ page }) => { + // Given the current native order type is Limit and selecting Post Only requires a separate host commit. + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario({ + ui: { openableQuantity: '100' }, + host: { submitApiResponses: Array.from({ length: 3 }, () => ({ outcome: 'success', delivery: 'immediate' })) }, + })); + await page.locator('[data-ladder-group="levels"][data-ladder-value="3"]').click(); + const native = await page.evaluateHandle(installNativePostOnlyTransition); + await pauseScenarioClock(page); + + // When the actual ladder action requests Post Only but the native tab remains uncommitted for 650 ms. + await page.locator('[data-ladder-action="OPEN_LONG"]').evaluate(button => button.click()); + await expect.poll(() => native.evaluate(boundary => boundary.snapshot())).toEqual({ + requests: 1, committed: false, selected: ['LIMIT'], + }); + await page.clock.runFor(650); + + // Then the real entrypoint remains in preparation without submitting under the unconfirmed order type. + await expect(page.locator(STATUS)).toHaveText('阶梯开多准备中'); + expect(await orderSubmissions(page)).toEqual([]); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'trade-input-written')).toEqual([]); + expect(await native.evaluate(boundary => boundary.snapshot())).toEqual({ + requests: 1, committed: false, selected: ['LIMIT'], + }); + + // When the native selection commits while the application clock remains paused. + const committedAt = await native.evaluate(boundary => { + boundary.commit(); + return performance.now(); + }); + + // Then the mutation releases the real next input operation without an additional fixed sleep. + await expect.poll(async () => (await readFixtureState(page)).events + .filter(({ type }) => type === 'trade-input-written') + .map(({ id, value, at }) => ({ id, value, at }))).toEqual([ + { id: 'limitPrice-open', value: '80.9', at: committedAt }, + ]); + expect(await orderSubmissions(page)).toEqual([]); + + // When the native form completes its separate stability checks for the selected three-order ladder. + await page.clock.resume(); + + // Then every exact order is acknowledged under the single committed Post Only selection. + await expect(page.locator(STATUS)).toContainText('已挂 3/3', { timeout: 8000 }); + expect((await orderSubmissions(page)).map(({ price, quantity }) => ({ price, quantity }))).toEqual([ + { price: '80.9', quantity: '0.66' }, + { price: '80.4', quantity: '0.66' }, + { price: '79.9', quantity: '0.68' }, + ]); + expect(await native.evaluate(boundary => boundary.snapshot())).toEqual({ + requests: 1, committed: true, selected: ['POST_ONLY'], + }); + await native.evaluate(boundary => boundary.dispose()); + await native.dispose(); + expect(host.errors).toEqual([]); +}); + +test('user keeps stable watchdog refreshes independent of orderbook size and limits panel layout checks', async ({ page }, testInfo) => { + // Given the actual panel is stable beside one thousand additional native orderbook rows. + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: 'LONG', quantity: '3' }], + })); + await page.locator('#futuresOrderbook').evaluate(book => { + const fragment = document.createDocumentFragment(); + for (let index = 0; index < 1000; index += 1) { + const row = document.createElement('div'); + row.className = 'row-content'; + const price = document.createElement('span'); + price.className = 'bid-light emit-price'; + price.textContent = (80 - index / 100).toFixed(2); + row.append(price); + fragment.append(row); + } + book.append(fragment); + }); + await pauseScenarioClock(page); + await page.clock.runFor(5000); + await expect(page.locator('#jh-binance-close-qty-final')).toHaveText('0.07'); + const probe = await page.evaluateHandle(installOrderEntryReadProbe); + + // When three actual five-second route watchdog cycles refresh the unchanged native page. + await page.clock.runFor(15000); + const counts = await probe.evaluate(boundary => boundary.dispose()); + await probe.dispose(); + + // Then refreshes never scan or measure book rows and perform only one panel layout check per cycle. + expect(counts.orderbookScans).toBe(0); + expect(counts.orderbookLayoutReads).toBe(0); + expect(counts.spacerRectReads).toBe(3); + expect(counts.panelHeightReads).toBe(3); + expect(counts.panelMutations).toBe(0); + await expect(page.locator(PANEL)).toHaveCount(1); + await expect(page.locator('#jh-binance-close-qty-final')).toHaveText('0.07'); + expect(await orderSubmissions(page)).toEqual([]); + expect(host.errors).toEqual([]); + await testInfo.attach('native-dom-operation-counts', { body: JSON.stringify(counts, null, 2), contentType: 'application/json' }); +}); + +test('user receives the first confirmed close quantities before the generic trade-form debounce can expire', async ({ page }) => { + // Given the native mode transition commits separately from the first close-quantity snapshot. + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: 'LONG', quantity: '3' }], + })); + const native = await page.evaluateHandle(installNativeCloseQuantityTransition); + await pauseScenarioClock(page); + await page.locator('[data-trade-mode="CLOSE"]').evaluate(tab => tab.click()); + await page.clock.runFor(200); + await expect(page.locator('#jh-binance-trade-mode-hint')).toHaveAttribute('title', '平仓模式:正在确认可平仓位'); + await expect(page.locator(LONG)).toBeEnabled(); + await expect(page.locator(SHORT)).toBeEnabled(); + await expect(page.locator('[data-ladder-action="CLOSE_SHORT"]')).toBeEnabled(); + const debounceStartedAt = await page.locator('.order-entry button').first().evaluate(button => { + button.classList.add('quantity-pending'); + return performance.now(); + }); + await page.clock.runFor(16); + await expect(page.locator('#jh-binance-trade-mode-hint')).toHaveAttribute('title', '平仓模式:正在确认可平仓位'); + + // When the native quantity publication arrives while the generic 50 ms debounce is still pending. + const publishedAt = await native.evaluate(boundary => { + boundary.publish({ longQty: '3', shortQty: '0' }); + return performance.now(); + }); + await page.clock.runFor(16); + + // Then the next frame uses the fresh long-only snapshot without waiting for the debounce deadline. + expect(await page.evaluate(startedAt => performance.now() - startedAt, publishedAt)).toBe(16); + expect(await page.evaluate(startedAt => performance.now() - startedAt, debounceStartedAt)).toBe(32); + await expect(page.locator('[data-testid="max-sell-amount"]')).toHaveText('可平 3 HYPE'); + await expect(page.locator('[data-testid="max-buy-amount"]')).toHaveText('可平 0 HYPE'); + await expect(page.locator(LONG)).toBeEnabled(); + await expect(page.locator(SHORT)).toBeDisabled(); + await expect(page.locator('[data-ladder-action="CLOSE_LONG"]')).toBeEnabled(); + await expect(page.locator('[data-ladder-action="CLOSE_SHORT"]')).toBeDisabled(); + await expect(page.locator('#jh-binance-trade-mode-hint')).toHaveAttribute('title', '平仓模式:当前仅有多仓,单击订单簿价格后将平多'); + expect(await orderSubmissions(page)).toEqual([]); + expect((await readFixtureState(page)).events.filter(({ type }) => /cancel/.test(type))).toEqual([]); + await native.evaluate(boundary => boundary.dispose()); + await native.dispose(); + expect(host.errors).toEqual([]); +}); diff --git a/e2e/binance-orderbook/specs/order-submit-behavior.pw.js b/e2e/binance-orderbook/specs/order-submit-behavior.pw.js new file mode 100644 index 0000000..39d5c20 --- /dev/null +++ b/e2e/binance-orderbook/specs/order-submit-behavior.pw.js @@ -0,0 +1,266 @@ +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'; + +const PLACE_ORDER = '**/bapi/futures/v1/private/future/order/place-order'; +const STATUS = '#jh-binance-ladder-status'; +const DIRECTIONS = [ + { action: 'OPEN_LONG', mode: 'OPEN', side: 'LONG', label: '开多', qty: '0.07', prices: ['80.9', '80.4', '79.9', '81.9', '81.4', '80.9'] }, + { action: 'OPEN_SHORT', mode: 'OPEN', side: 'SHORT', label: '开空', qty: '0.07', prices: ['81.03', '81.08', '81.13', '82.03', '82.08', '82.13'] }, + { action: 'CLOSE_LONG', mode: 'CLOSE', side: 'LONG', label: '平多', qty: '0.06', prices: ['81.03', '81.08', '81.13', '82.03', '82.08', '82.13'] }, + { 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 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. + const scenario = createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: direction.side, quantity: '100' }], + ui: { tradeMode: direction.mode }, + }); + const { errors } = await openUserscriptScenario(page, scenario); + let requests = 0; + await page.route(PLACE_ORDER, async route => { + requests += 1; + expect(requests).toBeLessThanOrEqual(6); + if (requests === 3) { + await page.locator('#futuresOrderbook .emit-price').evaluateAll(nodes => { + for (const node of nodes) node.textContent = (Number(node.textContent) + 1).toFixed(2); + }); + } + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(requests === 3 + ? { success: false, code: '90805022', message: 'Post only maker order rejected' } + : { success: true }) }); + }); + + // When the user runs one complete ladder in the chosen direction. + await page.locator('[data-ladder-action="' + direction.action + '"]').click(); + + // Then the two accepted orders are retained and only the rejected and remaining prices are rebuilt. + await expect(page.locator(STATUS)).toContainText('已完成', { timeout: 8000 }); + await expect(page.locator(STATUS)).toContainText('已挂 5/5'); + await expect(page.locator(STATUS)).toContainText('刷新盘口 1 次,错误码 90805022'); + const events = (await readFixtureState(page)).events; + const submitted = events.filter(({ type }) => type === 'order-submitted'); + expect(submitted.map(({ price }) => price)).toEqual(direction.prices); + expect(submitted.map(({ action }) => action)).toEqual(Array(6).fill(direction.label)); + expect(submitted.map(({ quantity }) => quantity)).toEqual(Array(6).fill(direction.qty)); + expect(events.filter(({ type }) => type === 'order-submit-api-success')).toHaveLength(5); + expect(events.filter(({ type }) => /cancel/.test(type))).toEqual([]); + expect(requests).toBe(6); + expect(errors).toEqual([]); + }); +} + +for (const [name, status, payload, headers, message] of [ + ['native rate limit', 429, { success: false, code: '-1003' }, { 'retry-after': '7' }, '下单请求频率受限'], + ['native service error', 503, { success: false, message: 'Service unavailable' }, {}, 'Binance 服务异常'], + ['capacity rejection', 200, { success: false, code: '90802025', message: 'Maximum open orders' }, {}, 'Maximum open orders(错误码 90802025)'], + ['terminal business rejection', 200, { success: false, code: '400123', message: 'Account restricted' }, {}, '订单提交失败(错误码 400123)'], + ['unrecognized successful HTTP response', 200, { data: { requestAccepted: true } }, {}, '下单请求已返回,但结果未识别'], +]) { + test(`user stops an ordinary ladder on a ${name} without a second submit or cancellation`, async ({ page }) => { + // Given the first native order response has an explicit transport and business outcome. + const { errors } = await openUserscriptScenario(page, createCancelScenario()); + let requests = 0; + await page.route(PLACE_ORDER, async route => { + requests += 1; + await route.fulfill({ status, headers, contentType: 'application/json', body: JSON.stringify(payload) }); + }); + + // When the user starts an ordinary open-long ladder. + await page.locator('[data-ladder-action="OPEN_LONG"]').click(); + + // Then the exact failure category and zero acknowledged orders are shown with no recovery action. + await expect(page.locator(STATUS)).toContainText('阶梯开多失败'); + await expect(page.locator(STATUS)).toContainText(message); + await expect(page.locator(STATUS)).toContainText('已挂 0/5'); + expect(requests).toBe(1); + const events = (await readFixtureState(page)).events; + expect(events.filter(({ type }) => type === 'order-submitted')).toHaveLength(1); + expect(events.filter(({ type }) => /cancel/.test(type))).toEqual([]); + expect(errors).toEqual([]); + }); +} + +test('user stops a close ladder on a conflicting reduce-only response without assuming the position was closed', async ({ page }) => { + // Given the native payload claims success but simultaneously contains a reduce-only error code. + const { errors } = await openUserscriptScenario(page, createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: '100' }], + ui: { tradeMode: 'CLOSE' }, + })); + let requests = 0; + await page.route(PLACE_ORDER, async route => { + requests += 1; + await route.fulfill({ status: 200, contentType: 'application/json', + body: JSON.stringify({ success: true, code: '90802022' }) }); + }); + + // When the user starts a single close-short round. + await page.locator('[data-ladder-action="CLOSE_SHORT"]').click(); + + // Then the contradictory response stops the round as unconfirmed and cannot authorize replacement. + await expect(page.locator(STATUS)).toContainText('只减仓拒单响应不完整或存在冲突'); + await expect(page.locator(STATUS)).toContainText('已挂 0/5'); + expect(requests).toBe(1); + expect((await readFixtureState(page)).events.filter(({ type }) => /cancel/.test(type))).toEqual([]); + expect(errors).toEqual([]); +}); + +test('user can stop after five consecutive maker rejections during the declared reprice pause', async ({ page }) => { + // Given five explicit maker rejections arrive without any accepted order. + await installScenarioClock(page); + const { errors } = await openUserscriptScenario(page, createCancelScenario({ host: { + submitApiResponses: Array.from({ length: 5 }, () => ({ + outcome: 'rejected', delivery: 'immediate', code: '-5022', message: 'Post only maker order rejected', + })), + } })); + await page.locator('[data-ladder-action="OPEN_LONG"]').click(); + await expect(page.locator(STATUS)).toContainText('3s 后继续', { timeout: 8000 }); + await expect(page.locator(STATUS)).toContainText('已刷新 5 次'); + + // When the user stops while the reprice cooldown is active and time advances past that cooldown. + await page.locator('[data-ladder-stop]').evaluate(button => button.click()); + await pauseScenarioClock(page); + await page.clock.runFor(10000); + + // Then the stopped result keeps zero accepted orders and no sixth request can begin. + await expect(page.locator(STATUS)).toContainText('阶梯开多已停止'); + await expect(page.locator(STATUS)).toContainText('已挂 0/5'); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted')).toHaveLength(5); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submit-api-success')).toEqual([]); + expect(errors).toEqual([]); +}); + +for (const direction of DIRECTIONS) { + test(`user submits exactly one ${direction.action} order from a trusted orderbook price click`, async ({ page }) => { + // Given both native position directions exist and the user selects one direction explicitly. + const { errors } = await openUserscriptScenario(page, createCancelScenario({ + positions: [ + { symbol: CURRENT_SYMBOL, side: 'LONG', quantity: '100' }, + { symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: '80' }, + ], + ui: { tradeMode: direction.mode }, + })); + await page.getByRole('radio', { name: direction.label, exact: true }).click(); + + // When the user clicks one real native bid price. + await page.locator('#futuresOrderbook .bid-light.emit-price').first().click(); + + // Then exactly the selected direction is acknowledged with the clicked price and exchange-valid quantity. + await expect(page.locator(STATUS)).toHaveText('单击' + direction.label + '已提交 · 81.0 × ' + (direction.mode === 'OPEN' ? '0.07' : '0.01')); + const submissions = (await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted'); + expect(submissions.map(({ action, price, quantity }) => ({ action, price, quantity }))).toEqual([ + { action: direction.label, price: '81.0', quantity: direction.mode === 'OPEN' ? '0.07' : '0.01' }, + ]); + expect(errors).toEqual([]); + }); +} + +for (const [name, selector, expected] of [ + ['quantity input', '#unitAmount-open', '单击下单未执行:未找到数量输入框'], + ['price input', '#limitPrice-open', '单击下单未执行:未找到价格输入框'], + ['precision value', '#futuresOrderbook .tick-content', '单击下单失败:未识别价格精度'], +]) { + test(`user receives a concrete refusal when a native ${name} disappears before a price click`, async ({ page }) => { + // Given the loaded panel loses one required native control before an actual orderbook click. + const { errors } = await openUserscriptScenario(page, createCancelScenario()); + await page.locator(selector).evaluate(element => element.remove()); + + // When the user selects a bid on the incomplete native form. + await page.locator('#futuresOrderbook .bid-light.emit-price').first().click(); + + // Then the missing control is identified and no native submit request is produced. + await expect(page.locator(STATUS)).toHaveText(expected); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted')).toEqual([]); + expect(errors).toEqual([]); + }); +} + +for (const changed of ['symbol', 'mode', 'precision', 'direction']) { + test(`user rejects an in-flight single-order draft when its captured ${changed} changes before native submission`, async ({ page }) => { + // Given a native state transition is scheduled by the first real controlled-input write. + const { errors } = await openUserscriptScenario(page, createCancelScenario()); + await page.locator('#limitPrice-open').evaluate((input, change) => { + input.addEventListener('input', () => { + if (change === 'symbol') window.__BINANCE_FIXTURE__.switchSymbol('BTCUSDT'); + if (change === 'mode') document.querySelector('[data-trade-mode="CLOSE"]').click(); + if (change === 'precision') window.__BINANCE_FIXTURE__.replacePrecisionControl({ + scope: 'root', value: '0.01', symbol: 'HYPEUSDT', options: ['0.001', '0.01', '0.1', '1'], + }); + if (change === 'direction') Array.from(document.querySelectorAll('[role="radio"]')) + .find(radio => radio.textContent.trim() === '开空').click(); + document.body.dataset.singleDraftChanged = change; + }, { once: true }); + }, changed); + + // When the user selects a native orderbook price and the external transition interrupts synchronization. + await page.locator('#futuresOrderbook .bid-light.emit-price').nth(1).click(); + + // Then the changed draft fails visibly and no request can use its stale symbol, mode, precision, or direction. + await expect(page.locator('body')).toHaveAttribute('data-single-draft-changed', changed); + await expect(page.locator(STATUS)).toContainText('失败'); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted')).toEqual([]); + expect(errors).toEqual([]); + }); +} + +test('user keeps one pending single order when another price click and a competing ladder arrive', async ({ page }) => { + // Given the first native single-order response is held by the external API boundary. + const context = await openUserscriptScenario(page, createCancelScenario({ host: { + submitApiResponses: [{ outcome: 'success', delivery: 'manual' }], + } })); + await page.locator('#futuresOrderbook .bid-light.emit-price').first().click(); + await expect(page.locator(STATUS)).toContainText('单击开多确认中'); + + // When a second trusted price click and the public ladder entrypoint compete with the pending single task. + await page.locator('#futuresOrderbook .bid-light.emit-price').nth(1).click(); + const competing = await page.evaluate(() => window.__TM_CLOSE_LONG_DEBUG__.startLadder('OPEN_SHORT')); + + // Then the existing request and captured first price remain the only executable task. + expect(competing).toEqual({ status: 'not_started' }); + expect(context.pendingSubmitSequences()).toEqual([1]); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted') + .map(({ price, quantity }) => ({ price, quantity }))).toEqual([{ price: '81.0', quantity: '0.07' }]); + + // When the original response is explicitly accepted. + await context.releaseSubmitResponse(1); + + // Then the original single-order result finishes without a later queued submission. + await expect(page.locator(STATUS)).toHaveText('单击开多已提交 · 81.0 × 0.07'); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted')).toHaveLength(1); + expect(context.errors).toEqual([]); +}); + +test('user cannot submit from a cached close display after both native quantity labels disappear', async ({ page }) => { + // Given a previously confirmed close quantity is still available for display after native data disappears. + const { errors } = await openUserscriptScenario(page, createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: 'LONG', quantity: '100' }], ui: { tradeMode: 'CLOSE' }, + })); + await page.locator('.order-entry [data-testid^="max-"]').evaluateAll(elements => elements.forEach(element => element.remove())); + + // When the user makes a trusted native price click while fresh close evidence is missing. + await page.locator('#futuresOrderbook .bid-light.emit-price').first().click(); + + // Then the missing current close action refuses execution and cached display values cannot authorize a submit. + await expect(page.locator(STATUS)).toHaveText('单击下单未执行:未找到可用平仓动作'); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted')).toEqual([]); + expect(errors).toEqual([]); +}); + +test('user can populate valid order fields in the configured safe mode without submitting', async ({ page }) => { + // Given the public debug configuration enables the script's existing safe execution mode. + const { errors } = await openUserscriptScenario(page, createCancelScenario()); + await page.evaluate(() => { window.__TM_CLOSE_LONG_DEBUG__.cfg.SAFE_MODE = true; }); + + // When the user selects one native bid price. + await page.locator('#futuresOrderbook .bid-light.emit-price').first().click(); + + // Then the real quantity and price synchronization completes while the native action stays unused. + await expect(page.locator('#limitPrice-open')).toHaveValue('81.0'); + await expect(page.locator('#unitAmount-open')).toHaveValue('0.07'); + await expect(page.locator('[data-ladder-action="OPEN_LONG"]')).toBeEnabled(); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted')).toEqual([]); + expect(errors).toEqual([]); +}); diff --git a/e2e/binance-orderbook/specs/panel-host-transition-boundaries.pw.js b/e2e/binance-orderbook/specs/panel-host-transition-boundaries.pw.js new file mode 100644 index 0000000..37666d8 --- /dev/null +++ b/e2e/binance-orderbook/specs/panel-host-transition-boundaries.pw.js @@ -0,0 +1,234 @@ +import { test, expect } from '../test.js'; +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 { + DEPTH_LABEL_LEVELS, + DEPTH_LABEL_SYMBOL, + DEPTH_PROFILE_SELECTOR, + emitDepthLabelUpdate, + openDepthLabelScenario, + readDepthDrawing, +} from '../helpers/depth-profile-fixture.js'; + +const PANEL = '#jh-binance-close-qty-multiplier-panel'; +const SPACER = '#jh-binance-close-qty-multiplier-spacer'; +const INPUT = '#jh-binance-close-qty-multiplier-input'; +const DEPTH_KEY = 'jh_binance_depth_profile_enabled_v1'; + +async function openReadyPanel(page) { + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario()); + await expect(page.locator('[data-orderbook-precision-value="0.01"]')).toBeEnabled(); + await pauseScenarioClock(page); + return host; +} + +async function expectNoFinancialActions(page) { + expect((await readFixtureState(page)).events.filter(event => [ + 'order-submitted', 'cancel-requested', 'row-cancel-requested', + ].includes(event.type))).toEqual([]); +} + +async function publishDepthPreference(page, value) { + await page.evaluate(({ key, value }) => { + const oldValue = localStorage.getItem(key); + localStorage.setItem(key, value); + window.dispatchEvent(new StorageEvent('storage', { key, oldValue, newValue: value })); + }, { key: DEPTH_KEY, value }); +} + +test('user keeps the panel hidden until the native form restores its missing placement anchor', async ({ page }) => { + // Given the rendered panel retains a user multiplier beside the native trade tabs. + const host = await openReadyPanel(page); + await page.locator(INPUT).fill('4'); + await page.locator(INPUT).blur(); + const panel = await page.locator(PANEL).elementHandle(); + + // When native reconciliation detaches the trade tabs before a window resize. + await page.evaluate(() => { + const node = document.querySelector('#position-direction'); + window.__DETACHED_TRADE_ANCHOR__ = { node, parent: node.parentElement, next: node.nextSibling }; + node.remove(); + window.dispatchEvent(new Event('resize')); + }); + await page.clock.runFor(100); + await page.evaluate(() => window.dispatchEvent(new Event('resize'))); + await page.clock.runFor(100); + + // Then the spacer is removed and the existing panel cannot intercept pointer events without an anchor. + await expect(page.locator(SPACER)).toHaveCount(0); + await expect(page.locator(PANEL)).toHaveCSS('visibility', 'hidden'); + await expect(page.locator(PANEL)).toHaveCSS('pointer-events', 'none'); + expect(await panel.evaluate(node => node === document.querySelector('#jh-binance-close-qty-multiplier-panel'))).toBe(true); + + // When the same native tabs mount again and layout is observed. + await page.evaluate(() => { + const { node, parent, next } = window.__DETACHED_TRADE_ANCHOR__; + parent.insertBefore(node, next); + delete window.__DETACHED_TRADE_ANCHOR__; + window.dispatchEvent(new Event('resize')); + }); + await page.clock.runFor(100); + + // Then one anchored panel restores the user's setting and its pointer interaction. + await expect(page.locator(PANEL)).toBeVisible(); + await expect(page.locator(PANEL)).toHaveCSS('pointer-events', 'auto'); + await expect(page.locator(SPACER)).toHaveCount(1); + await expect(page.locator(INPUT)).toHaveValue('4'); + expect(await panel.evaluate(node => node === document.querySelector('#jh-binance-close-qty-multiplier-panel'))).toBe(true); + await panel.dispose(); + await expectNoFinancialActions(page); + expect(host.errors).toEqual([]); +}); + +test('user cannot interact with the floating panel while its native form has no visible layout area', async ({ page }) => { + // Given the native order form provides the panel's measured placement rectangle. + const host = await openReadyPanel(page); + + // When the native page hides its form column during a layout transition. + await page.locator('#trade-form').evaluate(node => { node.style.display = 'none'; }); + await page.evaluate(() => window.dispatchEvent(new Event('resize'))); + await page.clock.runFor(100); + + // Then the zero-area anchor keeps the overlay hidden and unable to receive pointer events. + await expect(page.locator(PANEL)).toHaveCSS('visibility', 'hidden'); + await expect(page.locator(PANEL)).toHaveCSS('pointer-events', 'none'); + expect(await page.locator(SPACER).evaluate(node => { + const { width, height } = node.getBoundingClientRect(); + return { width, height }; + })).toEqual({ width: 0, height: 0 }); + + // When the native form becomes visible again and the browser publishes its resize. + await page.locator('#trade-form').evaluate(node => node.style.removeProperty('display')); + await page.evaluate(() => window.dispatchEvent(new Event('resize'))); + await page.clock.runFor(100); + + // Then the panel becomes available at the restored native anchor without a new action. + await expect(page.locator(PANEL)).toBeVisible(); + await expect(page.locator(PANEL)).toHaveCSS('pointer-events', 'auto'); + await expect(page.locator(INPUT)).toHaveValue('1'); + await expectNoFinancialActions(page); + expect(host.errors).toEqual([]); +}); + +test('user keeps one correctly owned floating panel after the host reparents its DOM during layout', async ({ page }) => { + // Given a ready floating panel has an established native form anchor. + const host = await openReadyPanel(page); + const panel = await page.locator(PANEL).elementHandle(); + + // When the page reparents that panel into a temporary layout container and resizes. + await page.evaluate(() => { + const temporary = document.createElement('div'); + temporary.id = 'native-temporary-layout'; + document.body.append(temporary); + temporary.append(document.querySelector('#jh-binance-close-qty-multiplier-panel')); + window.dispatchEvent(new Event('resize')); + }); + await page.clock.runFor(100); + + // Then the original panel returns to body ownership and the temporary container is empty. + expect(await panel.evaluate(node => node.parentElement === document.body)).toBe(true); + await expect(page.locator(PANEL)).toHaveCount(1); + await expect(page.locator(PANEL)).toBeVisible(); + await expect(page.locator('#native-temporary-layout > *')).toHaveCount(0); + await panel.dispose(); + await expectNoFinancialActions(page); + expect(host.errors).toEqual([]); +}); + +test('user synchronizes depth visibility from another browser context without opening another depth stream', async ({ page }) => { + // Given a real native depth snapshot is rendered through the complete userscript entrypoint. + const host = await openDepthLabelScenario(page); + const root = page.locator(DEPTH_PROFILE_SELECTOR); + const expectedLabels = ['1.3 · 620K', '1.8 · 3.8M', '2 · 2.4M']; + await expect.poll(async () => (await readDepthDrawing(page)).texts.map(({ text }) => text).sort()) + .toEqual(expectedLabels); + + // When another browser context disables the depth profile through its shared preference. + await publishDepthPreference(page, '0'); + + // Then the canvas is collapsed without disconnecting or replacing the page-owned stream. + await expect(root).toHaveAttribute('data-expanded', 'false'); + await expect(root.locator('canvas')).toBeHidden(); + await expect.poll(async () => (await readDepthDrawing(page)).texts).toEqual([]); + expect(await page.evaluate(() => window.__DEPTH_LABEL_FIXTURE__.socketCount)).toBe(1); + + // When that browser context enables the preference again. + await publishDepthPreference(page, '1'); + + // Then the cached native snapshot is rendered again without a second snapshot or stream. + await expect(root).toHaveAttribute('data-expanded', 'true'); + await expect.poll(async () => (await readDepthDrawing(page)).texts.map(({ text }) => text).sort()) + .toEqual(expectedLabels); + expect(host.snapshotRequests).toHaveLength(1); + expect(await page.evaluate(() => window.__DEPTH_LABEL_FIXTURE__.socketCount)).toBe(1); + await expectNoFinancialActions(page); + expect(host.errors).toEqual([]); +}); + +test('user sees invalid native depth clear the canvas and recovers only from valid native data', async ({ page }) => { + // Given the depth profile is showing current native quantities. + const host = await openDepthLabelScenario(page); + const root = page.locator(DEPTH_PROFILE_SELECTOR); + await expect.poll(async () => (await readDepthDrawing(page)).texts.length).toBeGreaterThan(0); + + // When the native stream publishes an invalid negative quantity. + await emitDepthLabelUpdate(page, { asks: [['1.8', '-1']], bids: [] }); + + // Then the native failure is visible and no stale or negative quantity remains painted. + await expect(root.locator('.jh-depth-profile-status')).toHaveText('深度数据不可用'); + await expect(root.locator('.jh-depth-profile-status')).toHaveAttribute('title', /Invalid depth profile .* quantity/); + await expect.poll(async () => (await readDepthDrawing(page)).texts).toEqual([]); + expect(await root.locator('canvas').evaluate(canvas => ( + canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data.every(value => value === 0) + ))).toBe(true); + + // When the user disables and enables the view before the native stream recovers. + await publishDepthPreference(page, '0'); + await expect(root).toHaveAttribute('data-expanded', 'false'); + await publishDepthPreference(page, '1'); + await expect(root).toHaveAttribute('data-expanded', 'true'); + + // Then enabling alone cannot replace the native error with invented depth. + await expect(root.locator('.jh-depth-profile-status')).toHaveText('深度数据不可用'); + await expect.poll(async () => (await readDepthDrawing(page)).texts).toEqual([]); + + // When the next valid native update follows the rejected sequence number. + await emitDepthLabelUpdate(page, { asks: DEPTH_LABEL_LEVELS.asks, bids: [] }); + + // Then the missing accepted sequence requires a fresh native snapshot instead of invented continuity. + await expect(root.locator('.jh-depth-profile-status')).toHaveText('重新同步深度'); + await expect(root.locator('.jh-depth-profile-status')).toHaveAttribute('title', + '重新同步深度: Depth update sequence gap: expected pu 102, received 103'); + await expect.poll(async () => (await readDepthDrawing(page)).texts).toEqual([]); + expect(host.snapshotRequests).toHaveLength(1); + + // When the native page fetches a fresh snapshot with an overlapping stream update. + const recoveryRequests = []; + await page.route('https://www.binance.com/fapi/v1/rpiDepth**', async route => { + const url = new URL(route.request().url()); + recoveryRequests.push({ symbol: url.searchParams.get('symbol'), limit: url.searchParams.get('limit') }); + await route.fulfill({ json: { lastUpdateId: 105, ...DEPTH_LABEL_LEVELS } }); + }); + await page.evaluate(async symbol => { + const state = window.__DEPTH_LABEL_FIXTURE__; + const snapshot = fetch(`/fapi/v1/rpiDepth?${new URLSearchParams({ symbol, limit: '1000' })}`); + state.updateId = 105; + state.socket.dispatchEvent(new MessageEvent('message', { data: JSON.stringify({ + stream: `${symbol.toLowerCase()}@rpiDepth@500ms`, + data: { e: 'depthUpdate', s: symbol, st: 1, U: 105, u: 105, pu: 104, b: [], a: [] }, + }) })); + await (await snapshot).json(); + }, DEPTH_LABEL_SYMBOL); + + // Then only validated depth returns through the original stream and the error text is cleared. + await expect(root.locator('.jh-depth-profile-status')).toHaveText(''); + await expect.poll(async () => (await readDepthDrawing(page)).texts.map(({ text }) => text).sort()) + .toEqual(['1.3 · 620K', '1.8 · 3.8M', '2 · 2.4M']); + expect(host.snapshotRequests).toHaveLength(1); + expect(recoveryRequests).toEqual([{ symbol: DEPTH_LABEL_SYMBOL, limit: '1000' }]); + expect(await page.evaluate(() => window.__DEPTH_LABEL_FIXTURE__.socketCount)).toBe(1); + await expectNoFinancialActions(page); + expect(host.errors).toEqual([]); +}); diff --git a/e2e/binance-orderbook/specs/panel-lifecycle-behavior.pw.js b/e2e/binance-orderbook/specs/panel-lifecycle-behavior.pw.js new file mode 100644 index 0000000..37fd999 --- /dev/null +++ b/e2e/binance-orderbook/specs/panel-lifecycle-behavior.pw.js @@ -0,0 +1,692 @@ +import { test, expect } from '../test.js'; +import { CURRENT_SYMBOL, OTHER_SYMBOL, createCancelScenario } from '../scenarios/cancel-current-symbol.js'; +import { openUserscriptScenario, readFixtureState } from '../helpers/userscript-page.js'; +import { installScenarioClock, pauseScenarioClock } from '../helpers/scenario-clock.js'; + +const PANEL = '#jh-binance-close-qty-multiplier-panel'; +const INPUT = '#jh-binance-close-qty-multiplier-input'; +const LONG = '#jh-binance-close-side-long'; +const SHORT = '#jh-binance-close-side-short'; +const FINAL_QUANTITY = '#jh-binance-close-qty-final'; +const MULTIPLIER_PREFIX = 'jh_binance_qty_multiplier_v2'; + +async function multiplierValue(page, mode, symbol = CURRENT_SYMBOL, precision = '0.1') { + return page.evaluate(key => localStorage.getItem(key), `${MULTIPLIER_PREFIX}:${mode}:${symbol}:${precision}`); +} + +async function expectNoOrderActions(page) { + expect((await readFixtureState(page)).events.filter(({ type }) => ( + type === 'order-submitted' || type === 'cancel-requested' + ))).toEqual([]); +} + +test('user sanitizes multiplier typing and repairs an invalid value on blur', async ({ page }) => { + // Given the complete generated entrypoint displays the current minimum open quantity. + const { errors } = await openUserscriptScenario(page, createCancelScenario()); + const input = page.locator(INPUT); + await expect(page.locator(FINAL_QUANTITY)).toHaveText('0.07'); + + // When the user types mixed characters into the numeric multiplier. + await input.fill('2a.3'); + + // Then the input and saved setting contain only digits and the real quantity calculation updates. + await expect(input).toHaveValue('23'); + await expect(page.locator('[data-multiplier-formula-prefix]')).toHaveText('0.07 × 23 ='); + await expect(page.locator(FINAL_QUANTITY)).toHaveText('1.61'); + expect(await multiplierValue(page, 'OPEN')).toBe('23'); + + // When the user replaces it with an invalid zero while still editing. + await input.fill('0'); + + // Then invalid input is visible but cannot replace the last valid saved multiplier. + await expect(input).toHaveValue('0'); + await expect(page.locator(FINAL_QUANTITY)).toHaveText('请输入正整数倍数'); + await expect(page.locator('#jh-binance-close-qty-multiplier-dec')).toBeDisabled(); + expect(await multiplierValue(page, 'OPEN')).toBe('23'); + + // When the user leaves the invalid field. + await input.blur(); + + // Then the documented multiplier of one is committed with the original minimum quantity. + await expect(input).toHaveValue('1'); + await expect(page.locator(FINAL_QUANTITY)).toHaveText('0.07'); + expect(await multiplierValue(page, 'OPEN')).toBe('1'); + await expectNoOrderActions(page); + expect(errors).toEqual([]); +}); + +test('user can clear a multiplier temporarily and commit its minimum on blur', async ({ page }) => { + // Given a valid edited multiplier has already been saved. + const { errors } = await openUserscriptScenario(page, createCancelScenario()); + const input = page.locator(INPUT); + await input.fill('4'); + + // When the user clears the active field. + await input.fill(''); + + // Then the empty edit remains visible without overwriting the valid saved value. + await expect(input).toHaveValue(''); + await expect(page.locator(FINAL_QUANTITY)).toHaveText('请输入正整数倍数'); + expect(await multiplierValue(page, 'OPEN')).toBe('4'); + + // When the empty edit loses focus. + await input.blur(); + + // Then the input, saved value, and formula return to a multiplier of one. + await expect(input).toHaveValue('1'); + expect(await multiplierValue(page, 'OPEN')).toBe('1'); + await expect(page.locator('[data-multiplier-formula-prefix]')).toHaveText('0.07 × 1 ='); + expect(errors).toEqual([]); +}); + +test('user decrements a multiplier only to one and repeated presses retain a single field identity', async ({ page }) => { + // Given the default multiplier disables decrement and exposes one editable field. + const { errors } = await openUserscriptScenario(page, createCancelScenario()); + const input = page.locator(INPUT); + const handle = await input.elementHandle(); + const decrement = page.locator('#jh-binance-close-qty-multiplier-dec'); + await expect(decrement).toBeDisabled(); + + // When two consecutive increment clicks are followed by two decrement clicks. + await page.locator('#jh-binance-close-qty-multiplier-inc').dblclick(); + await expect(input).toHaveValue('3'); + await decrement.click(); + await expect(input).toHaveValue('2'); + await decrement.click(); + + // Then the multiplier reaches one without replacing the field or submitting an order. + await expect(input).toHaveValue('1'); + await expect(decrement).toBeDisabled(); + expect(await handle.evaluate(element => element === document.querySelector('#jh-binance-close-qty-multiplier-input'))).toBe(true); + expect(await multiplierValue(page, 'OPEN')).toBe('1'); + await handle.dispose(); + await expectNoOrderActions(page); + expect(errors).toEqual([]); +}); + +test('user keeps independent open and close multipliers while switching the native form', async ({ page }) => { + // Given both position directions are available and the open multiplier is five. + const { errors } = await openUserscriptScenario(page, createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: 'LONG', quantity: '4' }, { symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: '7' }], + })); + await page.locator(INPUT).fill('5'); + await page.locator(INPUT).blur(); + + // When the user enters close mode and edits its independent multiplier. + await page.locator('#position-direction [data-trade-mode="CLOSE"]').click(); + await expect(page.locator(INPUT)).toHaveValue('1'); + await expect(page.locator('#jh-binance-qty-multiplier-hint')).toHaveText('最小平仓量的'); + await page.locator(INPUT).fill('3'); + await page.locator(INPUT).blur(); + + // Then the close formula uses the close minimum while the open setting stays five. + await expect(page.locator(FINAL_QUANTITY)).toHaveText('0.03'); + await expect(page.locator('#jh-binance-close-qty-min')).toBeHidden(); + expect(await multiplierValue(page, 'OPEN')).toBe('5'); + expect(await multiplierValue(page, 'CLOSE')).toBe('3'); + + // When the user returns to the native open mode. + await page.locator('#position-direction [data-trade-mode="OPEN"]').click(); + + // Then the original open setting and notional constraint return unchanged. + await expect(page.locator(INPUT)).toHaveValue('5'); + await expect(page.locator(FINAL_QUANTITY)).toHaveText('0.35'); + await expect(page.locator('#jh-binance-close-qty-min')).toHaveText('≥5U @ 81'); + await expectNoOrderActions(page); + expect(errors).toEqual([]); +}); + +test('user keeps multiplier preferences independent for each native price precision', async ({ page }) => { + // Given the current 0.1 precision has a saved multiplier of seven. + const { errors } = await openUserscriptScenario(page, createCancelScenario()); + await page.locator(INPUT).fill('7'); + await page.locator(INPUT).blur(); + const finer = page.locator('[data-orderbook-precision-value="0.01"]'); + await expect(finer).toBeEnabled(); + + // When the user selects 0.01 precision and saves a different multiplier. + await finer.click(); + await expect(page.locator('#futuresOrderbook .tick-content')).toHaveText('0.01'); + await expect(page.locator(INPUT)).toHaveValue('1'); + await page.locator(INPUT).fill('4'); + await page.locator(INPUT).blur(); + + // Then each precision keeps exactly its own stored multiplier. + expect(await multiplierValue(page, 'OPEN', CURRENT_SYMBOL, '0.1')).toBe('7'); + expect(await multiplierValue(page, 'OPEN', CURRENT_SYMBOL, '0.01')).toBe('4'); + + // When the user returns to the original price precision. + await page.locator('[data-orderbook-precision-value="0.1"]').click(); + + // Then the panel restores seven from the matching precision scope. + await expect(page.locator(INPUT)).toHaveValue('7'); + await expectNoOrderActions(page); + expect(errors).toEqual([]); +}); + +for (const eventType of ['input', 'blur']) { + test(`user discards a stale multiplier ${eventType} after a symbol transition`, async ({ page }) => { + // Given the focused HYPE field has saved five in its current symbol scope. + const { errors } = await openUserscriptScenario(page, createCancelScenario()); + await page.locator(INPUT).fill('5'); + + // When native navigation changes symbol before an old field event is delivered. + await page.evaluate(({ symbol, inputId, eventType }) => { + const input = document.getElementById(inputId); + window.__BINANCE_FIXTURE__.switchSymbol(symbol); + input.value = '99'; + if (eventType === 'blur') input.blur(); + else input.dispatchEvent(new InputEvent('input', { bubbles: true, data: '99' })); + }, { symbol: OTHER_SYMBOL, inputId: INPUT.slice(1), eventType }); + + // Then the stale value cannot cross into the new symbol's multiplier settings. + await expect(page.locator(INPUT)).toHaveValue('1'); + expect(await multiplierValue(page, 'OPEN', CURRENT_SYMBOL)).toBe('5'); + expect(await multiplierValue(page, 'OPEN', OTHER_SYMBOL)).toBe(null); + + // When the native page returns to the original symbol. + await page.evaluate(symbol => window.__BINANCE_FIXTURE__.switchSymbol(symbol), CURRENT_SYMBOL); + + // Then the original scope still displays five and no order action occurred. + await expect(page.locator(INPUT)).toHaveValue('5'); + await expectNoOrderActions(page); + expect(errors).toEqual([]); + }); +} + +test('user changes open direction with arrow keys while preserving radio focus and symbol ownership', async ({ page }) => { + // Given both open directions are enabled and Long owns the radio tab stop. + const { errors } = await openUserscriptScenario(page, createCancelScenario()); + const long = page.locator(LONG); + const short = page.locator(SHORT); + await long.focus(); + await expect(long).toHaveAttribute('aria-checked', 'true'); + + // When ArrowLeft wraps from Long to Short. + await long.press('ArrowLeft'); + + // Then Short becomes checked, focused, and persisted for the current symbol. + await expect(short).toBeFocused(); + await expect(short).toHaveAttribute('aria-checked', 'true'); + await expect(short).toHaveAttribute('tabindex', '0'); + await expect(long).toHaveAttribute('tabindex', '-1'); + expect(await page.evaluate(() => localStorage.getItem('jh_binance_open_side:HYPEUSDT'))).toBe('SHORT'); + + // When the other supported arrow keys cycle both directions and an unrelated key is pressed. + await short.press('ArrowDown'); + await expect(long).toBeFocused(); + await long.press('ArrowRight'); + await expect(short).toBeFocused(); + await short.press('ArrowUp'); + await long.press('Home'); + + // Then Long remains the selected tab stop and no trade or cancel action was issued. + await expect(long).toBeFocused(); + await expect(long).toHaveAttribute('aria-checked', 'true'); + expect(await page.evaluate(() => localStorage.getItem('jh_binance_open_side:HYPEUSDT'))).toBe('LONG'); + await expectNoOrderActions(page); + expect(errors).toEqual([]); +}); + +const CLOSE_DISPLAYS = [ + { label: 'long position only', longQty: '4', shortQty: '0', longDisabled: false, shortDisabled: true, selected: 'LONG', zhHint: '当前仅有多仓', enHint: 'long position only' }, + { label: 'short position only', longQty: '0', shortQty: '7', longDisabled: true, shortDisabled: false, selected: 'SHORT', zhHint: '当前仅有空仓', enHint: 'short position only' }, + { label: 'hedged positions', longQty: '4', shortQty: '7', longDisabled: false, shortDisabled: false, selected: 'LONG', zhHint: '双向持仓', enHint: 'hedged positions' }, + { label: 'no position', longQty: '0', shortQty: '0', longDisabled: true, shortDisabled: true, selected: 'LONG', zhHint: '暂无可平仓位', enHint: 'no position to close' }, +]; + +for (const locale of ['zh-CN', 'en']) { + for (const display of CLOSE_DISPLAYS) { + test(`user sees ${display.label} with the correct ${locale} close controls`, async ({ page }) => { + // Given the native close form owns the declared current-symbol position quantities. + const { errors } = await openUserscriptScenario(page, createCancelScenario({ + ui: { tradeMode: 'CLOSE' }, + positions: [ + { symbol: CURRENT_SYMBOL, side: 'LONG', quantity: display.longQty }, + { symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: display.shortQty }, + ], + })); + + // When the route applies the requested panel language and refreshes the real entrypoint. + await page.evaluate(({ locale, symbol }) => { + history.pushState({}, '', `/${locale}/futures/${symbol}`); + window.__TM_CLOSE_LONG_DEBUG__.renderPanel(); + }, { locale, symbol: CURRENT_SYMBOL }); + + // Then the position evidence controls both script and native buttons with the same semantics. + await expect(page.locator(LONG)).toHaveText(locale === 'en' ? 'Long' : '平多'); + await expect(page.locator(SHORT)).toHaveText(locale === 'en' ? 'Short' : '平空'); + expect(await page.locator(`${PANEL} [data-side-selector] [role="radio"]`).evaluateAll(buttons => buttons.map(button => button.id))) + .toEqual([LONG.slice(1), SHORT.slice(1)]); + await expect(page.locator(LONG)).toHaveCSS('order', '0'); + await expect(page.locator(SHORT)).toHaveCSS('order', '1'); + await expect(page.locator(LONG)).toBeEnabled({ enabled: !display.longDisabled }); + await expect(page.locator(SHORT)).toBeEnabled({ enabled: !display.shortDisabled }); + await expect(page.locator('.order-entry').getByRole('button', { name: '平多', exact: true })) + .toBeEnabled({ enabled: !display.longDisabled }); + await expect(page.locator('.order-entry').getByRole('button', { name: '平空', exact: true })) + .toBeEnabled({ enabled: !display.shortDisabled }); + await expect(page.locator(display.selected === 'LONG' ? LONG : SHORT)).toHaveAttribute('aria-checked', 'true'); + await expect(page.locator('#jh-binance-trade-mode-hint')).toHaveAttribute('title', new RegExp(locale === 'en' ? display.enHint : display.zhHint)); + await expect(page.locator(FINAL_QUANTITY)).toHaveText('0.01'); + await expect(page.locator('#jh-binance-qty-multiplier-hint')).toHaveText(locale === 'en' ? 'Minimum close qty' : '最小平仓量的'); + await expectNoOrderActions(page); + expect(errors).toEqual([]); + }); + } +} + +for (const missing of ['precision', 'mode']) { + test(`user cannot edit numeric panel controls while native ${missing} is unknown`, async ({ page }) => { + // Given the complete panel has saved a multiplier of six for a valid native context. + const { errors } = await openUserscriptScenario(page, createCancelScenario()); + await page.locator(INPUT).fill('6'); + await page.locator(INPUT).blur(); + + // When the native DOM temporarily loses one required context value. + await page.evaluate(missing => { + if (missing === 'precision') document.querySelector('#futuresOrderbook .tick-content').textContent = ''; + else document.querySelectorAll('#position-direction [role="tab"]').forEach(tab => tab.setAttribute('aria-selected', 'false')); + window.__TM_CLOSE_LONG_DEBUG__.renderPanel(); + }, missing); + + // Then numeric edits are disabled and the specific missing context is explained without losing preferences. + await expect(page.locator(INPUT)).toBeDisabled(); + await expect(page.locator('#jh-binance-close-qty-multiplier-inc')).toBeDisabled(); + await expect(page.locator('#jh-binance-close-qty-multiplier-dec')).toBeDisabled(); + await expect(page.locator(FINAL_QUANTITY)).toHaveText(missing === 'precision' ? '等待价格精度' : '等待开仓/平仓状态'); + expect(await multiplierValue(page, 'OPEN')).toBe('6'); + + // When the same native context becomes available again. + await page.evaluate(missing => { + if (missing === 'precision') document.querySelector('#futuresOrderbook .tick-content').textContent = '0.1'; + else document.querySelector('#position-direction [data-trade-mode="OPEN"]').setAttribute('aria-selected', 'true'); + window.__TM_CLOSE_LONG_DEBUG__.renderPanel(); + }, missing); + + // Then the existing multiplier returns in the enabled field. + await expect(page.locator(INPUT)).toBeEnabled(); + await expect(page.locator(INPUT)).toHaveValue('6'); + await expect(page.locator(FINAL_QUANTITY)).toHaveText('0.42'); + await expectNoOrderActions(page); + expect(errors).toEqual([]); + }); +} + +test('user reacquires a replaced native form root while retaining the same panel and multiplier', async ({ page }) => { + // Given the panel has a saved value and observers are attached to the original form root. + const { errors } = await openUserscriptScenario(page, createCancelScenario()); + await page.locator(INPUT).fill('8'); + await page.locator(INPUT).blur(); + const originalForm = await page.locator('#trade-form').elementHandle(); + const originalPanel = await page.locator(PANEL).elementHandle(); + + // When React replaces the native form and its normal renderer rebinds the current mode. + await page.evaluate(symbol => { + const form = document.querySelector('#trade-form'); + form.replaceWith(form.cloneNode(true)); + window.__BINANCE_FIXTURE__.switchSymbol(symbol); + window.__TM_CLOSE_LONG_DEBUG__.renderPanel(); + }, CURRENT_SYMBOL); + + // Then the real input resolver follows the new form and the existing panel keeps its saved value. + expect(await originalForm.evaluate(element => element.isConnected)).toBe(false); + expect(await originalPanel.evaluate(element => element === document.querySelector('#jh-binance-close-qty-multiplier-panel'))).toBe(true); + await expect(page.locator(PANEL)).toHaveCount(1); + await expect(page.locator(INPUT)).toHaveValue('8'); + expect(await page.evaluate(() => window.__TM_CLOSE_LONG_DEBUG__.findQtyInput().id)).toBe('unitAmount-open'); + await expect(page.locator('#jh-binance-close-qty-multiplier-spacer')).toHaveCount(1); + await originalForm.dispose(); + await originalPanel.dispose(); + await expectNoOrderActions(page); + expect(errors).toEqual([]); +}); + +test('user removes the panel outside futures routes and restores saved settings with the new locale', async ({ page }) => { + // Given route lifecycle timing is controlled and the native panel has saved multiplier nine. + await installScenarioClock(page); + const { errors } = await openUserscriptScenario(page, createCancelScenario()); + await page.locator(INPUT).fill('9'); + await page.locator(INPUT).blur(); + await pauseScenarioClock(page); + + // When navigation leaves futures and the watchdog advances through two route checks. + await page.evaluate(() => history.pushState({}, '', '/zh-CN/markets')); + await page.clock.runFor(10_000); + + // Then trading UI remains removed while its scoped setting is retained. + await expect(page.locator(PANEL)).toHaveCount(0); + await expect(page.locator('#jh-binance-close-qty-multiplier-spacer')).toHaveCount(0); + expect(await multiplierValue(page, 'OPEN')).toBe('9'); + + // When the native SPA returns to the English futures route and foreground lifecycle resumes. + await page.evaluate(symbol => { + history.pushState({}, '', `/en/futures/${symbol}`); + document.dispatchEvent(new Event('visibilitychange')); + }, CURRENT_SYMBOL); + await page.clock.runFor(100); + + // Then a single English panel restores the current symbol's saved multiplier. + await expect(page.locator(PANEL)).toHaveCount(1); + await expect(page.locator(INPUT)).toHaveValue('9'); + await expect(page.locator('#jh-binance-qty-multiplier-hint')).toHaveText('Minimum open qty'); + await expect(page.locator(LONG)).toHaveText('Long'); + await expectNoOrderActions(page); + expect(errors).toEqual([]); +}); + +test('user keeps an unchanged panel free of DOM writes during repeated stable renders', async ({ page }) => { + // Given the generated panel and native precision options have finished their initial renders. + const { errors } = await openUserscriptScenario(page, createCancelScenario()); + await expect(page.locator('[data-orderbook-precision-value="0.01"]')).toBeEnabled(); + await expect(page.locator(FINAL_QUANTITY)).toHaveText('0.07'); + + // When the existing public diagnostic entry requests several renders without changing inputs. + const mutations = await page.evaluate(() => { + const panel = document.querySelector('#jh-binance-close-qty-multiplier-panel'); + const observer = new MutationObserver(() => {}); + observer.observe(panel, { attributes: true, childList: true, characterData: true, subtree: true }); + for (let index = 0; index < 3; index += 1) window.__TM_CLOSE_LONG_DEBUG__.renderPanel(); + const records = observer.takeRecords().map(record => ({ type: record.type, attribute: record.attributeName, target: record.target.id })); + observer.disconnect(); + return records; + }); + + // Then stable rendering does not rewrite panel text, controls, or styles. + expect(mutations).toEqual([]); + await expect(page.locator(PANEL)).toHaveCount(1); + await expectNoOrderActions(page); + expect(errors).toEqual([]); +}); + +/** Build native-looking quantity markup while retaining real layout and buttons. */ +async function mountQuantityLabels(page, { mode, combined }) { + return page.evaluate(({ mode, combined }) => { + const root = document.querySelector('.order-entry'); + root.querySelectorAll('[data-testid^="max-"]').forEach(element => element.remove()); + root.style.display = 'grid'; + root.style.gridTemplateColumns = 'repeat(2,minmax(0,1fr))'; + root.style.position = 'relative'; + root.style.columnGap = '4px'; + const buttons = [...root.querySelectorAll('button')]; + buttons.forEach((button, index) => { + button.style.gridRow = '2'; + button.style.gridColumn = String(index + 1); + button.style.width = '100%'; + }); + const label = mode === 'OPEN' ? '可开' : '可平'; + const malformed = document.createElement('small'); + malformed.textContent = `${label} unavailable`; + malformed.style.position = 'absolute'; + malformed.style.top = '0'; + root.append(malformed); + const above = document.createElement('small'); + above.textContent = `${label} 888 HYPE`; + above.style.position = 'absolute'; + above.style.top = '-400px'; + root.append(above); + const labels = []; + if (combined) { + const element = document.createElement('div'); + element.id = 'fixture-combined-quantity'; + element.textContent = `${label} 12 HYPE ${label} 34 HYPE`; + element.style.gridColumn = '1 / -1'; + element.style.gridRow = '3'; + root.append(element); + labels.push(element); + } else { + for (const [index, quantity] of ['12', '34'].entries()) { + const element = document.createElement('div'); + element.id = index === 0 ? 'fixture-long-quantity' : 'fixture-short-quantity'; + element.textContent = `${label} ${quantity} HYPE`; + element.style.gridColumn = String(index + 1); + element.style.gridRow = '3'; + root.append(element); + labels.push(element); + } + } + const below = document.createElement('small'); + below.textContent = `${label} 999 HYPE`; + below.style.position = 'absolute'; + below.style.top = '500px'; + root.append(below); + window.__TM_CLOSE_LONG_DEBUG__.renderPanel(); + return { + buttonCenters: buttons.map(button => { + const rect = button.getBoundingClientRect(); + return (rect.left + rect.right) / 2; + }), + labelCenters: labels.map(element => { + const rect = element.getBoundingClientRect(); + return (rect.left + rect.right) / 2; + }), + aboveDistance: above.getBoundingClientRect().top - buttons[0].getBoundingClientRect().bottom, + belowDistance: below.getBoundingClientRect().top - buttons[0].getBoundingClientRect().bottom, + }; + }, { mode, combined }); +} + +for (const combined of [false, true]) { + test(`user reads both open quantities from ${combined ? 'one shared label' : 'directional labels'} beside native buttons`, async ({ page }) => { + // Given native test ids are absent and actual layout places valid labels beside their buttons. + const { errors } = await openUserscriptScenario(page, createCancelScenario()); + const geometry = await mountQuantityLabels(page, { mode: 'OPEN', combined }); + expect(geometry.buttonCenters[0]).toBeLessThan(geometry.buttonCenters[1]); + expect(geometry.aboveDistance).toBeLessThan(-32); + expect(geometry.belowDistance).toBeGreaterThan(240); + if (combined) { + expect(geometry.buttonCenters[0]).toBeLessThan(geometry.labelCenters[0]); + expect(geometry.buttonCenters[1]).toBeGreaterThan(geometry.labelCenters[0]); + } + + // When the complete production planner reads each directional source without submitting it. + const quantities = await page.evaluate(async () => { + const long = await window.__TM_CLOSE_LONG_DEBUG__.buildLadderPlan('OPEN_LONG'); + const short = await window.__TM_CLOSE_LONG_DEBUG__.buildLadderPlan('OPEN_SHORT'); + return { long: long.baseQty, short: short.baseQty }; + }); + + // Then valid nearby labels own the quantities while malformed and far-away text is ignored. + expect(quantities).toEqual({ long: '12', short: '34' }); + await expectNoOrderActions(page); + expect(errors).toEqual([]); + }); +} + +test('user resolves close direction from real nearby quantities and reacts to a confirmed zero side', async ({ page }) => { + // Given native ids are absent while independently positioned labels confirm both close quantities. + const { errors } = await openUserscriptScenario(page, createCancelScenario({ + ui: { tradeMode: 'CLOSE' }, + positions: [{ symbol: CURRENT_SYMBOL, side: 'LONG', quantity: '12' }, { symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: '34' }], + })); + const geometry = await mountQuantityLabels(page, { mode: 'CLOSE', combined: false }); + expect(geometry.labelCenters).toEqual(geometry.buttonCenters); + expect(geometry.aboveDistance).toBeLessThan(-16); + expect(geometry.belowDistance).toBeGreaterThan(200); + + // When the user chooses the short direction and the production resolver reads current native text. + await page.locator(SHORT).click(); + const action = await page.evaluate(() => { + const result = window.__TM_CLOSE_LONG_DEBUG__.resolveTradeAction(); + return { mode: result.mode, side: result.side, by: result.by, longQty: result.longQty, shortQty: result.shortQty, qtySource: result.qtySource }; + }); + + // Then the chosen side and exact quantities come from the nearby native labels. + expect(action).toEqual({ mode: 'CLOSE', side: '平空', by: 'dual_panel', longQty: 12, shortQty: 34, qtySource: 'near_button' }); + + // When the native long-side label confirms that its position has reached zero. + await page.locator('#fixture-long-quantity').evaluate(element => { element.textContent = '可平 0 HYPE'; }); + await page.evaluate(() => window.__TM_CLOSE_LONG_DEBUG__.renderPanel()); + + // Then only the remaining short position stays actionable in both panel and native controls. + await expect(page.locator(LONG)).toBeDisabled(); + await expect(page.locator(SHORT)).toBeEnabled(); + await expect(page.locator(SHORT)).toHaveAttribute('aria-checked', 'true'); + await expect(page.locator('.order-entry').getByRole('button', { name: '平多', exact: true })).toBeDisabled(); + await expect(page.locator('#jh-binance-trade-mode-hint')).toHaveAttribute('title', /当前仅有空仓/); + await expectNoOrderActions(page); + expect(errors).toEqual([]); +}); + +test('user keeps cached close display while refusing execution until both native quantities return', async ({ page }) => { + // Given the current close display has confirmed long and short position quantities. + const { errors } = await openUserscriptScenario(page, createCancelScenario({ + ui: { tradeMode: 'CLOSE' }, + positions: [{ symbol: CURRENT_SYMBOL, side: 'LONG', quantity: '12' }, { symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: '34' }], + })); + await expect(page.locator(LONG)).toBeEnabled(); + await expect(page.locator(SHORT)).toBeEnabled(); + + // When a native rerender temporarily removes all quantity evidence. + const unresolved = await page.evaluate(() => { + document.querySelectorAll('.order-entry [data-testid^="max-"]').forEach(element => element.remove()); + window.__TM_CLOSE_LONG_DEBUG__.renderPanel(); + const display = window.__TM_CLOSE_LONG_DEBUG__.displayCloseState; + return { + action: window.__TM_CLOSE_LONG_DEBUG__.resolveTradeAction(), + display: { longQty: display.longQty, shortQty: display.shortQty, isPending: display.isPending, isUsingCache: display.isUsingCache }, + }; + }); + + // Then the last confirmed display remains visible but missing current evidence cannot choose an executable action. + expect(unresolved).toEqual({ action: null, display: { longQty: 12, shortQty: 34, isPending: true, isUsingCache: true } }); + await expect(page.locator('#jh-binance-trade-mode-hint')).toHaveAttribute('title', /暂沿用上次识别结果/); + + // When native quantity fields return with a smaller long position and no short position. + await page.evaluate(() => { + document.querySelector('.order-entry').insertAdjacentHTML('beforeend', '
可平 3 HYPE
可平 0 HYPE
'); + window.__TM_CLOSE_LONG_DEBUG__.renderPanel(); + }); + + // Then the fresh snapshot replaces cached display and enables only closing the long position. + await expect(page.locator(LONG)).toBeEnabled(); + await expect(page.locator(SHORT)).toBeDisabled(); + const recovered = await page.evaluate(() => { + const result = window.__TM_CLOSE_LONG_DEBUG__.resolveTradeAction(); + return { side: result.side, longQty: result.longQty, shortQty: result.shortQty, qtySource: result.qtySource }; + }); + expect(recovered).toEqual({ side: '平多', longQty: 3, shortQty: 0, qtySource: 'testid' }); + await expectNoOrderActions(page); + expect(errors).toEqual([]); +}); + +test('user cannot resolve a close action when native buttons and quantity labels are absent', async ({ page }) => { + // Given a close form previously confirmed both position directions. + const { errors } = await openUserscriptScenario(page, createCancelScenario({ + ui: { tradeMode: 'CLOSE' }, + positions: [{ symbol: CURRENT_SYMBOL, side: 'LONG', quantity: '12' }, { symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: '34' }], + })); + + // When native descendants temporarily disappear while the form owner remains connected. + const action = await page.evaluate(() => { + document.querySelectorAll('.order-entry button, .order-entry [data-testid^="max-"]').forEach(element => element.remove()); + window.__TM_CLOSE_LONG_DEBUG__.renderPanel(); + return window.__TM_CLOSE_LONG_DEBUG__.resolveTradeAction(); + }); + + // Then no absent native button or missing quantity is treated as an executable close direction. + expect(action).toBe(null); + await expect(page.locator('#jh-binance-trade-mode-hint')).toHaveAttribute('title', /正在确认可平仓位/); + await expectNoOrderActions(page); + expect(errors).toEqual([]); +}); + +for (const contextChange of ['mode', 'precision']) { + for (const eventType of ['input', 'blur']) { + test(`user discards an old multiplier ${eventType} after native ${contextChange} changes`, async ({ page }) => { + // Given an active multiplier edit belongs to open mode at precision 0.1. + const { errors } = await openUserscriptScenario(page, createCancelScenario()); + await page.locator(INPUT).fill('5'); + + // When native context changes without moving focus and the old edit then delivers its event. + if (contextChange === 'mode') { + await page.locator('#position-direction [data-trade-mode="CLOSE"]').evaluate(element => element.click()); + await expect(page.locator('#position-direction [data-trade-mode="CLOSE"]')).toHaveAttribute('aria-selected', 'true'); + } else { + await page.evaluate(symbol => window.__BINANCE_FIXTURE__.replacePrecisionControl({ + scope: 'select', symbol, value: '0.01', options: ['0.001', '0.01', '0.1', '1'], + }), CURRENT_SYMBOL); + } + await page.locator(INPUT).evaluate((input, eventType) => { + input.value = '99'; + if (eventType === 'blur') input.blur(); + else input.dispatchEvent(new InputEvent('input', { bubbles: true, data: '99' })); + }, eventType); + + // Then the stale event preserves the original setting and cannot save into the replacement scope. + await expect(page.locator(INPUT)).toHaveValue('1'); + expect(await multiplierValue(page, 'OPEN')).toBe('5'); + expect(await multiplierValue(page, contextChange === 'mode' ? 'CLOSE' : 'OPEN', CURRENT_SYMBOL, contextChange === 'precision' ? '0.01' : '0.1')).toBe(null); + await expectNoOrderActions(page); + expect(errors).toEqual([]); + }); + } +} + +test('user keeps the only enabled close direction when pressing navigation keys', async ({ page }) => { + // Given only the current symbol's short position is available to close. + const { errors } = await openUserscriptScenario(page, createCancelScenario({ + ui: { tradeMode: 'CLOSE' }, positions: [{ symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: '4' }], + })); + const short = page.locator(SHORT); + await short.focus(); + + // When arrow navigation has no other enabled direction to select. + await short.press('ArrowRight'); + await short.press('ArrowLeft'); + + // Then focus and selection remain on the only valid close direction without writing a preference. + await expect(short).toBeFocused(); + await expect(short).toHaveAttribute('aria-checked', 'true'); + await expect(page.locator(LONG)).toBeDisabled(); + expect(await page.evaluate(() => localStorage.getItem('jh_binance_close_side:HYPEUSDT'))).toBe(null); + await expectNoOrderActions(page); + expect(errors).toEqual([]); +}); + +test('user refreshes the current multiplier when another browser context sends a matching storage event', async ({ page }) => { + // Given the current panel is not being edited and starts with its default multiplier. + const { errors } = await openUserscriptScenario(page, createCancelScenario()); + await expect(page.locator(INPUT)).toHaveValue('1'); + + // When the browser delivers a current-scope storage update followed by an unrelated key. + await page.evaluate(() => { + const key = 'jh_binance_qty_multiplier_v2:OPEN:HYPEUSDT:0.1'; + localStorage.setItem(key, '8'); + window.dispatchEvent(new StorageEvent('storage', { key, newValue: '8', oldValue: null })); + window.dispatchEvent(new StorageEvent('storage', { key: 'unrelated-panel-setting', newValue: '99' })); + }); + + // Then only the matching preference updates the visible quantity calculation. + await expect(page.locator(INPUT)).toHaveValue('8'); + await expect(page.locator(FINAL_QUANTITY)).toHaveText('0.56'); + expect(await multiplierValue(page, 'OPEN')).toBe('8'); + await expectNoOrderActions(page); + expect(errors).toEqual([]); +}); + +test('user opens a retired close percentage profile with its supported replacement selected', async ({ page }) => { + // Given the current and another symbol still have the retired saved close percentage + await page.addInitScript(({ symbol, otherSymbol }) => { + localStorage.setItem(`jh_binance_ladder_close_percent:${symbol}:0.1`, '100'); + localStorage.setItem(`jh_binance_ladder_close_percent:${otherSymbol}:0.1`, '100'); + }, { symbol: CURRENT_SYMBOL, otherSymbol: OTHER_SYMBOL }); + + // When the complete userscript initializes the current symbol's native close panel + const { errors } = await openUserscriptScenario(page, createCancelScenario({ + ui: { tradeMode: 'CLOSE' }, positions: [{ symbol: CURRENT_SYMBOL, side: 'LONG', quantity: '4' }], + })); + + // Then only the current profile migrates and its supported choice is selected without a migration status message + const saved = await page.evaluate(({ symbol, otherSymbol }) => ({ + current: localStorage.getItem(`jh_binance_ladder_close_percent:${symbol}:0.1`), + other: localStorage.getItem(`jh_binance_ladder_close_percent:${otherSymbol}:0.1`), + }), { symbol: CURRENT_SYMBOL, otherSymbol: OTHER_SYMBOL }); + expect(saved).toEqual({ current: '0.3', other: '100' }); + await expect(page.locator('[data-ladder-group="percent"]')).toHaveText(['0.3%', '1%', '5%', '10%', '30%']); + expect(await page.locator('[data-ladder-group="percent"][data-ladder-value="0.3"]') + .evaluate(button => button.style.borderColor)).toBe('var(--color-PrimaryYellow)'); + await expect(page.locator('#jh-binance-ladder-status')).not.toContainText('平仓量 100% 已调整为'); + await expectNoOrderActions(page); + expect(errors).toEqual([]); +}); diff --git a/e2e/binance-orderbook/specs/precision-bootstrap-behavior.pw.js b/e2e/binance-orderbook/specs/precision-bootstrap-behavior.pw.js new file mode 100644 index 0000000..ba9dd08 --- /dev/null +++ b/e2e/binance-orderbook/specs/precision-bootstrap-behavior.pw.js @@ -0,0 +1,328 @@ +import { test, expect } from '../test.js'; +import { + CURRENT_SYMBOL, + OTHER_SYMBOL, + createCancelScenario, +} from '../scenarios/cancel-current-symbol.js'; +import { openUserscriptScenario, readFixtureState } from '../helpers/userscript-page.js'; +import { installScenarioClock, pauseScenarioClock } from '../helpers/scenario-clock.js'; + +const PANEL = '#jh-binance-close-qty-multiplier-panel'; +const NATIVE_ROOT = '#futuresOrderbook .orderbook-tickSize'; +const SHORTCUTS = `${PANEL} [data-orderbook-precision-value]`; +const PRECISION_STATUS = `${PANEL} [data-orderbook-precision-status]`; +const REFRESH = `${PANEL} [data-orderbook-precision-refresh]`; +const OTHER_OPTIONS = ['1', '10', '100', '1000']; + +/** Record native clicks and rendered option sets without changing either controller. */ +function installBootstrapObservation() { + const clicks = []; + const panels = []; + const readSymbol = () => location.pathname.split('/').at(-1); + const onClick = (event) => { + if (!(event.target instanceof Element)) return; + if (!event.target.closest('#futuresOrderbook .orderbook-tickSize .bn-select-trigger')) return; + clicks.push({ + symbol: readSymbol(), + precision: document.querySelector('#futuresOrderbook .tick-content').textContent, + }); + }; + let lastPanel = ''; + const observer = new MutationObserver(() => { + const panel = { + symbol: readSymbol(), + options: Array.from(document.querySelectorAll( + '#jh-binance-close-qty-multiplier-panel [data-orderbook-precision-value]', + ), (node) => node.dataset.orderbookPrecisionValue), + }; + const signature = JSON.stringify(panel); + if (signature === lastPanel) return; + lastPanel = signature; + panels.push(panel); + }); + document.addEventListener('click', onClick, true); + observer.observe(document.documentElement, { childList: true, subtree: true }); + window.__PRECISION_BOOTSTRAP_OBSERVATION__ = { clicks, panels, observer, onClick }; +} + +test.afterEach(async ({ page }, testInfo) => { + const observation = await page.evaluate(() => { + const probe = window.__PRECISION_BOOTSTRAP_OBSERVATION__; + if (!probe) return { clicks: [], panels: [] }; + probe.observer.disconnect(); + document.removeEventListener('click', probe.onClick, true); + delete window.__PRECISION_BOOTSTRAP_OBSERVATION__; + const latePortal = window.__PRECISION_BOOTSTRAP_LATE_PORTAL__; + if (latePortal) { + latePortal.bubble.remove(); + delete window.__PRECISION_BOOTSTRAP_LATE_PORTAL__; + } + return { clicks: probe.clicks, panels: probe.panels }; + }); + if (testInfo.status !== testInfo.expectedStatus) { + await testInfo.attach('precision-bootstrap-observation.json', { + body: Buffer.from(JSON.stringify(observation, null, 2)), + contentType: 'application/json', + }); + } +}); + +async function expectPrecisionOptions(page, options, current) { + await expect.poll(() => page.locator(SHORTCUTS).evaluateAll((nodes) => ( + nodes.map((node) => node.dataset.orderbookPrecisionValue) + ))).toEqual(options); + await expect(page.locator(`${PANEL} [data-orderbook-precision-value="${current}"]`)) + .toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator(SHORTCUTS).first()).toBeEnabled(); +} + +async function openReadyPrecision(page) { + await installScenarioClock(page); + const scenario = createCancelScenario(); + const host = await openUserscriptScenario(page, scenario, { + beforeOrderbook: `(${installBootstrapObservation.toString()})();`, + }); + await expectPrecisionOptions(page, scenario.host.precisionOptions, scenario.ui.orderbookPrecision); + await pauseScenarioClock(page); + return { ...host, scenario }; +} + +async function readNativeClicks(page, symbol) { + return page.evaluate((symbol) => window.__PRECISION_BOOTSTRAP_OBSERVATION__.clicks + .filter((entry) => entry.symbol === symbol), symbol); +} + +async function expectNoSelectionOrFinancialAction(page) { + const state = await readFixtureState(page); + expect(state.events.filter(({ type }) => [ + 'precision-selected', 'order-submitted', 'cancel-requested', 'row-cancel-requested', + ].includes(type))).toEqual([]); +} + +for (const unavailable of ['bid quotes', 'ask quotes', 'precision field']) { + test(`user waits for ${unavailable} before reading a new symbol's precision menu`, async ({ page }) => { + // Given the original symbol has finished reading its own native precision options. + const host = await openReadyPrecision(page); + + // When the host switches symbol before one required field or book side is ready. + const replacement = await page.evaluate(({ unavailable, symbol, options }) => { + const replacement = window.__BINANCE_FIXTURE__.replacePrecisionControl({ + scope: 'root', symbol, value: '10', options, + }); + if (unavailable === 'precision field') { + document.querySelector('#futuresOrderbook .tick-content').textContent = ''; + } else { + const side = unavailable === 'bid quotes' ? 'bid' : 'ask'; + document.querySelectorAll(`#futuresOrderbook .${side}-light`).forEach((node) => { + node.closest('.row-content').style.display = 'none'; + }); + } + return replacement; + }, { unavailable, symbol: OTHER_SYMBOL, options: OTHER_OPTIONS }); + if (unavailable !== 'precision field') { + const side = unavailable === 'bid quotes' ? 'bid' : 'ask'; + expect(await page.locator(`#futuresOrderbook .${side}-light`).evaluateAll((nodes) => ( + nodes.map((node) => node.getClientRects().length) + ))).toEqual([0, 0, 0, 0, 0, 0]); + } + await page.clock.runFor(1000); + + // Then bootstrap exposes a waiting state and never toggles the new symbol's native menu. + await expect(page).toHaveURL(`https://www.binance.com/zh-CN/futures/${OTHER_SYMBOL}`); + await expect(page.locator(SHORTCUTS)).toHaveCount(0); + await expect(page.locator(PRECISION_STATUS)).toHaveText( + unavailable === 'precision field' ? '等待精度档位' : '读取精度档位', + ); + expect(await readNativeClicks(page, OTHER_SYMBOL)).toEqual([]); + expect((await readFixtureState(page)).events.filter(({ type, symbol }) => ( + type === 'precision-overlay-opened' && symbol === OTHER_SYMBOL + ))).toEqual([]); + await expectNoSelectionOrFinancialAction(page); + + // When the missing field or quote side becomes available within the bootstrap deadline. + await page.evaluate((unavailable) => { + if (unavailable === 'precision field') { + document.querySelector('#futuresOrderbook .tick-content').textContent = '10'; + } else { + const side = unavailable === 'bid quotes' ? 'bid' : 'ask'; + document.querySelectorAll(`#futuresOrderbook .${side}-light`).forEach((node) => { + node.closest('.row-content').style.removeProperty('display'); + }); + } + }, unavailable); + await page.clock.runFor(250); + + // Then bootstrap reads the new owned portal once and closes it without selecting a precision. + await expectPrecisionOptions(page, OTHER_OPTIONS, '10'); + await expect(page.locator('.bn-select-bubble')).toHaveCount(0); + expect(await readNativeClicks(page, OTHER_SYMBOL)).toEqual([ + { symbol: OTHER_SYMBOL, precision: '10' }, + { symbol: OTHER_SYMBOL, precision: '10' }, + ]); + expect((await readFixtureState(page)).events + .filter(({ type, symbol }) => symbol === OTHER_SYMBOL && [ + 'precision-overlay-opened', 'precision-overlay-closed', + ].includes(type)) + .map(({ type, listboxId }) => ({ type, listboxId }))) + .toEqual([ + { type: 'precision-overlay-opened', listboxId: replacement.listboxId }, + { type: 'precision-overlay-closed', listboxId: replacement.listboxId }, + ]); + await expectNoSelectionOrFinancialAction(page); + expect(host.errors).toEqual([]); + }); +} + +for (const menuState of ['missing', 'malformed']) { + test(`user recovers a ${menuState} precision menu only by refreshing after the failed automatic attempt`, async ({ page }) => { + // Given the original symbol's precision menu is healthy and its automatic bootstrap has completed. + const host = await openReadyPrecision(page); + + // When the next symbol has either an unbound menu trigger or a malformed owned portal. + await page.evaluate(({ menuState, symbol, options }) => { + window.__BINANCE_FIXTURE__.replacePrecisionControl({ + scope: 'root', symbol, value: '10', options, + }); + const trigger = document.querySelector('#futuresOrderbook .bn-select-trigger'); + if (menuState === 'missing') { + trigger.replaceWith(trigger.cloneNode(true)); + } else { + trigger.addEventListener('click', () => { + const listbox = document.querySelector('.bn-select-bubble [role="listbox"]'); + listbox.parentElement.classList.remove('bn-select-overlay'); + }, { once: true }); + } + }, { menuState, symbol: OTHER_SYMBOL, options: OTHER_OPTIONS }); + await page.clock.runFor(8000); + + // Then the bounded bootstrap reports failure without retaining old-symbol shortcuts or changing precision. + await expect(page.locator(PRECISION_STATUS)).toHaveText('档位读取失败,请刷新'); + await expect(page.locator(SHORTCUTS)).toHaveCount(0); + await expect(page.locator(REFRESH)).toBeEnabled(); + const failedClicks = await readNativeClicks(page, OTHER_SYMBOL); + expect(failedClicks).toEqual(Array.from({ length: menuState === 'missing' ? 3 : 2 }, () => ({ + symbol: OTHER_SYMBOL, precision: '10', + }))); + expect((await readFixtureState(page)).orderbookPrecision).toBe('10'); + await expectNoSelectionOrFinancialAction(page); + + // When the first five-second route watchdog runs after the failure. + await page.clock.runFor(5000); + + // Then that watchdog does not start another automatic menu attempt. + expect(await readNativeClicks(page, OTHER_SYMBOL)).toEqual(failedClicks); + await expect(page.locator(PRECISION_STATUS)).toHaveText('档位读取失败,请刷新'); + + // When a second five-second watchdog runs with the same failed symbol. + await page.clock.runFor(5000); + + // Then the failed attempt remains final until the user explicitly asks for another read. + expect(await readNativeClicks(page, OTHER_SYMBOL)).toEqual(failedClicks); + await expect(page.locator(SHORTCUTS)).toHaveCount(0); + + // When the host repairs its native Select and another watchdog runs before any user refresh. + const repaired = await page.evaluate(({ symbol, options }) => ( + window.__BINANCE_FIXTURE__.replacePrecisionControl({ + scope: 'select', symbol, value: '10', options, + }) + ), { symbol: OTHER_SYMBOL, options: OTHER_OPTIONS }); + await page.clock.runFor(5000); + + // Then host repair alone does not bypass the symbol's one automatic-attempt contract. + expect(await readNativeClicks(page, OTHER_SYMBOL)).toEqual(failedClicks); + await expect(page.locator(SHORTCUTS)).toHaveCount(0); + await expect(page.locator(PRECISION_STATUS)).toHaveText('档位读取失败,请刷新'); + + // When the user presses the real precision refresh button against the repaired host. + await page.locator(REFRESH).click(); + await page.clock.runFor(250); + + // Then one forced read installs the new symbol's exact options and closes only its repaired portal. + await expectPrecisionOptions(page, OTHER_OPTIONS, '10'); + await expect(page.locator('.bn-select-bubble')).toHaveCount(0); + expect(await readNativeClicks(page, OTHER_SYMBOL)).toEqual([ + ...failedClicks, + { symbol: OTHER_SYMBOL, precision: '10' }, + { symbol: OTHER_SYMBOL, precision: '10' }, + ]); + expect((await readFixtureState(page)).events + .filter(({ type, listboxId }) => listboxId === repaired.listboxId && [ + 'precision-overlay-opened', 'precision-overlay-closed', + ].includes(type)) + .map(({ type, symbol }) => ({ type, symbol }))) + .toEqual([ + { type: 'precision-overlay-opened', symbol: OTHER_SYMBOL }, + { type: 'precision-overlay-closed', symbol: OTHER_SYMBOL }, + ]); + await expectNoSelectionOrFinancialAction(page); + expect(host.errors).toEqual([]); + }); +} + +test('user keeps current-symbol shortcuts when the previous symbol portal arrives after a pending read', async ({ page }) => { + // Given the first symbol is ready before the other symbol opens a portal whose options have not mounted. + const host = await openReadyPrecision(page); + const pending = await page.evaluate(({ symbol, options }) => { + const replacement = window.__BINANCE_FIXTURE__.replacePrecisionControl({ + scope: 'root', symbol, value: '10', options, + }); + const trigger = document.querySelector('#futuresOrderbook .bn-select-trigger'); + trigger.addEventListener('click', () => { + const listbox = document.querySelector('.bn-select-bubble [role="listbox"]'); + const options = Array.from(listbox.children); + window.__PRECISION_BOOTSTRAP_LATE_PORTAL__ = { + bubble: listbox.closest('.bn-select-bubble'), listbox, options, + }; + options.forEach((option) => option.remove()); + }, { once: true }); + return replacement; + }, { symbol: OTHER_SYMBOL, options: OTHER_OPTIONS }); + await page.clock.runFor(100); + await expect(page.getByRole('listbox')).toHaveAttribute('id', pending.listboxId); + await expect(page.getByRole('option')).toHaveCount(0); + await expect(page.locator(PRECISION_STATUS)).toHaveText('读取精度档位'); + expect(await readNativeClicks(page, OTHER_SYMBOL)).toEqual([ + { symbol: OTHER_SYMBOL, precision: '10' }, + ]); + + // When the user returns to the first symbol and the detached old-symbol portal publishes its late options. + const restored = await page.evaluate(({ symbol, value, options }) => { + const panelStart = window.__PRECISION_BOOTSTRAP_OBSERVATION__.panels.length; + const replacement = window.__BINANCE_FIXTURE__.replacePrecisionControl({ + scope: 'root', symbol, value, options, + }); + const latePortal = window.__PRECISION_BOOTSTRAP_LATE_PORTAL__; + latePortal.listbox.append(...latePortal.options); + document.body.append(latePortal.bubble); + return { ...replacement, panelStart }; + }, { + symbol: CURRENT_SYMBOL, + value: host.scenario.ui.orderbookPrecision, + options: host.scenario.host.precisionOptions, + }); + await page.clock.runFor(1500); + + // Then the stale portal remains separate while the current symbol installs only its own native options. + await expect(page).toHaveURL(`https://www.binance.com/zh-CN/futures/${CURRENT_SYMBOL}`); + await expectPrecisionOptions(page, host.scenario.host.precisionOptions, host.scenario.ui.orderbookPrecision); + const lateOptions = page.locator(`[id="${pending.listboxId}"]`).getByRole('option'); + await expect(lateOptions).toHaveText(OTHER_OPTIONS); + await expect(page.locator(`[id="${pending.listboxId}"]`)).toBeVisible(); + await expect(page.locator(`[id="${restored.listboxId}"]`)).toHaveCount(0); + expect(await page.evaluate(({ symbol, panelStart }) => ( + window.__PRECISION_BOOTSTRAP_OBSERVATION__.panels.slice(panelStart) + .filter((panel) => panel.symbol === symbol && panel.options.length > 0) + ), { symbol: CURRENT_SYMBOL, panelStart: restored.panelStart })) + .toEqual([{ symbol: CURRENT_SYMBOL, options: host.scenario.host.precisionOptions }]); + expect((await readFixtureState(page)).events + .filter(({ type, listboxId }) => listboxId === restored.listboxId && [ + 'precision-overlay-opened', 'precision-overlay-closed', + ].includes(type)) + .map(({ type, symbol }) => ({ type, symbol }))) + .toEqual([ + { type: 'precision-overlay-opened', symbol: CURRENT_SYMBOL }, + { type: 'precision-overlay-closed', symbol: CURRENT_SYMBOL }, + ]); + await expectNoSelectionOrFinancialAction(page); + expect(host.errors).toEqual([]); +}); diff --git a/e2e/binance-orderbook/specs/precision-selection-boundaries.pw.js b/e2e/binance-orderbook/specs/precision-selection-boundaries.pw.js new file mode 100644 index 0000000..5a9561b --- /dev/null +++ b/e2e/binance-orderbook/specs/precision-selection-boundaries.pw.js @@ -0,0 +1,237 @@ +import { test, expect } from '../test.js'; +import { CURRENT_SYMBOL, OTHER_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 { installNativePrecisionSelectionHost } from '../../../test/helpers/native-precision-selection-host.js'; + +const SHORTCUT = '[data-orderbook-precision-value="0.01"]'; +const CURRENT = '#futuresOrderbook .tick-content'; +const TRIGGER = '#futuresOrderbook .bn-select-trigger'; +const OPTIONS = ['0.001', '0.01', '0.1', '1']; + +async function openReady(page, { hold = false } = {}) { + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario(), { + beforeOrderbook: hold + ? `window.__NATIVE_PRECISION_SELECTION__ = (${installNativePrecisionSelectionHost.toString()})();` + : '', + }); + await expect(page.locator(SHORTCUT)).toBeEnabled(); + await pauseScenarioClock(page); + return host; +} + +async function selections(page) { + return (await readFixtureState(page)).events + .filter(event => event.type === 'precision-selected') + .map(({ symbol, value }) => ({ symbol, value })); +} + +async function expectNoFinancialActions(page) { + expect((await readFixtureState(page)).events.filter(event => [ + 'order-submitted', 'cancel-requested', 'row-cancel-requested', + ].includes(event.type))).toEqual([]); +} + +test.afterEach(async ({ page }) => { + await page.evaluate(() => { + if (!window.__NATIVE_PRECISION_SELECTION__) return; + window.__NATIVE_PRECISION_SELECTION__.dispose(); + delete window.__NATIVE_PRECISION_SELECTION__; + }); +}); + +test('user selecting the current precision keeps the native dropdown closed without another selection', async ({ page }) => { + // Given the native precision and its enabled shortcut already agree. + const host = await openReady(page); + const before = (await readFixtureState(page)).events.filter(event => event.type.startsWith('precision-')); + + // When the user clicks the already selected shortcut. + await page.locator('[data-orderbook-precision-value="0.1"]').click(); + await page.clock.runFor(32); + + // Then the native control and event ledger remain unchanged with no dropdown or order action. + await expect(page.locator(CURRENT)).toHaveText('0.1'); + await expect(page.locator('.bn-select-bubble')).toHaveCount(0); + expect((await readFixtureState(page)).events.filter(event => event.type.startsWith('precision-'))).toEqual(before); + await expectNoFinancialActions(page); + expect(host.errors).toEqual([]); +}); + +test('user applies a precision shortcut through a native dropdown that is already open', async ({ page }) => { + // Given the user has opened the native precision menu before using the shortcut. + const host = await openReady(page); + await page.locator(TRIGGER).click(); + await expect(page.getByRole('listbox')).toBeVisible(); + + // When the shortcut selects its existing native option. + await page.locator(SHORTCUT).click(); + await page.clock.runFor(32); + + // Then one exact selection closes the existing menu without toggling another portal. + await expect(page.locator(CURRENT)).toHaveText('0.01'); + await expect(page.locator('.bn-select-bubble')).toHaveCount(0); + expect(await selections(page)).toEqual([{ symbol: CURRENT_SYMBOL, value: '0.01' }]); + expect((await readFixtureState(page)).events.filter(event => event.type === 'precision-overlay-opened')).toHaveLength(2); + await expectNoFinancialActions(page); + expect(host.errors).toEqual([]); +}); + +for (const missing of ['current option', 'requested option']) { + test(`user keeps the native precision when a previously cached menu loses its ${missing}`, async ({ page }) => { + // Given the panel has cached its shortcuts before the native menu changes its available options. + const host = await openReady(page); + await page.locator(TRIGGER).click(); + const value = missing === 'current option' ? '0.1' : '0.01'; + await page.locator(`.bn-select-bubble [data-precision-value="${value}"]`).evaluate(option => option.remove()); + + // When the user tries the cached shortcut against that current native menu. + await page.locator(SHORTCUT).click(); + await page.clock.runFor(32); + + // Then no native selection is dispatched and the original precision remains authoritative. + await expect(page.locator(CURRENT)).toHaveText('0.1'); + expect(await selections(page)).toEqual([]); + const shortcuts = page.locator('[data-orderbook-precision-value]'); + expect(await shortcuts.evaluateAll(nodes => nodes.map(node => node.dataset.orderbookPrecisionValue))) + .toEqual(missing === 'current option' ? OPTIONS : OPTIONS.filter(option => option !== '0.01')); + await expect(shortcuts.first()).toBeEnabled(); + await expectNoFinancialActions(page); + expect(host.errors).toEqual([]); + }); +} + +for (const pendingField of ['unchanged', 'temporarily empty']) { + test(`user waits for a native precision commit while its field is ${pendingField}`, async ({ page }) => { + // Given the native Select accepts a click but holds its committed value. + const host = await openReady(page, { hold: true }); + + // When the user requests another precision and the native field has not committed it. + await page.locator(SHORTCUT).click(); + await page.clock.runFor(32); + if (pendingField === 'temporarily empty') await page.locator(CURRENT).evaluate(node => { node.textContent = ''; }); + await page.clock.runFor(500); + + // Then the precision controls remain busy without an unconfirmed selection being counted. + await expect(page.locator(SHORTCUT)).toBeDisabled(); + await expect(page.locator(CURRENT)).toHaveText(pendingField === 'unchanged' ? '0.1' : ''); + expect(await page.evaluate(() => window.__NATIVE_PRECISION_SELECTION__.snapshot().map(({ value }) => value))) + .toEqual(['0.01']); + expect(await selections(page)).toEqual([]); + + // When the native Select commits the held click within its observed deadline. + await page.evaluate(() => window.__NATIVE_PRECISION_SELECTION__.commit()); + await page.clock.runFor(100); + + // Then the field and enabled shortcut agree on one exact confirmed value. + await expect(page.locator(CURRENT)).toHaveText('0.01'); + await expect(page.locator(SHORTCUT)).toBeEnabled(); + await expect(page.locator(SHORTCUT)).toHaveAttribute('aria-pressed', 'true'); + expect(await selections(page)).toEqual([{ symbol: CURRENT_SYMBOL, value: '0.01' }]); + await expectNoFinancialActions(page); + expect(host.errors).toEqual([]); + }); +} + +test('user can retry precision selection only after an uncommitted native request reaches its full deadline', async ({ page }) => { + // Given the real precision selection is waiting for the native Select's commit. + const host = await openReady(page, { hold: true }); + await page.locator(SHORTCUT).click(); + await page.clock.runFor(32); + + // When the clock reaches one millisecond before the native confirmation deadline. + const remaining = await page.evaluate(() => window.__NATIVE_PRECISION_SELECTION__.snapshot()[0].at + 1199 - Date.now()); + expect(remaining).toBeGreaterThan(0); + await page.clock.runFor(remaining); + + // Then the unconfirmed request still owns disabled controls and cannot be reported as selected. + await expect(page.locator(SHORTCUT)).toBeDisabled(); + expect(await selections(page)).toEqual([]); + + // When the deadline expires and its queued render completes. + await page.clock.runFor(17); + + // Then controls become available without changing or automatically retrying the native precision. + await expect(page.locator(SHORTCUT)).toBeEnabled(); + await expect(page.locator(CURRENT)).toHaveText('0.1'); + expect(await page.evaluate(() => window.__NATIVE_PRECISION_SELECTION__.snapshot().map(({ value }) => value))) + .toEqual(['0.01']); + expect(await selections(page)).toEqual([]); + await expectNoFinancialActions(page); + expect(host.errors).toEqual([]); +}); + +for (const transition of ['another precision', 'another symbol']) { + test(`user abandons a pending precision selection when the native control changes to ${transition}`, async ({ page }) => { + // Given an old native precision option has been requested but is not committed. + const host = await openReady(page, { hold: true }); + await page.locator(SHORTCUT).click(); + await page.clock.runFor(32); + const symbol = transition === 'another symbol' ? OTHER_SYMBOL : CURRENT_SYMBOL; + + // When the native host replaces that control with a separately committed context. + await page.evaluate(({ symbol, options }) => window.__BINANCE_FIXTURE__.replacePrecisionControl({ + scope: 'root', symbol, value: '1', options, + }), { symbol, options: OPTIONS }); + await page.clock.runFor(250); + + // Then the replacement context remains authoritative and the old request is never replayed. + await expect(page.locator(CURRENT)).toHaveText('1'); + await expect(page.locator('[data-orderbook-precision-value="1"]')).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('[data-orderbook-precision-value="1"]')).toBeEnabled(); + await expect(page).toHaveURL(`https://www.binance.com/zh-CN/futures/${symbol}`); + expect(await selections(page)).toEqual([]); + expect(await page.evaluate(() => window.__NATIVE_PRECISION_SELECTION__.snapshot().map(({ value }) => value))) + .toEqual(['0.01']); + await expectNoFinancialActions(page); + expect(host.errors).toEqual([]); + }); +} + +test('user retains the replacement precision control when the previous native trigger detaches before opening', async ({ page }) => { + // Given native precision shortcuts are ready before a host replacement is queued. + const host = await openReady(page); + + // When a real shortcut click and native trigger replacement occur before the queued menu open. + await page.evaluate(options => { + document.querySelector('[data-orderbook-precision-value="0.01"]').click(); + window.__BINANCE_FIXTURE__.replacePrecisionControl({ scope: 'select', symbol: 'HYPEUSDT', value: '0.1', options }); + }, OPTIONS); + await page.clock.runFor(100); + + // Then the detached trigger cannot open a portal or select a value on the replacement. + await expect(page.locator(CURRENT)).toHaveText('0.1'); + await expect(page.locator('.bn-select-bubble')).toHaveCount(0); + await expect(page.locator(SHORTCUT)).toBeEnabled(); + expect(await selections(page)).toEqual([]); + + // When the user explicitly selects again using the replacement control. + await page.locator(SHORTCUT).click(); + await page.clock.runFor(100); + + // Then one selection reaches that current native owner. + await expect(page.locator(CURRENT)).toHaveText('0.01'); + expect(await selections(page)).toEqual([{ symbol: CURRENT_SYMBOL, value: '0.01' }]); + await expectNoFinancialActions(page); + expect(host.errors).toEqual([]); +}); + +test('user starts only one native selection when two precision shortcut events arrive before the busy render', async ({ page }) => { + // Given two enabled precision shortcuts share one idle selection controller. + const host = await openReady(page); + + // When both native click events arrive in the same host turn. + await page.evaluate(() => { + document.querySelector('[data-orderbook-precision-value="0.01"]').click(); + document.querySelector('[data-orderbook-precision-value="1"]').click(); + }); + await page.clock.runFor(100); + + // Then only the first selection commits and both shortcuts recover after that one task. + await expect(page.locator(CURRENT)).toHaveText('0.01'); + await expect(page.locator(SHORTCUT)).toBeEnabled(); + await expect(page.locator('[data-orderbook-precision-value="1"]')).toBeEnabled(); + expect(await selections(page)).toEqual([{ symbol: CURRENT_SYMBOL, value: '0.01' }]); + await expectNoFinancialActions(page); + expect(host.errors).toEqual([]); +}); diff --git a/e2e/binance-orderbook/specs/quantity-and-reprice-boundaries.pw.js b/e2e/binance-orderbook/specs/quantity-and-reprice-boundaries.pw.js new file mode 100644 index 0000000..32bc4d9 --- /dev/null +++ b/e2e/binance-orderbook/specs/quantity-and-reprice-boundaries.pw.js @@ -0,0 +1,121 @@ +import { test, expect } from '../test.js'; +import { createCancelScenario } from '../scenarios/cancel-current-symbol.js'; +import { openUserscriptScenario, readFixtureState } from '../helpers/userscript-page.js'; +import { installScenarioClock, pauseScenarioClock } from '../helpers/scenario-clock.js'; + +const STATUS = '#jh-binance-ladder-status'; + +async function submissions(page) { + return (await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted'); +} + +for (const boundary of [ + { name: 'confirmed zero balance after brief action feedback', balance: '0.00 USDT', missing: false, message: '可用余额不足', deadlineMs: 240, waitsForQuantity: false }, + { name: 'temporarily missing quantity', balance: '100.00 USDT', missing: true, message: '未读取到可开数量', deadlineMs: 1200, waitsForQuantity: true }, + { name: 'zero quantity with available balance', balance: '100.00 USDT', missing: false, message: '当前可开数量为 0', deadlineMs: 1200, waitsForQuantity: true }, +]) { + test(`user distinguishes ${boundary.name} through the actual open-ladder entrypoint`, async ({ page }) => { + // Given the native quantity and balance expose separate, explicit readiness evidence. + await installScenarioClock(page); + const { errors } = await openUserscriptScenario(page, createCancelScenario({ + ui: { openableQuantity: '0' }, + })); + await page.locator('.available-balance span').last().evaluate((element, text) => { + element.textContent = text; + }, boundary.balance); + if (boundary.missing) { + await page.locator('[data-testid^="max-"]').evaluateAll(elements => elements.forEach(element => element.remove())); + } + await pauseScenarioClock(page); + await page.locator('#limitPrice-open').evaluate(input => { + input.addEventListener('input', () => { + document.body.dataset.quantityReadStartedAt = String(performance.now()); + }, { once: true }); + }); + + // When an ordinary ladder reaches one millisecond before its feedback or quantity-readiness deadline. + const actionStartedAt = await page.locator('[data-ladder-action="OPEN_LONG"]').evaluate(button => { + const startedAt = performance.now(); + button.click(); + return startedAt; + }); + await expect(page.locator('body')).toHaveAttribute('data-quantity-read-started-at', /\d/); + const remaining = await page.evaluate(({ actionStartedAt, deadlineMs, waitsForQuantity }) => { + const startedAt = waitsForQuantity + ? Number(document.body.dataset.quantityReadStartedAt) + : actionStartedAt; + return startedAt + deadlineMs - 1 - performance.now(); + }, { actionStartedAt, deadlineMs: boundary.deadlineMs, waitsForQuantity: boundary.waitsForQuantity }); + expect(remaining).toBeGreaterThanOrEqual(0); + await page.clock.runFor(remaining); + + // Then feedback remains pending for its full visible interval without submitting any order. + await expect(page.locator(STATUS)).toHaveText('阶梯开多准备中'); + expect(await submissions(page)).toEqual([]); + + // When the final millisecond completes the applicable public feedback deadline. + await page.clock.runFor(1); + + // Then the correct reason is visible, and neither submission nor cancellation occurred. + await expect(page.locator(STATUS)).toContainText(boundary.message); + expect(await submissions(page)).toEqual([]); + expect((await readFixtureState(page)).events.filter(({ type }) => /cancel/.test(type))).toEqual([]); + expect(errors).toEqual([]); + }); +} + +test('user reprices only the three unfinished orders from the current book after the full fifth-rejection pause', async ({ page }) => { + // Given two orders succeed before five consecutive native maker rejections. + await installScenarioClock(page); + const success = { outcome: 'success', delivery: 'immediate' }; + const rejection = { outcome: 'rejected', delivery: 'immediate', code: '-5022', message: 'Post only maker order rejected' }; + const host = await openUserscriptScenario(page, createCancelScenario({ host: { + submitApiResponses: [success, success, ...Array.from({ length: 4 }, () => rejection), + { ...rejection, delivery: 'manual' }, success, success, success], + } })); + await page.locator(STATUS).evaluate(status => { + const observer = new MutationObserver(() => { + if (status.textContent.includes('3s 后继续')) { + document.body.dataset.repricePauseStartedAt = String(performance.now()); + observer.disconnect(); + } + }); + observer.observe(status, { childList: true, characterData: true, subtree: true }); + }); + await page.locator('[data-ladder-action="OPEN_LONG"]').click(); + await expect.poll(host.pendingSubmitSequences, { timeout: 8000 }).toEqual([7]); + await pauseScenarioClock(page); + + // When the fifth rejection is delivered and 2,999 ms of the declared pause elapse. + await host.releaseSubmitResponse(7); + await expect(page.locator(STATUS)).toContainText('3s 后继续'); + await expect(page.locator(STATUS)).toContainText('剩余 3 档'); + await expect(page.locator(STATUS)).toContainText('已刷新 5 次'); + const remaining = await page.evaluate(() => Number(document.body.dataset.repricePauseStartedAt) + 2999 - performance.now()); + expect(remaining).toBeGreaterThanOrEqual(0); + await page.clock.runFor(remaining); + + // Then no early retry occurs and the two acknowledgements remain intact. + expect(await submissions(page)).toHaveLength(7); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submit-api-success') + .map(({ submitSequence }) => submitSequence)).toEqual([1, 2]); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submit-api-rejected') + .map(({ submitSequence }) => submitSequence)).toEqual([3, 4, 5, 6, 7]); + await expect(page.locator(STATUS)).toContainText('3s 后继续'); + + // When the full three seconds expire and the native form can finish the remaining requests. + await page.clock.runFor(1); + await page.clock.resume(); + + // Then exactly three repriced acknowledgements finish the original five-order quantity allocation. + await expect(page.locator(STATUS)).toContainText('已完成', { timeout: 8000 }); + await expect(page.locator(STATUS)).toContainText('已挂 5/5'); + await expect(page.locator(STATUS)).toContainText('刷新盘口 5 次,错误码 -5022'); + expect((await submissions(page)).map(({ price, quantity }) => ({ price, quantity }))).toEqual( + ['80.9', '80.4', '79.9', '80.9', '80.9', '80.9', '80.9', '80.9', '80.4', '79.9'] + .map(price => ({ price, quantity: '0.07' })), + ); + expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submit-api-success') + .map(({ submitSequence }) => submitSequence)).toEqual([1, 2, 8, 9, 10]); + expect(host.errors).toEqual([]); +}); diff --git a/e2e/binance-orderbook/specs/route-recovery-behavior.pw.js b/e2e/binance-orderbook/specs/route-recovery-behavior.pw.js new file mode 100644 index 0000000..cde75eb --- /dev/null +++ b/e2e/binance-orderbook/specs/route-recovery-behavior.pw.js @@ -0,0 +1,436 @@ +import { test, expect, reloadPageWithCoverage } from '../test.js'; +import { + CURRENT_SYMBOL, + OTHER_SYMBOL, + ORDER_SETS, + createCancelScenario, +} from '../scenarios/cancel-current-symbol.js'; +import { openUserscriptScenario, readFixtureState } from '../helpers/userscript-page.js'; +import { installScenarioClock, pauseScenarioClock } from '../helpers/scenario-clock.js'; + +const PANEL = '#jh-binance-close-qty-multiplier-panel'; +const STATUS = '#jh-binance-ladder-status'; +const RECOVERY_KEY = 'binance-orderbook-trade:chart-orders-recovery:v2'; +const RECOVERY_RECORD = JSON.stringify({ version: 2, originalChecked: true, createdAtMs: 1_000 }); +const POSITION_PATH = '/bapi/futures/v6/private/future/user-data/user-position'; + +async function eventsOfType(page, type) { + return (await readFixtureState(page)).events.filter(event => event.type === type); +} + +async function readRecoveryRecord(page) { + return page.evaluate(key => sessionStorage.getItem(key), RECOVERY_KEY); +} + +async function changeRoute(page, path) { + await page.evaluate(nextPath => history.pushState({}, '', nextPath), path); +} + +/** Keep timing assertions relative to the native operation that scheduled the work. */ +async function advanceFromNativeEvent(page, type, elapsed) { + const remaining = await page.evaluate(({ type, elapsed }) => { + const event = window.__BINANCE_FIXTURE__.snapshot().events + .filter(entry => entry.type === type).at(-1); + if (!event) throw new Error(`Native event was not observed: ${type}`); + return event.at + elapsed - performance.now(); + }, { type, elapsed }); + expect(remaining).toBeGreaterThanOrEqual(0); + await page.clock.runFor(remaining); +} + +async function openPendingCloseRound(page) { + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: '100' }], + ui: { tradeMode: 'CLOSE' }, + host: { + submitApiResponses: [ + { outcome: 'success', delivery: 'immediate' }, + { outcome: 'success', delivery: 'immediate' }, + { outcome: 'success', delivery: 'manual' }, + { outcome: 'success', delivery: 'manual' }, + { outcome: 'success', delivery: 'immediate' }, + { outcome: 'success', delivery: 'manual' }, + ], + }, + })); + await page.locator('[data-ladder-group="levels"][data-ladder-value="3"]').click(); + await page.locator('[data-ladder-action="CLOSE_SHORT"]').click({ modifiers: ['Alt'] }); + await expect.poll(host.pendingSubmitSequences).toEqual([3]); + await pauseScenarioClock(page); + return host; +} + +/** Delay delivery only; the already contracted fixture still owns the response. */ +async function holdRulesResponse(page, symbol) { + const requested = Promise.withResolvers(); + const released = Promise.withResolvers(); + const url = `https://fapi.binance.com/fapi/v1/exchangeInfo?symbol=${symbol}`; + let requestCount = 0; + await page.route(url, async route => { + requestCount += 1; + expect(route.request().method()).toBe('GET'); + expect(requestCount).toBe(1); + requested.resolve(); + await released.promise; + await route.fallback(); + }); + return { + requested: requested.promise, + requestCount: () => requestCount, + async release() { + const received = page.waitForResponse(url); + released.resolve(); + const response = await received; + await response.finished(); + }, + }; +} + +async function reloadWithRecoveryRecord(page, scenario, record = RECOVERY_RECORD) { + await installScenarioClock(page); + const host = await openUserscriptScenario(page, scenario); + await page.evaluate(({ key, record }) => sessionStorage.setItem(key, record), { + key: RECOVERY_KEY, + record, + }); + await reloadPageWithCoverage(page); + await page.locator(PANEL).waitFor({ state: 'visible' }); + await page.locator('#jh-binance-ladder-body').waitFor({ state: 'visible' }); + await pauseScenarioClock(page); + return host; +} + +test('user preserves an active close ladder and exact round totals across SPA locale changes', async ({ page }) => { + // Given two acknowledged orders and the third pending order belong to one continuous round. + const host = await openPendingCloseRound(page); + const originalPanel = await page.locator(PANEL).elementHandle(); + await expect(page.locator(STATUS)).toHaveText('连续阶梯平空 · 第 3 笔确认中 · 0/1 轮 · 本轮 2/3 笔 · 累计 2 笔'); + + // When the SPA changes only the language while the third response remains pending. + await changeRoute(page, `/en/futures/${CURRENT_SYMBOL}`); + await page.clock.runFor(32); + + // Then the rebuilt English panel keeps the same pending operation and confirmed counters. + expect(await originalPanel.evaluate(element => element.isConnected)).toBe(false); + await originalPanel.dispose(); + await expect(page.locator(PANEL)).toHaveCount(1); + await expect(page.locator(STATUS)).toHaveText('Continuous Close Short · Order 3 confirming · 0/1 rounds · This round 2/3 · Total 2'); + await expect(page.getByRole('button', { name: 'Stop Close Short', exact: true })).toBeEnabled(); + expect(host.pendingSubmitSequences()).toEqual([3]); + expect(await eventsOfType(page, 'order-submit-api-success')).toHaveLength(2); + + // When the original response succeeds and the same session starts its second round. + await host.releaseSubmitResponse(3); + await expect.poll(async () => (await eventsOfType(page, 'order-submit-api-success')).length).toBe(3); + // The native chart has a separate 250 ms order-drawing discovery window. + await page.clock.runFor(250); + await expect(page.locator(STATUS)).toHaveText('Continuous Close Short · Continue in 1s · 1/1 rounds · This round 3/3 · Total 3'); + await page.clock.resume(); + await expect.poll(host.pendingSubmitSequences).toEqual([4]); + await pauseScenarioClock(page); + await changeRoute(page, `/zh-CN/futures/${CURRENT_SYMBOL}`); + await page.clock.runFor(32); + + // Then changing back preserves the first round and the second round's pending first order. + await expect(page.locator(STATUS)).toHaveText('连续阶梯平空 · 第 1 笔确认中 · 1/2 轮 · 本轮 0/3 笔 · 累计 3 笔'); + await expect(page.getByRole('button', { name: '停止平空', exact: true })).toBeEnabled(); + expect(await eventsOfType(page, 'order-submitted')).toHaveLength(4); + + // When the remaining acknowledgements finish the second round and the user stops. + await host.releaseSubmitResponse(4); + await page.clock.resume(); + await expect.poll(host.pendingSubmitSequences).toEqual([6]); + await pauseScenarioClock(page); + await host.releaseSubmitResponse(6); + await expect.poll(async () => (await eventsOfType(page, 'order-submit-api-success')).length).toBe(6); + await page.clock.runFor(250); + await expect(page.locator(STATUS)).toHaveText('连续阶梯平空 · 1s 后继续 · 2/2 轮 · 本轮 3/3 笔 · 累计 6 笔'); + await page.getByRole('button', { name: '停止平空', exact: true }).click(); + await page.clock.runFor(5_000); + + // Then no locale change resets or duplicates the six original-direction submissions. + await expect(page.locator(STATUS)).toHaveText('连续阶梯平空 · 已停止 · 2/2 轮 · 本轮 3/3 笔 · 累计 6 笔'); + expect((await eventsOfType(page, 'order-submitted')).map(({ action, price, quantity }) => ({ + action, price, quantity, + }))).toEqual([ + { action: '平空', price: '80.9', quantity: '0.1' }, + { action: '平空', price: '80.4', quantity: '0.1' }, + { action: '平空', price: '79.9', quantity: '0.1' }, + { action: '平空', price: '80.9', quantity: '0.1' }, + { action: '平空', price: '80.4', quantity: '0.1' }, + { action: '平空', price: '79.9', quantity: '0.1' }, + ]); + expect(host.errors).toEqual([]); +}); + +test('user leaves a futures route while an order is pending without allowing a later ladder submission', async ({ page }) => { + // Given the first order is pending and the remaining four orders have never been submitted. + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: 'LONG', quantity: '1' }], + host: { submitApiResponses: [{ outcome: 'success', delivery: 'manual' }] }, + })); + await page.locator('[data-ladder-action="OPEN_LONG"]').click(); + await expect.poll(host.pendingSubmitSequences).toEqual([1]); + await pauseScenarioClock(page); + let positionRequests = 0; + page.on('request', request => { + if (new URL(request.url()).pathname === POSITION_PATH) positionRequests += 1; + }); + + // When navigation leaves futures before the native acknowledgement arrives. + await changeRoute(page, '/zh-CN/markets'); + await page.clock.runFor(32); + + // Then the trading panel is removed while the one earlier request remains pending. + await expect(page.locator(PANEL)).toHaveCount(0); + await expect(page.locator('#jh-binance-close-qty-multiplier-spacer')).toHaveCount(0); + expect(host.pendingSubmitSequences()).toEqual([1]); + expect(await eventsOfType(page, 'order-submitted')).toHaveLength(1); + + // When the earlier response succeeds and two watchdog intervals pass off the trading route. + await host.releaseSubmitResponse(1); + await page.clock.runFor(10_000); + + // Then the old ladder cannot submit its second order or issue a new position read. + expect(await eventsOfType(page, 'order-submitted')).toHaveLength(1); + expect(await eventsOfType(page, 'order-submit-api-success')).toHaveLength(1); + expect(positionRequests).toBe(0); + await expect(page.locator(PANEL)).toHaveCount(0); + + // When the user returns to the original futures page. + await changeRoute(page, `/zh-CN/futures/${CURRENT_SYMBOL}`); + await page.clock.runFor(5_000); + + // Then the restored panel reports interruption and does not restart its abandoned ladder. + await expect(page.locator(PANEL)).toHaveCount(1); + await expect(page.locator(STATUS)).toContainText('交易对已切换'); + await expect(page.locator('[data-ladder-stop]')).toHaveCount(0); + expect(await eventsOfType(page, 'order-submitted')).toHaveLength(1); + expect(host.errors).toEqual([]); +}); + +test('user leaving futures stops readiness position polls and does not revive the continuous session on return', async ({ page }) => { + // Given a completed close round is waiting for a disabled native button and has polled its position. + const host = await openPendingCloseRound(page); + let positionRequests = 0; + page.on('request', request => { + if (new URL(request.url()).pathname === POSITION_PATH) positionRequests += 1; + }); + await page.locator('.order-entry').getByRole('button', { name: '平空', exact: true }) + .evaluate(button => { button.disabled = true; }); + const positionResponse = page.waitForResponse(response => new URL(response.url()).pathname === POSITION_PATH); + await host.releaseSubmitResponse(3); + await expect.poll(async () => (await eventsOfType(page, 'order-submit-api-success')).length).toBe(3); + await page.clock.runFor(250); + await expect(page.locator(STATUS)).toContainText('等待按钮恢复'); + await (await positionResponse).finished(); + expect(positionRequests).toBe(1); + + // When the user leaves futures and more than ten readiness deadlines pass. + await changeRoute(page, '/zh-CN/markets'); + await page.clock.runFor(10_000); + + // Then business work is stopped and the watchdog never recreates the panel off-route. + await expect(page.locator(PANEL)).toHaveCount(0); + expect(positionRequests).toBe(1); + expect(await eventsOfType(page, 'order-submitted')).toHaveLength(3); + expect(host.pendingSubmitSequences()).toEqual([]); + + // When the native button becomes ready and the user returns to an English futures page. + await page.locator('.order-entry').getByRole('button', { name: '平空', exact: true }) + .evaluate(button => { button.disabled = false; }); + await changeRoute(page, `/en/futures/${CURRENT_SYMBOL}`); + await page.clock.runFor(5_000); + + // Then one account-observer refresh is allowed, but readiness polling and trading stay stopped. + await expect(page.locator(STATUS)).toHaveText('Continuous Close Short · Stopped · 1/1 rounds · This round 3/3 · Total 3 · Symbol changed'); + await expect(page.locator('[data-ladder-stop]')).toHaveCount(0); + expect(positionRequests).toBe(2); + expect(await eventsOfType(page, 'order-submitted')).toHaveLength(3); + expect(host.errors).toEqual([]); +}); + +for (const changed of ['symbol', 'precision']) { + test(`user refuses a stale ladder when ${changed} changes during awaited exchange-rule bootstrap`, async ({ page }) => { + // Given the new symbol's native exchange rules remain pending while the user starts a ladder. + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario()); + const rules = await holdRulesResponse(page, OTHER_SYMBOL); + await page.evaluate(symbol => window.__BINANCE_FIXTURE__.switchSymbol(symbol), OTHER_SYMBOL); + await rules.requested; + await expect(page.locator('#jh-binance-close-qty-final')).toHaveText('最小量读取中'); + await page.locator('[data-ladder-action="OPEN_LONG"]').click(); + await expect(page.locator(STATUS)).toHaveText('阶梯开多准备中'); + await pauseScenarioClock(page); + expect(await eventsOfType(page, 'order-submitted')).toEqual([]); + + // When the native context changes before the existing exchange-rule response is delivered. + if (changed === 'symbol') { + await page.evaluate(symbol => window.__BINANCE_FIXTURE__.switchSymbol(symbol), CURRENT_SYMBOL); + } else { + await page.locator('#futuresOrderbook .bn-select-trigger').click(); + await page.getByRole('option', { name: '0.01', exact: true }).click(); + } + await rules.release(); + await page.clock.runFor(1_000); + + // Then no order is submitted from the stale context and the completed task exposes its specific refusal. + await expect(page.locator(STATUS)).toContainText(changed === 'symbol' + ? '交易对已切换' + : '读取交易规则时价格精度已变化,已停止'); + await expect(page.locator('[data-ladder-stop]')).toHaveCount(0); + expect(rules.requestCount()).toBe(1); + expect(await eventsOfType(page, 'order-submitted')).toEqual([]); + expect(await eventsOfType(page, 'cancel-requested')).toEqual([]); + expect((await readFixtureState(page)).currentSymbol).toBe(changed === 'symbol' ? CURRENT_SYMBOL : OTHER_SYMBOL); + expect(host.errors).toEqual([]); + }); +} + +test('user keeps a reload recovery record until the chart is ready and its final restored drawing is saved', async ({ page }) => { + // Given a real reload discovers an old recovery record while the chart toolbar is unavailable. + const host = await reloadWithRecoveryRecord(page, createCancelScenario({ + orders: ORDER_SETS.current, + ui: { showOrders: false }, + host: { mutationDelayMs: 500 }, + })); + await page.locator('.chart-toolbar').evaluate(toolbar => { toolbar.style.display = 'none'; }); + expect(await readRecoveryRecord(page)).toBe(RECOVERY_RECORD); + + // When two route-watchdog checks run before the native chart becomes available. + await page.clock.runFor(10_000); + + // Then the recovery record remains pending and no chart toggle or save has been fabricated. + expect(await readRecoveryRecord(page)).toBe(RECOVERY_RECORD); + expect((await readFixtureState(page)).showOrders).toBe(false); + expect(await eventsOfType(page, 'chart-orders-checked')).toEqual([]); + expect(await eventsOfType(page, 'chart-saved')).toEqual([]); + + // When the toolbar returns and route synchronization starts native restoration. + await page.locator('.chart-toolbar').evaluate(toolbar => { toolbar.style.display = ''; }); + await changeRoute(page, `/en/futures/${CURRENT_SYMBOL}`); + await expect.poll(async () => (await readFixtureState(page)).showOrders).toBe(true); + await advanceFromNativeEvent(page, 'chart-orders-checked', 499); + + // Then checking the box alone cannot clear the record before the host publishes its drawing save. + expect(await readRecoveryRecord(page)).toBe(RECOVERY_RECORD); + expect(await eventsOfType(page, 'chart-save-requested')).toEqual([]); + expect(await eventsOfType(page, 'chart-saved')).toEqual([]); + + // When the drawing arrives and the full coalesced-save quiet period completes. + await page.clock.runFor(1); + expect(await eventsOfType(page, 'chart-save-requested')).toHaveLength(1); + await page.clock.runFor(49); + expect(await readRecoveryRecord(page)).toBe(RECOVERY_RECORD); + expect(await eventsOfType(page, 'chart-saved')).toEqual([]); + await page.clock.runFor(1); + + // Then exactly the final restored snapshot is saved and only now is the recovery record removed. + await expect.poll(() => readRecoveryRecord(page)).toBe(null); + expect((await eventsOfType(page, 'chart-saved')).map(({ snapshot }) => snapshot)).toEqual([ + { checked: true, drawingCount: 1, finalOrderId: 'current-1' }, + ]); + await expect(page.locator('#chart-orders-menu')).not.toHaveClass(/active/); + expect((await readFixtureState(page)).orders).toEqual(ORDER_SETS.current); + expect(await eventsOfType(page, 'order-submitted')).toEqual([]); + expect(host.errors).toEqual([]); +}); + +test('user clears a valid reload journal without toggling an already restored chart', async ({ page }) => { + // Given a real reload retains a valid journal although native order drawings are already enabled. + const host = await reloadWithRecoveryRecord(page, createCancelScenario({ + orders: ORDER_SETS.current, + ui: { showOrders: true }, + })); + expect(await readRecoveryRecord(page)).toBe(RECOVERY_RECORD); + + // When the current native chart is checked during a locale-only route transition. + await changeRoute(page, `/en/futures/${CURRENT_SYMBOL}`); + + // Then restoration closes the menu and clears the journal without any toggle or chart save. + await expect.poll(() => readRecoveryRecord(page)).toBe(null); + expect((await readFixtureState(page)).showOrders).toBe(true); + expect(await eventsOfType(page, 'chart-orders-popover-opened')).toHaveLength(1); + expect(await eventsOfType(page, 'chart-orders-popover-closed')).toHaveLength(1); + expect(await eventsOfType(page, 'chart-orders-checked')).toEqual([]); + expect(await eventsOfType(page, 'chart-saved')).toEqual([]); + expect(host.errors).toEqual([]); +}); + +test('user keeps the reload journal when native chart restoration cannot close its menu', async ({ page }) => { + // Given the native host explicitly refuses to close the chart orders menu after reload. + const host = await reloadWithRecoveryRecord(page, createCancelScenario({ + orders: ORDER_SETS.current, + ui: { showOrders: false }, + host: { chartOrdersPopoverCloseMode: 'stuck' }, + })); + + // When restoration checks the box but reaches the native menu-close deadline. + await changeRoute(page, `/en/futures/${CURRENT_SYMBOL}`); + await expect.poll(async () => (await readFixtureState(page)).showOrders).toBe(true); + await page.clock.runFor(2_000); + + // Then a successful chart save alone does not falsely complete restoration. + expect(await readRecoveryRecord(page)).toBe(RECOVERY_RECORD); + expect(await eventsOfType(page, 'chart-orders-checked')).toHaveLength(1); + expect(await eventsOfType(page, 'chart-saved')).toHaveLength(1); + await expect(page.locator('#chart-orders-menu')).toHaveClass(/active/); + + // When another route transition retries the still pending native cleanup. + await changeRoute(page, `/zh-CN/futures/${CURRENT_SYMBOL}`); + await page.clock.runFor(2_000); + + // Then the record remains retryable without toggling the already restored order drawings again. + expect(await readRecoveryRecord(page)).toBe(RECOVERY_RECORD); + expect(await eventsOfType(page, 'chart-orders-checked')).toHaveLength(1); + expect(await eventsOfType(page, 'chart-orders-popover-close-requested')).toHaveLength(2); + expect((await readFixtureState(page)).orders).toEqual(ORDER_SETS.current); + expect(await eventsOfType(page, 'cancel-requested')).toEqual([]); + expect(host.errors).toEqual([]); +}); + +for (const [name, record] of [ + ['malformed', '{'], + ['unsupported', JSON.stringify({ version: 1, originalChecked: true, createdAtMs: 1_000 })], +]) { + test(`user discards ${name} reload data without changing native chart visibility`, async ({ page }) => { + // Given invalid persisted recovery data exists before the generated userscript loads again. + const host = await reloadWithRecoveryRecord(page, createCancelScenario({ + orders: ORDER_SETS.current, + ui: { showOrders: false }, + }), record); + + // When the reloaded page and two later route-watchdog checks process that journal. + await page.clock.runFor(10_000); + + // Then the invalid record is removed without any chart or order mutation. + expect(await readRecoveryRecord(page)).toBe(null); + expect((await readFixtureState(page)).showOrders).toBe(false); + expect((await readFixtureState(page)).orders).toEqual(ORDER_SETS.current); + expect(await eventsOfType(page, 'chart-orders-popover-opened')).toEqual([]); + expect(await eventsOfType(page, 'chart-orders-checked')).toEqual([]); + expect(await eventsOfType(page, 'chart-saved')).toEqual([]); + expect(await eventsOfType(page, 'order-submitted')).toEqual([]); + expect(host.errors).toEqual([]); + }); +} + +test('user does not restore chart visibility after the pending reload journal is removed', async ({ page }) => { + // Given startup has a valid record but has not yet found the native chart target. + const host = await reloadWithRecoveryRecord(page, createCancelScenario({ ui: { showOrders: false } })); + expect(await readRecoveryRecord(page)).toBe(RECOVERY_RECORD); + + // When that session record is cleared before route synchronization can restore it. + await page.evaluate(key => sessionStorage.removeItem(key), RECOVERY_KEY); + await changeRoute(page, `/en/futures/${CURRENT_SYMBOL}`); + await page.clock.runFor(10_000); + + // Then the pending recovery stops and later watchdog checks preserve the native hidden state. + expect(await readRecoveryRecord(page)).toBe(null); + expect((await readFixtureState(page)).showOrders).toBe(false); + expect(await eventsOfType(page, 'chart-orders-popover-opened')).toEqual([]); + expect(await eventsOfType(page, 'chart-orders-checked')).toEqual([]); + expect(host.errors).toEqual([]); +}); diff --git a/e2e/binance-orderbook/specs/rules-and-form-boundaries.pw.js b/e2e/binance-orderbook/specs/rules-and-form-boundaries.pw.js new file mode 100644 index 0000000..2ee853e --- /dev/null +++ b/e2e/binance-orderbook/specs/rules-and-form-boundaries.pw.js @@ -0,0 +1,236 @@ +import { test, expect } from '../test.js'; +import { OTHER_SYMBOL, createCancelScenario } from '../scenarios/cancel-current-symbol.js'; +import { openUserscriptScenario, readFixtureState } from '../helpers/userscript-page.js'; +import { installScenarioClock, pauseScenarioClock } from '../helpers/scenario-clock.js'; + +const FINAL_QUANTITY = '#jh-binance-close-qty-final'; +const STATUS = '#jh-binance-ladder-status'; +const FILTERS = [ + { filterType: 'LOT_SIZE', minQty: '0.01', stepSize: '0.01' }, + { filterType: 'MARKET_LOT_SIZE', minQty: '0.2', stepSize: '0.1' }, + { filterType: 'MIN_NOTIONAL', notional: '5' }, +]; + +async function orderSubmissions(page) { + return (await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted'); +} + +async function switchToUncachedSymbol(page) { + await page.evaluate(symbol => window.__BINANCE_FIXTURE__.switchSymbol(symbol), OTHER_SYMBOL); + await page.clock.runFor(100); +} + +for (const failure of ['http', 'missing symbol', 'invalid json', 'network']) { + test(`user waits through the rule cooldown after a ${failure} failure before the next request recovers`, async ({ page }) => { + // Given the current symbol is ready and the next symbol has one explicit exchange-info failure. + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario()); + await expect(page.locator(FINAL_QUANTITY)).toHaveText('0.07'); + await pauseScenarioClock(page); + let requests = 0; + const recoveredRules = Promise.withResolvers(); + await page.route('https://fapi.binance.com/fapi/v1/exchangeInfo**', async route => { + expect(new URL(route.request().url()).searchParams.get('symbol')).toBe(OTHER_SYMBOL); + requests += 1; + if (requests > 1) { + await recoveredRules.promise; + await route.fulfill({ json: { symbols: [{ symbol: OTHER_SYMBOL, filters: FILTERS }] } }); + } else if (failure === 'http') { + await route.fulfill({ status: 503, json: { message: 'Exchange info unavailable' } }); + } else if (failure === 'missing symbol') { + await route.fulfill({ json: { symbols: [] } }); + } else if (failure === 'invalid json') { + await route.fulfill({ contentType: 'application/json', body: '{' }); + } else { + await route.abort('failed'); + } + }); + + try { + // When navigation loads the failed rules and a real price click occurs within the cooldown. + await switchToUncachedSymbol(page); + await expect.poll(() => requests).toBe(1); + await page.clock.runFor(100); + await page.locator('#futuresOrderbook .bid-light').first().click(); + + // Then the real click reports unavailable rules and cannot submit an order. + await expect(page.locator(FINAL_QUANTITY)).toHaveText('最小量读取中'); + await expect(page.locator(STATUS)).toHaveText('单击下单未执行:数量规则读取中'); + expect(await orderSubmissions(page)).toEqual([]); + + // When several normal refreshes occur within the five-second rule cooldown. + await page.clock.runFor(4000); + await page.evaluate(() => window.dispatchEvent(new Event('resize'))); + await page.clock.runFor(16); + + // Then no additional rules request or order can be produced during that cooldown. + expect(requests).toBe(1); + expect(await orderSubmissions(page)).toEqual([]); + + // When the cooldown ends but the recovery response is still held beyond the next browser frames. + await page.clock.runFor(1100); + await page.evaluate(() => window.dispatchEvent(new Event('resize'))); + await page.clock.runFor(100); + await expect.poll(() => requests).toBe(2); + await page.clock.runFor(100); + + // Then receiving the second request alone cannot mark the quantity rules as ready. + await expect(page.locator(FINAL_QUANTITY)).toHaveText('最小量读取中'); + expect(await orderSubmissions(page)).toEqual([]); + + // When the successful response is released and normal browser frame scheduling resumes. + recoveredRules.resolve(); + await page.clock.resume(); + + // Then the returned quantity contract updates the panel without another request or order submission. + await expect(page.locator(FINAL_QUANTITY)).toHaveText('0.07'); + await expect(page.locator('#jh-binance-close-qty-min')).toHaveText('≥5U @ 81'); + expect(requests).toBe(2); + expect(await orderSubmissions(page)).toEqual([]); + expect(host.errors).toEqual([]); + } finally { + recoveredRules.resolve(); + } + }); +} + +for (const rule of [ + { label: 'no filters field', entry: {} }, + { label: 'empty filters', entry: { filters: [] } }, + { label: 'missing lot minimum', entry: { filters: [{ filterType: 'LOT_SIZE', stepSize: '0.01' }] } }, + { label: 'missing lot increment', entry: { filters: [{ filterType: 'LOT_SIZE', minQty: '0.01' }] } }, +]) { + test(`user cannot submit a ladder while exchange rules contain ${rule.label}`, async ({ page }) => { + // Given an uncached symbol will return an incomplete minimum-quantity contract. + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario()); + await pauseScenarioClock(page); + let requests = 0; + await page.route('https://fapi.binance.com/fapi/v1/exchangeInfo**', async route => { + requests += 1; + await route.fulfill({ json: { symbols: [{ symbol: OTHER_SYMBOL, ...rule.entry }] } }); + }); + await switchToUncachedSymbol(page); + await expect.poll(() => requests).toBe(1); + await page.clock.runFor(100); + + // When the user explicitly starts the ordinary open-long ladder. + await page.locator('[data-ladder-action="OPEN_LONG"]').evaluate(button => button.click()); + await page.clock.runFor(300); + await page.clock.resume(); + + // Then the entrypoint identifies the missing quantity rules and never sends an order. + await expect(page.locator(FINAL_QUANTITY)).toHaveText('最小量读取中'); + await expect(page.locator(STATUS)).toContainText('下单数量规则尚未就绪'); + expect(requests).toBe(1); + expect(await orderSubmissions(page)).toEqual([]); + expect(host.errors).toEqual([]); + }); +} + +for (const rules of [ + { label: 'market-specific increments', filters: FILTERS, quantity: '0.2' }, + { label: 'the declared lot increment when market filters are absent', filters: [FILTERS[0]], quantity: '0.01' }, +]) { + test(`user sees market quantity calculated from ${rules.label}`, async ({ page }) => { + // Given the next symbol has an explicit exchange quantity contract. + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario()); + await pauseScenarioClock(page); + let requests = 0; + await page.route('https://fapi.binance.com/fapi/v1/exchangeInfo**', async route => { + requests += 1; + await route.fulfill({ json: { symbols: [{ symbol: OTHER_SYMBOL, filters: rules.filters }] } }); + }); + await switchToUncachedSymbol(page); + await expect.poll(() => requests).toBe(1); + + // When the native page selects its Market order tab. + await page.locator('.order-type-tabs [role="tab"]').evaluate(tab => { + tab.dataset.tabKey = 'MARKET'; + tab.textContent = '市价'; + tab.setAttribute('aria-selected', 'true'); + }); + await page.clock.runFor(100); + await page.clock.resume(); + + // Then the panel uses the correct market minimum and increment while keeping the exchange rules cached. + await expect(page.locator(FINAL_QUANTITY)).toHaveText(rules.quantity); + await expect(page.locator('[data-multiplier-formula-prefix]')).toHaveText(`${rules.quantity} × 1 =`); + expect(requests).toBe(1); + expect(await orderSubmissions(page)).toEqual([]); + expect(host.errors).toEqual([]); + }); +} + +for (const mark of [ + { label: 'a numeric mark price', value: 10, quantity: '0.5', constraint: '≥5U @ 10' }, + { label: 'a string mark price', value: '20', quantity: '0.25', constraint: '≥5U @ 20' }, + { label: 'an unavailable mark price', value: null, quantity: '0.01', constraint: '' }, + { label: 'a malformed application payload', malformed: true, quantity: '0.01', constraint: '' }, +]) { + test(`user recalculates the minimum from ${mark.label} when the native price input is empty`, async ({ page }) => { + // Given cached exchange rules require five USDT and the current native price is about to be cleared. + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario()); + await expect(page.locator(FINAL_QUANTITY)).toHaveText('0.07'); + await pauseScenarioClock(page); + + // When the native application publishes its mark price and clears the editable limit-price field. + await page.evaluate(mark => { + document.querySelector('#__APP_DATA').textContent = mark.malformed + ? '{' + : JSON.stringify({ appState: { loader: { dataByRouteId: { bd56: { reactQueryData: { + 'queryMarkPrice,HYPEUSDT': { markPrice: mark.value }, + } } } } } }); + const input = document.querySelector('#limitPrice-open'); + input.value = ''; + input.dispatchEvent(new Event('input', { bubbles: true })); + window.dispatchEvent(new Event('resize')); + }, mark); + await page.clock.runFor(100); + + // Then the visible quantity and notional explanation match the available reference exactly. + await expect(page.locator(FINAL_QUANTITY)).toHaveText(mark.quantity); + await expect(page.locator('#jh-binance-close-qty-min')).toHaveText(mark.constraint); + expect(await orderSubmissions(page)).toEqual([]); + expect(host.errors).toEqual([]); + }); +} + +test('user sees the amount constraint separated from the formula only while an opening notional applies', async ({ page }) => { + // Given the open panel displays both a minimum-quantity formula and its notional constraint. + const host = await openUserscriptScenario(page, createCancelScenario()); + const divider = page.locator('[data-multiplier-constraint-divider]'); + await expect(page.locator('#jh-binance-close-qty-min')).toHaveText('≥5U @ 81'); + + // When the complete open calculation is rendered. + const geometry = await divider.boundingBox(); + + // Then a fixed-width decorative divider visibly separates the two pieces of information. + await expect(divider).toBeVisible(); + await expect(divider).toHaveAttribute('aria-hidden', 'true'); + await expect(divider).toHaveCSS('display', 'block'); + await expect(divider).toHaveCSS('flex-shrink', '0'); + await expect(divider).toHaveCSS('background-color', 'rgb(213, 217, 226)'); + expect({ width: geometry.width, height: geometry.height }).toEqual({ width: 1, height: 12 }); + + // When the native page switches to a close order without an opening notional requirement. + await page.locator('[data-trade-mode="CLOSE"]').click(); + + // Then the formula remains while its obsolete constraint and divider both leave the visible calculation. + await expect(page.locator('[data-multiplier-formula-prefix]')).toHaveText('0.01 × 1 ='); + await expect(divider).toHaveCount(1); + await expect(divider).toBeHidden(); + await expect(divider).toHaveCSS('display', 'none'); + await expect(page.locator('#jh-binance-close-qty-min')).toBeHidden(); + + // When the native form returns to open mode. + await page.locator('[data-trade-mode="OPEN"]').click(); + + // Then both the original constraint and its separator return without submitting an order. + await expect(divider).toBeVisible(); + await expect(page.locator('#jh-binance-close-qty-min')).toHaveText('≥5U @ 81'); + expect(await orderSubmissions(page)).toEqual([]); + expect(host.errors).toEqual([]); +}); diff --git a/e2e/binance-orderbook/specs/strategy29-panel-drag.pw.js b/e2e/binance-orderbook/specs/strategy29-panel-drag.pw.js index 411ac22..2972d14 100644 --- a/e2e/binance-orderbook/specs/strategy29-panel-drag.pw.js +++ b/e2e/binance-orderbook/specs/strategy29-panel-drag.pw.js @@ -1,5 +1,5 @@ import { readFile } from 'node:fs/promises'; -import { test, expect } from '../test.js'; +import { test, expect, reloadPageWithCoverage } from '../test.js'; const source = await readFile(new URL('../../../src/binance-strategy29-bollinger/dom/panel-position.js', import.meta.url), 'utf8'); const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`; @@ -46,7 +46,7 @@ test('user drags a panel across the chart iframe and restores its saved position await expect(page.frameLocator('iframe').locator('body')).toHaveAttribute('data-clicked', '1'); // When the user reloads the page and the panel is installed again. - await page.reload(); + await reloadPageWithCoverage(page); await install(page); // Then the panel restores the exact saved position. diff --git a/e2e/binance-orderbook/test.js b/e2e/binance-orderbook/test.js index 81982bb..7769627 100644 --- a/e2e/binance-orderbook/test.js +++ b/e2e/binance-orderbook/test.js @@ -1,6 +1,7 @@ import { test as base, expect } from '@playwright/test'; import { startBrowserCoverage, + checkpointBrowserCoverage, finishBrowserCoverage, } from '../../scripts/test-coverage/collect-browser.mjs'; @@ -53,3 +54,11 @@ export const test = base.extend({ }); export { expect }; + +/** Preserve the outgoing document's full roots before its real reload. */ +export async function reloadPageWithCoverage(page) { + if (process.env.USERSCRIPTS_BROWSER_COVERAGE_DIRECTORY) { + await checkpointBrowserCoverage(page, 'before-reload'); + } + return page.reload(); +} diff --git a/eslint.config.js b/eslint.config.js index 2f052ad..fd732f9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,5 +1,5 @@ import testPolicy from './scripts/test-policy/eslint-plugin.js'; -import { contractCallAllowances, legacyBehaviorFiles, legacyCallAllowances } from './scripts/test-policy/migration-inventory.js'; +import { contractCallAllowances } from './scripts/test-policy/migration-inventory.js'; export default [ { @@ -24,12 +24,7 @@ export default [ files: ['test/**/*.test.js', 'e2e/**/specs/**/*.pw.js'], rules: { 'test-policy/behavior-contract': 'error' }, }, - { - name: 'explicit-legacy-behavior-inventory', - files: legacyBehaviorFiles, - rules: { 'test-policy/behavior-contract': 'off' }, - }, - ...[...legacyCallAllowances, ...contractCallAllowances].map(({ file, rule, allow }) => ({ + ...contractCallAllowances.map(({ file, rule, allow }) => ({ name: `bounded-call-inventory:${file}:${rule}`, files: [file], rules: { [`test-policy/${rule}`]: ['error', { allow }] }, diff --git a/package-lock.json b/package-lock.json index fe1fa11..ddc0327 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "devDependencies": { + "@bcoe/v8-coverage": "1.0.2", "@jridgewell/sourcemap-codec": "1.6.0", "@playwright/test": "^1.62.1", "acorn": "8.18.0", @@ -65,6 +66,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", diff --git a/package.json b/package.json index c246ae3..68afed0 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "test:ui:debug": "playwright test --debug" }, "devDependencies": { + "@bcoe/v8-coverage": "1.0.2", "@jridgewell/sourcemap-codec": "1.6.0", "@playwright/test": "^1.62.1", "acorn": "8.18.0", diff --git a/scripts/auto_refresh.user.js b/scripts/auto_refresh.user.js index b574e72..d4aef83 100644 --- a/scripts/auto_refresh.user.js +++ b/scripts/auto_refresh.user.js @@ -3,7 +3,7 @@ // @namespace daily-0805-refresh // @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 1.0.10 +// @version 1.0.11 // @author jackhai9 // @description ⚠️ 暂不推荐此方式,已改为 macOS launchd + AppleScript 定时打开页面,请参考 https://github.com/jackhai9/dotfiles 中的 home-configs/.local/bin/anyrouter-checkin.sh // @match https://anyrouter.top/* @@ -65,14 +65,15 @@ log('Next refresh at:', next.toString(), 'delay(ms):', delay); if (schedule._t) clearTimeout(schedule._t); - schedule._t = setTimeout(() => { - scheduledTargetTs = 0; - location.reload(); - }, delay); + schedule._t = setTimeout(refreshIfDue, delay); } function refreshIfDue() { + if (!ENABLE_WHEN(location.href)) return false; if (!scheduledTargetTs || Date.now() < scheduledTargetTs) return false; + // Focus, the watchdog, and the timeout can all observe the same deadline. + clearTimeout(schedule._t); + schedule._t = null; scheduledTargetTs = 0; location.reload(); return true; diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index 6ce8995..6924290 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.209 +// @version 2.7.211 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -1855,10 +1855,10 @@ if (apiError.success !== false || apiError.code !== 90802022) return null; return apiError; } - function parseRetryAfterMs(value) { - if (value == null || value === "") return null; + function resolveBinanceRateLimitCooldownMs(value) { + if (value == null || value === "") return 1e4; const seconds = Number(value); - return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : null; + return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : 1e4; } function resolveBinanceSubmitResponseRecovery(diagnostics, apiErrors) { if (!Array.isArray(diagnostics) || !Array.isArray(apiErrors)) { @@ -1869,7 +1869,7 @@ if (rateLimitDiagnostic || hasRateLimitCode) { return { kind: "rate_limited", - cooldownMs: parseRetryAfterMs(rateLimitDiagnostic?.retryAfter) ?? 1e4 + cooldownMs: resolveBinanceRateLimitCooldownMs(rateLimitDiagnostic?.retryAfter) }; } if (diagnostics.some(({ httpStatus }) => httpStatus >= 500 && httpStatus <= 599)) { @@ -3978,6 +3978,7 @@ } const observedFetch = new Proxy(nativeFetch, { apply(target, receiver, args) { + if (restored) return Reflect.apply(target, receiver, args); let observation = null; try { observation = resolveNativeSnapshotSymbol(args[0], baseUrl); @@ -4002,7 +4003,9 @@ return response.clone().json(); }).then( (payload) => acceptSnapshot(symbol, payload), - (error) => failRecord(ensureRecord(symbol), error) + (error) => { + if (!restored) failRecord(ensureRecord(symbol), error); + } ); } return result; @@ -4011,7 +4014,7 @@ const ObservedWebSocket = new Proxy(NativeWebSocket, { construct(target, args, newTarget) { const socket = Reflect.construct(target, args, newTarget); - observeSocket(socket); + if (!restored) observeSocket(socket); return socket; } }); @@ -4019,6 +4022,7 @@ globalObject.WebSocket = ObservedWebSocket; return { subscribe(options) { + if (restored) throw new Error("Binance native depth source has been restored"); const { symbol, onProfile, @@ -4029,7 +4033,6 @@ onProfile: assertFunction(onProfile, "profile listener"), onStatus: assertFunction(onStatus, "status listener") }; - if (restored) throw new Error("Binance native depth source has been restored"); record.subscribers.add(subscriber); subscriber.onStatus(record.status); if (record.profile) subscriber.onProfile(record.profile); @@ -6417,8 +6420,7 @@ } function ensureOrderbookPrecisionObserver() { if (document.hidden || !isFuturesTradingPage()) return; - const trigger = findOrderbookPrecisionTrigger(); - const root = trigger?.element?.closest(".orderbook-tickSize") || trigger?.element || null; + const root = document.querySelector("#futuresOrderbook .orderbook-tickSize"); if (!root) { if (orderbookPrecisionObserverRoot) stopOrderbookPrecisionObserver(); return; @@ -7013,8 +7015,7 @@ rateLimited ? "仓位确认请求频率受限" : "仓位确认暂未完成" ); if (rateLimited) { - const retryAfterSeconds = Number(error.retryAfter); - recoveryError.continuousRecoveryCooldownMs = Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0 ? retryAfterSeconds * 1e3 : 1e4; + recoveryError.continuousRecoveryCooldownMs = resolveBinanceRateLimitCooldownMs(error.retryAfter); } recoveryError.skipImmediateCloseRecheck = true; throw recoveryError; @@ -10032,9 +10033,23 @@ await restoreOpenOrdersSubTab(previousOpenOrdersSubTabIdentity, symbol); } if (previousOpenOrdersScrollTop !== null) { - openOrdersScope = await waitForActiveOpenOrdersScope(); - const scrollContainer = findOpenOrderRowsScrollContainer(openOrdersScope); - if (scrollContainer) { + const restoredRows = await waitForAccountOrdersState(() => { + if (!isCurrentObservedSymbol(symbol)) return null; + const root = getActiveOpenOrdersScope2(); + if (!root) return null; + if (previousOpenOrdersSubTabIdentity && getOpenOrdersSubTabIdentity2( + findSelectedOpenOrdersSubTab2(root) + ) !== previousOpenOrdersSubTabIdentity) return null; + if (symbolFilterOriginalChecked !== null && getCheckboxCheckedState( + findHideOtherSymbolCheckbox(root) + ) !== symbolFilterOriginalChecked) return null; + const hasRows = readOpenOrderRowElements(root).length > 0; + const empty = !hasRows && !findCurrentSymbolCancelAllButton(root) && hasBinanceCurrentSymbolOpenOrdersEmptyText(readOpenOrdersScopeText2(root)); + if (!hasRows && !empty) return null; + return { root, scrollContainer: findOpenOrderRowsScrollContainer(root) }; + }, 2200); + const scrollContainer = restoredRows?.scrollContainer; + if (isCurrentObservedSymbol(symbol) && restoredRows?.root === getActiveOpenOrdersScope2() && scrollContainer?.isConnected && restoredRows.root.contains(scrollContainer)) { scrollContainer.scrollTop = Math.min( previousOpenOrdersScrollTop, scrollContainer.scrollHeight @@ -11173,16 +11188,18 @@ function getLadderControlSections(tradeMode, closeContext, symbol, precision) { const ladderRunning = !!ladderTask || !!continuousLadderTask; const actionDisabled = ladderRunning || !!singleOrderTask || cancelCurrentSymbolOpenOrdersBlocksLadderActions; + const activeActionType = activeLadderActionType || activeContinuousLadderActionType; + const activeStopButtons = activeActionType ? [ladderExecutionButton(activeActionType)] : []; if (!["OPEN", "CLOSE"].includes(tradeMode)) { return { optionRows: [`
${ui(PANEL_COPY.state.waitingTradeMode)}
`], - actionButtons: [] + actionButtons: activeStopButtons }; } if (!precision) { return { optionRows: [`
${ui(PANEL_COPY.state.waitingPricePrecision)}
`], - actionButtons: [] + actionButtons: activeStopButtons }; } if (tradeMode === "OPEN") { diff --git a/scripts/m3u8-downloader.user.js b/scripts/m3u8-downloader.user.js index 16b10e6..0494c5d 100644 --- a/scripts/m3u8-downloader.user.js +++ b/scripts/m3u8-downloader.user.js @@ -3,7 +3,7 @@ // @namespace https://github.com/jackhai9/userscripts // @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 0.10.38 +// @version 0.10.40 // @description m3u8 下载增强脚本,仅在白名单视频站启用,避免误伤交易页等重前端应用 // @author jackhai9 // @include https://18jav.tv/* @@ -66,7 +66,7 @@ function shellQuote(value) { return "'" + value.replace(/'/g, "'\\''") + "'"; } - function buildCaptionUrlFromM3u82(url, captionFile) { + function buildCaptionUrlFromM3u8(url, captionFile) { const sourceUrl = new URL(url); sourceUrl.searchParams.delete("title"); const pathParts = sourceUrl.pathname.split("/").filter(Boolean); @@ -199,8 +199,8 @@ referer: options.referer || "", m3u8: getCleanMediaUrl(options.m3u8Url), videoId: getBrooksVideoIdFromM3u8(options.m3u8Url), - cn: buildCaptionUrlFromM3u82(options.m3u8Url, "CN.vtt"), - en: buildCaptionUrlFromM3u82(options.m3u8Url, "EN.vtt"), + cn: buildCaptionUrlFromM3u8(options.m3u8Url, "CN.vtt"), + en: buildCaptionUrlFromM3u8(options.m3u8Url, "EN.vtt"), index: options.index }; } @@ -1115,7 +1115,7 @@ const filename = `${title}.${lang}.vtt`; console.log(`Downloading caption: ${url}`); console.log(`Saving as: ${filename}`); - return new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { let xhr = new originXHR(); xhr.open("GET", url, true); xhr.responseType = "text"; diff --git a/scripts/test-coverage/branch-policy.json b/scripts/test-coverage/branch-policy.json index 1902816..be8a331 100644 --- a/scripts/test-coverage/branch-policy.json +++ b/scripts/test-coverage/branch-policy.json @@ -1,5 +1,5 @@ { - "minimumBranches": 66.5, + "minimumBranches": 90, "criticalSources": [ "src/binance-orderbook-trade/core/cancel-orders.js", "src/binance-orderbook-trade/core/close-action.js", diff --git a/scripts/test-coverage/browser-snapshots.mjs b/scripts/test-coverage/browser-snapshots.mjs new file mode 100644 index 0000000..a1d0d1e --- /dev/null +++ b/scripts/test-coverage/browser-snapshots.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mergeScriptCovs } from '@bcoe/v8-coverage'; + +function functionKey(fn) { + assert.ok(fn.ranges.length > 0, 'A browser function must contain its captured root'); + const root = fn.ranges[0]; + return root.startOffset + ':' + root.endOffset; +} + +/** + * Unloaded Chromium contexts can retain calls while losing their block ranges. + * Keep those raw calls as separate evidence; only detailed or original zero + * records can support branch credit. The matching-range guard prevents MCR from + * inferring missing functions' execution from their enclosing script root. + */ +export function mergeBrowserSnapshots(capture) { + assert.equal(typeof capture.sessionId, 'string', 'Browser captures require their CDP session identity'); + assert.ok(capture.snapshots.length > 0, 'Browser captures require at least one snapshot'); + const scripts = new Map(); + for (const [snapshotIndex, snapshot] of capture.snapshots.entries()) { + const seen = new Set(); + for (const entry of snapshot.entries) { + assert.equal(seen.has(entry.scriptId), false, 'A snapshot cannot contain duplicate script IDs'); + seen.add(entry.scriptId); + assert.equal(typeof entry.source, 'string', 'Browser snapshots require actual executed bytes'); + if (!scripts.has(entry.scriptId)) scripts.set(entry.scriptId, []); + scripts.get(entry.scriptId).push({ entry, snapshotIndex, phase: snapshot.phase }); + } + } + const entries = []; + const blockEvidenceUnavailable = []; + for (const [scriptId, samples] of scripts) { + const first = samples[0].entry; + const retained = new Set(); + const projected = []; + const unavailable = []; + for (const { entry, snapshotIndex, phase } of samples) { + assert.equal(entry.source, first.source, 'A script ID cannot change source bytes within one CDP session'); + assert.equal(entry.url, first.url, 'A script ID cannot change URL within one CDP session'); + const functions = []; + for (const fn of entry.functions) { + assert.equal(typeof fn.isBlockCoverage, 'boolean', 'Browser function granularity must be explicit'); + const key = functionKey(fn); + if (!fn.isBlockCoverage && fn.ranges[0].count > 0) { + assert.equal(fn.ranges.length, 1, 'Function-only coverage cannot contain detailed blocks'); + unavailable.push({ snapshotIndex, phase, fn, key }); + } else { + retained.add(key); + functions.push(structuredClone(fn)); + } + } + if (functions.length > 0) projected.push({ scriptId, url: entry.url, functions }); + } + for (const { snapshotIndex, phase, fn, key } of unavailable) { + assert.ok(retained.has(key), + 'Function-only calls require a matching captured detailed or zero function; script-root inference is unsafe'); + blockEvidenceUnavailable.push({ + sessionId: capture.sessionId, scriptId, url: first.url, + sourceSha256: createHash('sha256').update(first.source).digest('hex'), + snapshotIndex, phase, functionName: fn.functionName, range: { ...fn.ranges[0] }, + }); + } + assert.ok(projected.length > 0, 'A browser script requires retained coverage evidence'); + entries.push({ ...mergeScriptCovs(projected), source: first.source }); + } + return { entries, blockEvidenceUnavailable }; +} diff --git a/scripts/test-coverage/collect-browser.mjs b/scripts/test-coverage/collect-browser.mjs index 34f1313..20ae911 100644 --- a/scripts/test-coverage/collect-browser.mjs +++ b/scripts/test-coverage/collect-browser.mjs @@ -1,9 +1,11 @@ +import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { readFile, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { ROOT, productionSourceFiles, relativeSourcePath } from './config.mjs'; let originals; +const sessions = new WeakMap(); async function originalSources() { originals ??= productionSourceFiles().then(async (paths) => new Set( @@ -13,18 +15,64 @@ async function originalSources() { } export async function startBrowserCoverage(page) { - await page.coverage.startJSCoverage({ resetOnNavigation: false, reportAnonymousScripts: true }); + assert.equal(sessions.has(page), false, 'A page can have only one active coverage session'); + const client = await page.context().newCDPSession(page); + const state = { client, sessionId: randomUUID(), sources: new Map(), snapshots: [] }; + sessions.set(page, state); + client.on('Debugger.scriptParsed', ({ scriptId }) => { + assert.equal(state.sources.has(scriptId), false, 'Script IDs must remain unique within a CDP session'); + // Resolve while the execution context still exists, before navigation can + // discard it. Collection fails if an executed script's bytes are unavailable. + state.sources.set(scriptId, client.send('Debugger.getScriptSource', { scriptId }).then( + ({ scriptSource }) => ({ source: scriptSource }), + error => ({ error }), + )); + }); + await client.send('Debugger.enable'); + await client.send('Profiler.enable'); + await client.send('Profiler.startPreciseCoverage', { callCount: true, detailed: true }); +} + +export async function checkpointBrowserCoverage(page, phase) { + const state = sessions.get(page); + assert.ok(state, 'Browser coverage must be started before a checkpoint'); + const { result } = await state.client.send('Profiler.takePreciseCoverage'); + const entries = []; + for (const entry of result) { + assert.ok(state.sources.has(entry.scriptId), 'Every captured script must have a parsed-source record'); + const captured = await state.sources.get(entry.scriptId); + if (captured.error) throw captured.error; + entries.push({ ...entry, source: captured.source }); + } + state.snapshots.push({ phase, entries }); +} + +export async function stopBrowserCoverage(page) { + const state = sessions.get(page); + assert.ok(state, 'Browser coverage must be started before collection finishes'); + try { + await checkpointBrowserCoverage(page, 'finish'); + return { sessionId: state.sessionId, snapshots: state.snapshots }; + } finally { + await state.client.send('Profiler.stopPreciseCoverage'); + await state.client.send('Profiler.disable'); + await state.client.send('Debugger.disable'); + await state.client.detach(); + sessions.delete(page); + } } export async function finishBrowserCoverage(page, outputDirectory, testInfo) { + const capture = await stopBrowserCoverage(page); const sources = await originalSources(); - const entries = (await page.coverage.stopJSCoverage()).filter((entry) => ( - typeof entry.source === 'string' - && (entry.source.includes('// ==UserScript==') || sources.has(entry.source)) - )); + const snapshots = capture.snapshots.map(snapshot => ({ + ...snapshot, + entries: snapshot.entries.filter(entry => entry.source.includes('// ==UserScript==') || sources.has(entry.source)), + })); await writeFile(resolve(outputDirectory, randomUUID() + '.json'), JSON.stringify({ testId: testInfo.testId, testFile: relativeSourcePath(testInfo.file), - entries, + sessionId: capture.sessionId, + snapshots, })); } diff --git a/scripts/test-coverage/gates.mjs b/scripts/test-coverage/gates.mjs index 96432ab..f733061 100644 --- a/scripts/test-coverage/gates.mjs +++ b/scripts/test-coverage/gates.mjs @@ -12,7 +12,7 @@ export function assessBranchCoverage(coverage, policy, { requireTarget = false } assert.deepEqual(coverage.layers, ['node', 'browser'], 'Coverage gates require both Node and browser execution'); assert.ok(coverage.summary.branches.total > 0, 'Production coverage needs a nonempty denominator'); assert.ok(Number.isFinite(policy.minimumBranches) && policy.minimumBranches >= 0 - && policy.minimumBranches <= BRANCH_TARGET, 'Invalid staged branch threshold'); + && policy.minimumBranches <= BRANCH_TARGET, 'Invalid branch threshold'); assert.ok(Array.isArray(policy.criticalSources) && policy.criticalSources.length > 0, 'Critical coverage sources must be explicit'); assert.equal(new Set(policy.criticalSources).size, policy.criticalSources.length, 'Duplicate critical coverage source'); @@ -20,7 +20,7 @@ export function assessBranchCoverage(coverage, policy, { requireTarget = false } const targetMet = measured >= BRANCH_TARGET; const failures = []; if (measured < policy.minimumBranches) { - failures.push(`All production sources: ${measured.toFixed(2)}% is below the staged ${policy.minimumBranches}% threshold`); + failures.push(`All production sources: ${measured.toFixed(2)}% is below the configured ${policy.minimumBranches}% threshold`); } const critical = policy.criticalSources.map((path) => { const matches = coverage.files.filter((file) => file.path === path); @@ -32,6 +32,6 @@ export function assessBranchCoverage(coverage, policy, { requireTarget = false } if (requireTarget && !targetMet) { failures.push(`All production sources: ${measured.toFixed(2)}% is below the final ${BRANCH_TARGET}% target`); } - return { passed: failures.length === 0, measured, stagedMinimum: policy.minimumBranches, + return { passed: failures.length === 0, measured, minimumBranches: policy.minimumBranches, target: BRANCH_TARGET, targetMet, requireTarget, critical, failures }; } diff --git a/scripts/test-coverage/report.mjs b/scripts/test-coverage/report.mjs index 109d7a2..f2fa8c5 100644 --- a/scripts/test-coverage/report.mjs +++ b/scripts/test-coverage/report.mjs @@ -7,6 +7,7 @@ import { BRANCH_TARGET, ROOT, isProductionSource, productionSourceFiles } from ' import { createSourceRegistry, mapCoverageEntry } from './source-maps.mjs'; import { verifyCaptures } from './capture-contract.mjs'; import { splitCoverageEntry } from './split-entries.mjs'; +import { mergeBrowserSnapshots } from './browser-snapshots.mjs'; export async function buildCoverageReport({ nodeDirectory, browserDirectory, outputDirectory, expectedNodeTests }) { const registry = await createSourceRegistry(); @@ -24,6 +25,7 @@ export async function buildCoverageReport({ nodeDirectory, browserDirectory, out const browserManifest = browserDirectory === null ? null : JSON.parse(await readFile(resolve(browserDirectory, 'manifest.json'), 'utf8')); const unmapped = []; + const blockEvidenceUnavailable = []; const counts = { node: 0, browser: 0 }; for (const [layer, directory] of [['node', nodeDirectory], ['browser', browserDirectory]]) { if (directory === null) continue; @@ -31,12 +33,21 @@ export async function buildCoverageReport({ nodeDirectory, browserDirectory, out assert.ok(files.length > 0, 'Missing ' + layer + ' coverage captures'); for (const path of files) { const capture = JSON.parse(await readFile(resolve(directory, path), 'utf8')); - const entries = capture.entries; + let entries; if (layer === 'node') capturedTests.add(capture.testFile); else { assert.equal(capturedBrowserTests.has(capture.testId), false, 'Duplicate browser capture'); capturedBrowserTests.add(capture.testId); } + if (layer === 'browser') { + const merged = mergeBrowserSnapshots(capture); + entries = merged.entries; + blockEvidenceUnavailable.push(...merged.blockEvidenceUnavailable.map(evidence => ({ + ...evidence, captureFile: path, testId: capture.testId, testFile: capture.testFile, + }))); + } else { + entries = capture.entries; + } const mapped = []; for (const entry of entries.flatMap((raw) => splitCoverageEntry(raw, registry))) { const result = mapCoverageEntry(entry, registry); @@ -73,6 +84,8 @@ export async function buildCoverageReport({ nodeDirectory, browserDirectory, out capturedEntries: counts, tests: tested, unmapped, + metricInterpretation: blockEvidenceUnavailable.length > 0 ? 'retained-evidence-lower-bound' : 'captured-v8-counts', + blockEvidenceUnavailable, }; await writeFile(resolve(outputDirectory, 'coverage-summary.json'), JSON.stringify(summary, null, 2) + '\n'); return { summary, reportPath: resolve(outputDirectory, 'index.html') }; diff --git a/scripts/test-coverage/run.mjs b/scripts/test-coverage/run.mjs index 8882dfc..5c4a1fa 100644 --- a/scripts/test-coverage/run.mjs +++ b/scripts/test-coverage/run.mjs @@ -82,7 +82,7 @@ export async function runCoverage(mode, { reportOnly = false, requireTarget = fa if (!result.summary.gate.passed) { throw new Error('Coverage gate failed:\n' + result.summary.gate.failures.join('\n')); } - process.stdout.write('Staged coverage gate passed. Final target met: ' + result.summary.gate.targetMet + '\n'); + process.stdout.write('Coverage gate passed. Repository target met: ' + result.summary.gate.targetMet + '\n'); } else { process.stdout.write('Diagnostic report: thresholds were not enforced.\n'); } diff --git a/scripts/test-policy/migration-inventory.js b/scripts/test-policy/migration-inventory.js index 5436eda..8b0e5ec 100644 --- a/scripts/test-policy/migration-inventory.js +++ b/scripts/test-policy/migration-inventory.js @@ -1,165 +1,10 @@ -/** Existing suites awaiting behavioral migration; new files are strict by default. */ -export const legacyBehaviorGroups = [ - { - reason: 'Orderbook parsers, DOM adapters, options, and rendering still need scenario names and Given/When/Then organization.', - files: [ - 'test/dom/binance-orderbook-trade/account-orders.test.js', - 'test/dom/binance-orderbook-trade/chart-orders.test.js', - 'test/dom/binance-orderbook-trade/depth-profile.test.js', - 'test/dom/binance-orderbook-trade/orderbook-precision.test.js', - 'test/dom/binance-orderbook-trade/trade-form.test.js', - 'test/dom/binance-orderbook-trade/usdt-rebalance-dialog.test.js', - 'test/unit/binance-orderbook-trade/auto-open-leverage.test.js', - 'test/unit/binance-orderbook-trade/binance-native-depth-source.test.js', - 'test/unit/binance-orderbook-trade/binance-page-text.test.js', - 'test/unit/binance-orderbook-trade/cancel-all-dialog.test.js', - 'test/unit/binance-orderbook-trade/cancel-dialog-decision.test.js', - 'test/unit/binance-orderbook-trade/chart-marker-save-controller.test.js', - 'test/unit/binance-orderbook-trade/chart-marker-save-entrypoints.test.js', - 'test/unit/binance-orderbook-trade/chart-orders-recovery.test.js', - 'test/unit/binance-orderbook-trade/decimal.test.js', - 'test/unit/binance-orderbook-trade/depth-profile-book.test.js', - 'test/unit/binance-orderbook-trade/depth-profile-render-cycle.test.js', - 'test/unit/binance-orderbook-trade/depth-profile-session.test.js', - 'test/unit/binance-orderbook-trade/interaction-feedback.test.js', - 'test/unit/binance-orderbook-trade/ladder-options.test.js', - 'test/unit/binance-orderbook-trade/ladder-progress.test.js', - 'test/unit/binance-orderbook-trade/ladder.test.js', - 'test/unit/binance-orderbook-trade/open-order-capacity.test.js', - 'test/unit/binance-orderbook-trade/open-order-rows.test.js', - 'test/unit/binance-orderbook-trade/orderbook.test.js', - 'test/unit/binance-orderbook-trade/panel-copy.test.js', - 'test/unit/binance-orderbook-trade/panel-options.test.js', - 'test/unit/binance-orderbook-trade/precision.test.js', - 'test/unit/binance-orderbook-trade/route.test.js', - 'test/unit/binance-orderbook-trade/status-symbol.test.js', - 'test/unit/binance-orderbook-trade/trade-form.test.js', - 'test/unit/binance-orderbook-trade/tradingview-target.test.js', - 'test/unit/binance-orderbook-trade/ui-covering-array.test.js', - 'test/unit/binance-orderbook-trade/usdt-rebalance.test.js', - ], - }, - { - reason: 'Strategy 27/29 clients, lifecycle, transport, and chart integration retain existing assertions until each behavior is migrated.', - files: [ - 'test/dom/binance-strategy27-events/compound-candidate-controller.test.js', - 'test/dom/binance-strategy27-events/strategy27-entrypoint.test.js', - 'test/dom/binance-strategy27-events/strategy27-event-panel.test.js', - 'test/dom/binance-strategy27-events/tradingview-compound-layer.test.js', - 'test/dom/binance-strategy27-events/tradingview-event-layer.test.js', - 'test/dom/binance-strategy29-bollinger/runtime.test.js', - 'test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js', - 'test/dom/binance-strategy29-bollinger/summary-locale-position.test.js', - 'test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js', - 'test/unit/binance-strategy27-events/compound-candidate-annotation.test.js', - 'test/unit/binance-strategy27-events/compound-candidate-client.test.js', - 'test/unit/binance-strategy27-events/compound-candidate-contract.test.js', - 'test/unit/binance-strategy27-events/compound-candidate-lifecycle.test.js', - 'test/unit/binance-strategy27-events/event-annotation.test.js', - 'test/unit/binance-strategy27-events/live-event-client.test.js', - 'test/unit/binance-strategy27-events/live-event-contract.test.js', - 'test/unit/binance-strategy29-bollinger/bearish-bollinger-pattern.test.js', - 'test/unit/binance-strategy29-bollinger/coordination.test.js', - 'test/unit/binance-strategy29-bollinger/entry-sandbox.test.js', - 'test/unit/binance-strategy29-bollinger/remote-summary-client.test.js', - 'test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js', - 'test/unit/binance-strategy29-bollinger/remote-summary-controller.test.js', - ], - }, - { - reason: 'Shared data, media export, and offline evidence suites have not yet received complete behavioral organization.', - files: [ - 'test/dom/binance-trading-data-footer.test.js', - 'test/dom/coinmarketcap-valuation-helper.test.js', - 'test/dom/m3u8-media-scan.test.js', - 'test/unit/auto-refresh.test.js', - 'test/unit/binance-data-panel-lifecycle-regressions.test.js', - 'test/unit/binance-data-panel-route-regressions.test.js', - 'test/unit/binance-data-panel-symbols.test.js', - 'test/unit/binance-live-capture-builder.test.js', - 'test/unit/binance-live-capture-cli.test.js', - 'test/unit/binance-live-order-scale-config.test.js', - 'test/unit/binance-live-performance-probe.test.js', - 'test/unit/binance-live-performance.test.js', - 'test/unit/binance-signal-client-settings.test.js', - 'test/unit/binance-stage3-evidence.test.js', - 'test/unit/binance-symbol.test.js', - 'test/unit/binance-ui-workflow.test.js', - 'test/unit/brooks-media-audit.test.js', - 'test/unit/brooks-media-download.test.js', - 'test/unit/brooks-media-import-index.test.js', - 'test/unit/m3u8-downloader-course-export.test.js', - 'test/unit/signal-gateway-bridge.test.js', - 'test/unit/spa-route-change.test.js', - ], - }, - { - reason: 'Metadata/build assertions remain useful. The two source-regressions files also contain behavioral checks that still need migration; they are not wholly architectural tests.', - files: [ - 'test/unit/binance-orderbook-trade/source-regressions.test.js', - 'test/unit/binance-strategy29-bollinger/source-regressions.test.js', - 'test/unit/binance-shared-route-architecture.test.js', - 'test/unit/userscript-metadata-icons.test.js', - 'test/unit/userscript-release-contract.test.js', - ], - }, -]; +/** All existing and new suites now follow the strict behavior policy. */ +export const legacyBehaviorGroups = []; export const legacyBehaviorFiles = legacyBehaviorGroups.flatMap((group) => group.files); -/** Counts must shrink with migrations; moving or adding a target fails lint. */ -export const legacyCallAllowances = [ - { - file: 'test/dom/binance-trading-data-footer.test.js', - rule: 'no-uncontracted-mocks', - allow: [{ target: 'method:Date:now', count: 1, reason: 'The extracted footer harness owns Date.now; migrate its elapsed-time behavior to mock.timers with the complete panel harness.' }], - }, - { - file: 'test/dom/binance-strategy27-events/compound-candidate-controller.test.js', - rule: 'no-uncontracted-mocks', - allow: [{ target: 'method:globalThis.crypto.subtle:digest', count: 1, reason: 'One lifecycle-hash race pauses the real digest; move this pause into a contract-tested crypto boundary fixture.' }], - }, - { - file: 'test/dom/binance-strategy27-events/strategy27-entrypoint.test.js', - rule: 'no-uncontracted-mocks', - allow: [ - { target: 'method:Date:now', count: 1, reason: 'Entrypoint lifecycle aging still uses an old manual clock; migrate to the shared deterministic clock boundary.' }, - { target: 'method:page:setInterval', count: 1, reason: 'The entrypoint harness captures its owned interval; replace the paired timer overrides with a tested clock fixture.' }, - { target: 'method:page:clearInterval', count: 1, reason: 'The entrypoint harness removes its owned interval; migrate together with its setInterval boundary.' }, - { target: 'method:h.page.document:querySelectorAll', count: 1, reason: 'The existing DOM-query budget probe counts real querySelectorAll calls; move instrumentation to the chart fixture contract.' }, - { target: 'method:h.page:prompt', count: 1, reason: 'The current sandbox test rejects page-realm prompts; migrate to a dedicated prompt boundary fixture with rejection assertions.' }, - ], - }, - { - file: 'test/unit/m3u8-downloader-course-export.test.js', - rule: 'no-fixed-waits', - allow: [ - { target: 'setTimeout(20)', count: 19, reason: 'The legacy userscript VM export harness settles network and DOM turns by elapsed time; migrate to explicit export/download completion events.' }, - { target: 'setTimeout(650)', count: 1, reason: 'One legacy reset/export pacing scenario uses real elapsed time; migrate its scheduling contract to a virtual clock.' }, - { target: 'setTimeout(1100)', count: 1, reason: 'One legacy active-runtime scenario crosses a real second; migrate runtime accounting to a virtual clock.' }, - ], - }, - { - file: 'test/dom/binance-strategy29-bollinger/runtime.test.js', - rule: 'no-fixed-waits', - allow: [{ target: 'f.view.setTimeout(0)', count: 5, reason: 'Legacy remote-client/DOM integration waits for browser turns; expose request and render completion gates before migrating these scenarios.' }], - }, - { - file: 'test/unit/binance-orderbook-trade/trade-form.test.js', - rule: 'no-fixed-waits', - allow: [{ target: 'dom.window.setTimeout(0)', count: 3, reason: 'Legacy form request and MutationObserver scenarios settle through JSDOM turns; replace each with its observed completion signal.' }], - }, - { - file: 'test/unit/binance-orderbook-trade/cancel-all-dialog.test.js', - rule: 'no-fixed-waits', - allow: [{ target: 'dom.window.setTimeout(0)', count: 1, reason: 'One legacy negative MutationObserver scenario flushes unrelated DOM churn; migrate to an explicit delivered-mutation signal.' }], - }, - { - file: 'test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js', - rule: 'no-fixed-waits', - allow: [{ target: 'setTimeout(0)', count: 1, reason: 'One legacy render-batch test verifies a real browser-task yield; migrate its scheduling boundary without replacing the yield with a microtask.' }], - }, -]; +/** All previously allowed method replacements and fixed waits are migrated. */ +export const legacyCallAllowances = []; /** Host measurement needs one real task boundary after the observed interaction. */ export const contractCallAllowances = [ diff --git a/src/binance-orderbook-trade/core/binance-native-depth-source.js b/src/binance-orderbook-trade/core/binance-native-depth-source.js index aefaccb..1cbca87 100644 --- a/src/binance-orderbook-trade/core/binance-native-depth-source.js +++ b/src/binance-orderbook-trade/core/binance-native-depth-source.js @@ -181,6 +181,8 @@ export function installBinanceNativeDepthSource(globalObject) { const observedFetch = new Proxy(nativeFetch, { apply(target, receiver, args) { + // The page can retain a wrapper after its global transport has been restored. + if (restored) return Reflect.apply(target, receiver, args); let observation = null; try { observation = resolveNativeSnapshotSymbol(args[0], baseUrl); @@ -205,7 +207,9 @@ export function installBinanceNativeDepthSource(globalObject) { return response.clone().json(); }).then( (payload) => acceptSnapshot(symbol, payload), - (error) => failRecord(ensureRecord(symbol), error), + (error) => { + if (!restored) failRecord(ensureRecord(symbol), error); + }, ); } return result; @@ -215,7 +219,7 @@ export function installBinanceNativeDepthSource(globalObject) { const ObservedWebSocket = new Proxy(NativeWebSocket, { construct(target, args, newTarget) { const socket = Reflect.construct(target, args, newTarget); - observeSocket(socket); + if (!restored) observeSocket(socket); return socket; }, }); @@ -225,6 +229,7 @@ export function installBinanceNativeDepthSource(globalObject) { return { subscribe(options) { + if (restored) throw new Error('Binance native depth source has been restored'); const { symbol, onProfile, @@ -235,7 +240,6 @@ export function installBinanceNativeDepthSource(globalObject) { onProfile: assertFunction(onProfile, 'profile listener'), onStatus: assertFunction(onStatus, 'status listener'), }; - if (restored) throw new Error('Binance native depth source has been restored'); record.subscribers.add(subscriber); subscriber.onStatus(record.status); if (record.profile) subscriber.onProfile(record.profile); diff --git a/src/binance-orderbook-trade/core/order-feedback.js b/src/binance-orderbook-trade/core/order-feedback.js index 8e1ed53..8d86e38 100644 --- a/src/binance-orderbook-trade/core/order-feedback.js +++ b/src/binance-orderbook-trade/core/order-feedback.js @@ -108,10 +108,11 @@ export function readConfirmedReduceOnlyRejection(mode, observation, successes) { return apiError; } -function parseRetryAfterMs(value) { - if (value == null || value === '') return null; +/** Missing or unusable headers retain Binance's ten-second rate-limit backoff. */ +export function resolveBinanceRateLimitCooldownMs(value) { + if (value == null || value === '') return 10000; const seconds = Number(value); - return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null; + return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : 10000; } /** @@ -130,7 +131,7 @@ export function resolveBinanceSubmitResponseRecovery(diagnostics, apiErrors) { if (rateLimitDiagnostic || hasRateLimitCode) { return { kind: 'rate_limited', - cooldownMs: parseRetryAfterMs(rateLimitDiagnostic?.retryAfter) ?? 10000, + cooldownMs: resolveBinanceRateLimitCooldownMs(rateLimitDiagnostic?.retryAfter), }; } if (diagnostics.some(({ httpStatus }) => httpStatus >= 500 && httpStatus <= 599)) { diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index d686db7..d8fc020 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.209 +// @version 2.7.211 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -35,6 +35,7 @@ import { import { BINANCE_PAGE_TEXT, buildBinanceTextAlternation, + hasBinanceCurrentSymbolOpenOrdersEmptyText, includesBinancePageText, includesCompactBinancePageText, isBinanceCancelAllText, @@ -134,6 +135,7 @@ import { isReduceOnlyOpenOrdersConflictFeedback, isPotentialOrderFeedbackText, readConfirmedReduceOnlyRejection, + resolveBinanceRateLimitCooldownMs, resolveBinanceSubmitResponseRecovery, summarizeBinancePlaceOrderPayload, } from './core/order-feedback.js'; @@ -1816,8 +1818,8 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; function ensureOrderbookPrecisionObserver() { if (document.hidden || !isFuturesTradingPage()) return; - const trigger = findOrderbookPrecisionTrigger(); - const root = trigger?.element?.closest('.orderbook-tickSize') || trigger?.element || null; + // Native text can be temporarily empty while this same precision root updates. + const root = document.querySelector('#futuresOrderbook .orderbook-tickSize'); if (!root) { if (orderbookPrecisionObserverRoot) stopOrderbookPrecisionObserver(); return; @@ -2541,11 +2543,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; rateLimited ? '仓位确认请求频率受限' : '仓位确认暂未完成', ); if (rateLimited) { - const retryAfterSeconds = Number(error.retryAfter); - recoveryError.continuousRecoveryCooldownMs = Number.isFinite(retryAfterSeconds) - && retryAfterSeconds >= 0 - ? retryAfterSeconds * 1000 - : 10000; + recoveryError.continuousRecoveryCooldownMs = resolveBinanceRateLimitCooldownMs(error.retryAfter); } recoveryError.skipImmediateCloseRecheck = true; throw recoveryError; @@ -6057,9 +6055,30 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; await restoreOpenOrdersSubTab(previousOpenOrdersSubTabIdentity, symbol); } if (previousOpenOrdersScrollTop !== null) { - openOrdersScope = await waitForActiveOpenOrdersScope(); - const scrollContainer = findOpenOrderRowsScrollContainer(openOrdersScope); - if (scrollContainer) { + // Restored filters and tabs commit before rows mount. The restored list + // can include other symbols or Conditional orders, so it has no current-symbol filter requirement. + const restoredRows = await waitForAccountOrdersState(() => { + if (!isCurrentObservedSymbol(symbol)) return null; + const root = getActiveOpenOrdersScope(); + if (!root) return null; + if (previousOpenOrdersSubTabIdentity && getOpenOrdersSubTabIdentity( + findSelectedOpenOrdersSubTab(root), + ) !== previousOpenOrdersSubTabIdentity) return null; + if (symbolFilterOriginalChecked !== null && getCheckboxCheckedState( + findHideOtherSymbolCheckbox(root), + ) !== symbolFilterOriginalChecked) return null; + const hasRows = readOpenOrderRowElements(root).length > 0; + const empty = !hasRows + && !findCurrentSymbolCancelAllButton(root) + && hasBinanceCurrentSymbolOpenOrdersEmptyText(readOpenOrdersScopeText(root)); + if (!hasRows && !empty) return null; + return { root, scrollContainer: findOpenOrderRowsScrollContainer(root) }; + }, 2200); + const scrollContainer = restoredRows?.scrollContainer; + if (isCurrentObservedSymbol(symbol) + && restoredRows?.root === getActiveOpenOrdersScope() + && scrollContainer?.isConnected + && restoredRows.root.contains(scrollContainer)) { scrollContainer.scrollTop = Math.min( previousOpenOrdersScrollTop, scrollContainer.scrollHeight, @@ -7400,16 +7419,21 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; const actionDisabled = ladderRunning || !!singleOrderTask || cancelCurrentSymbolOpenOrdersBlocksLadderActions; + const activeActionType = activeLadderActionType || activeContinuousLadderActionType; + // Stop belongs to the running task even when fresh numeric inputs are unavailable. + const activeStopButtons = activeActionType + ? [ladderExecutionButton(activeActionType)] + : []; if (!['OPEN', 'CLOSE'].includes(tradeMode)) { return { optionRows: [`
${ui(PANEL_COPY.state.waitingTradeMode)}
`], - actionButtons: [], + actionButtons: activeStopButtons, }; } if (!precision) { return { optionRows: [`
${ui(PANEL_COPY.state.waitingPricePrecision)}
`], - actionButtons: [], + actionButtons: activeStopButtons, }; } if (tradeMode === 'OPEN') { diff --git a/src/m3u8-downloader/index.user.js b/src/m3u8-downloader/index.user.js index 3a85a21..a535c5b 100644 --- a/src/m3u8-downloader/index.user.js +++ b/src/m3u8-downloader/index.user.js @@ -3,7 +3,7 @@ // @namespace https://github.com/jackhai9/userscripts // @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 0.10.38 +// @version 0.10.40 // @description m3u8 下载增强脚本,仅在白名单视频站启用,避免误伤交易页等重前端应用 // @author jackhai9 // @include https://18jav.tv/* @@ -21,6 +21,7 @@ import { M3U8_MESSAGE_TYPE } from './constants.js' import { + buildCaptionUrlFromM3u8, buildExternalDownloaderUrl, getParentMessageTargetOrigin, getYtDlpOutputName, @@ -350,7 +351,8 @@ import { createBrooksMediaExporter } from './brooks-exporter.js' console.log(`Saving as: ${filename}`); // 使用 XMLHttpRequest 替代 fetch - return new Promise((resolve, reject) => { + // Await so asynchronous XHR failures reach this caption job's error boundary. + return await new Promise((resolve, reject) => { let xhr = new originXHR();// 使用原始的 XMLHttpRequest xhr.open('GET', url, true); xhr.responseType = 'text'; diff --git a/test/dom/binance-data-panels-entrypoint.test.js b/test/dom/binance-data-panels-entrypoint.test.js new file mode 100644 index 0000000..d3b0f37 --- /dev/null +++ b/test/dom/binance-data-panels-entrypoint.test.js @@ -0,0 +1,280 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createDataPanelHost, tradingDataset, completeTradingBatch, cmcDetail, completeCmcData, +} from '../helpers/data-media-migration-host.js'; + +async function activateTrading(host, dataset = tradingDataset(Date.now()), options) { + const time = await host.network.waitForRequest(request => !request.settled && request.url.pathname.endsWith('/time')); + time.respond({ serverTime: Date.now() }); + await completeTradingBatch(host, dataset, options); + await host.rendered(panel => panel?.querySelector('[data-role="updated-at"]')?.textContent.startsWith('更新于')); +} + +for (const scenario of [ + { name: 'long', values: {}, oi: '2.00M ▲', composite: '偏多 7:0' }, + { name: 'short', values: { oi: 2_000, previousOi: 4_000, ratio: 0.5, basis: -0.01, funding: 0.0002 }, oi: '2K ▼', composite: '偏空 0:7' }, + { name: 'neutral', values: { oi: 50, previousOi: 50, ratio: 1, basis: 0, funding: 0, supply: 0 }, oi: '50.00', composite: '中性 0:0' }, + { name: 'billion', values: { oi: 2_000_000_000 }, oi: '2.00B ▲', composite: '偏多 7:0' }, +]) { + test(`user sees ${scenario.name} indicators and the corresponding fresh directional votes`, { timeout: 5_000 }, async t => { + // Given a futures page with complete data for one directional scenario + const host = createDataPanelHost(t, 'trading'); + const dataset = tradingDataset(Date.now(), scenario.values); + + // When the complete installed script receives all seven endpoint responses + await host.start(); + await activateTrading(host, dataset); + + // Then the visible quantities, votes, and endpoint parameters match that data + assert.match(host.element('rows').textContent, new RegExp(scenario.oi.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + assert.match(host.element('composite').textContent, new RegExp(scenario.composite)); + assert.equal(host.element('symbol').textContent, 'BTCUSDT'); + assert.equal(host.element('rows').children.length, 8); + const basisRequest = host.network.requests.find(request => request.url.pathname.endsWith('/basis')); + assert.equal(basisRequest.url.searchParams.get('pair'), 'BTCUSDT'); + assert.equal(basisRequest.url.searchParams.get('contractType'), 'PERPETUAL'); + assert.equal(basisRequest.url.searchParams.get('period'), '5m'); + }); +} + +test('user sees missing endpoint data explicitly and receives no retry for client errors', { timeout: 5_000 }, async t => { + // Given all endpoints return a deterministic 400 error for the current symbol + const host = createDataPanelHost(t, 'trading'); + const dataset = tradingDataset(Date.now()); + const statuses = Object.fromEntries(Object.keys(dataset).map(key => [key, 400])); + + // When the first complete fetch fails at each endpoint + await host.start(); + await activateTrading(host, dataset, { status: statuses }); + + // Then missing rows remain visible and cannot contribute directional votes + assert.equal([...host.element('rows').children].every(row => row.children[1].textContent === '--'), true); + assert.match(host.element('composite').textContent, /中性 0:0/); + assert.equal(host.network.requests.length, 8); + assert.equal(host.errors.length, 7); +}); + +test('user sees cached rows without their directional votes after a failed refresh', { timeout: 5_000 }, async t => { + // Given a completed bullish fetch with data available in the current-symbol cache + const host = createDataPanelHost(t, 'trading'); + await host.start(); + await activateTrading(host); + host.setHidden(true); + + // When returning to the page encounters failures for every endpoint + host.setHidden(false); + const dataset = tradingDataset(Date.now()); + await activateTrading(host, dataset, { status: Object.fromEntries(Object.keys(dataset).map(key => [key, 400])) }); + await host.rendered(() => host.element('composite').textContent.includes('中性 0:0')); + + // Then cached values are retained with hollow markers and no fresh votes + assert.match(host.element('rows').textContent, /2\.00M ▲/); + assert.equal([...host.element('rows').children].every(row => row.lastElementChild.style.background === 'transparent'), true); + assert.match(host.element('composite').textContent, /中性 0:0/); +}); + +test('user receives one retry for transient endpoint errors and then sees fresh data', { timeout: 5_000 }, async t => { + // Given a trading panel whose first endpoint request has a server failure + const host = createDataPanelHost(t, 'trading'); + await host.start(); + const time = await host.network.waitForRequest(request => request.url.pathname.endsWith('/time')); + time.respond({ serverTime: Date.now() }); + const dataset = tradingDataset(Date.now()); + + // When the initial request fails and its single recovery request succeeds + await completeTradingBatch(host, dataset, { status: { openInterestHist: 503 } }); + const retry = await host.network.waitForRequest(request => !request.settled && request.url.pathname.endsWith('/openInterestHist')); + retry.respond(dataset.openInterestHist); + await host.rendered(panel => panel?.querySelector('[data-role="updated-at"]')?.textContent.startsWith('更新于')); + + // Then the rendered value is fresh and exactly two calls targeted that endpoint + assert.match(host.element('rows').textContent, /2\.00M ▲/); + assert.equal(host.network.requests.filter(request => request.url.pathname.endsWith('/openInterestHist')).length, 2); + assert.match(host.element('composite').textContent, /偏多 7:0/); +}); + +test('user sees the current period fetched only after the server boundary delay', { timeout: 5_000 }, async t => { + // Given the page opens exactly on a five-minute boundary with the previous period + const host = createDataPanelHost(t, 'trading'); + await host.start(); + await activateTrading(host, tradingDataset(Date.now() - 300_000)); + const previousCount = host.network.requests.length; + + // When virtual time approaches and then reaches the five-second publication delay + host.clock.tick(4_999); + assert.equal(host.network.requests.length, previousCount); + host.clock.tick(1); + await completeTradingBatch(host, tradingDataset(Date.now(), { ratio: 0.5 })); + await host.rendered(() => host.element('composite').textContent.includes('偏空 3:4')); + + // Then one new batch updates the votes and highlights the changed ratios + assert.equal(host.network.requests.length, previousCount + 7); + assert.match(host.element('composite').textContent, /偏空 3:4/); + assert.equal(host.element('rows').querySelectorAll('.jh-td-flash').length, 4); +}); + +for (const kind of ['trading', 'cmc']) { + test(`user activates the ${kind} panel only on a trading route and retains their collapse choice`, { timeout: 5_000 }, async t => { + // Given an idle wallet route with an existing saved collapsed preference + const prefix = kind === 'trading' ? 'jh_binance_trading_data' : 'jh_binance_cmc_data'; + const host = createDataPanelHost(t, kind, { path: '/zh-CN/futures', storage: { [`${prefix}_collapsed`]: '1', [`${prefix}_pos`]: '{invalid' } }); + await host.start(); + assert.equal(host.panel(), null); + assert.equal(host.network.requests.length, 0); + + // When navigation enters a trading route and the upstream data arrives + host.navigate('/zh-CN/futures/BTCUSDT'); + if (kind === 'trading') await activateTrading(host); + else await completeCmcData(host); + assert.equal(host.element('body').style.display, 'none'); + host.element('collapse').click(); + + // Then expanding is persisted and leaving the trading route removes the panel + assert.equal(host.element('body').style.display, 'block'); + assert.equal(host.window.localStorage.getItem(`${prefix}_collapsed`), '0'); + host.element('collapse').click(); + assert.equal(host.window.localStorage.getItem(`${prefix}_collapsed`), '1'); + host.navigate('/zh-CN/my/wallet/futures/overview'); + assert.equal(host.panel(), null); + const count = host.network.requests.length; + host.clock.tick(60_000); + assert.equal(host.network.requests.length, count); + }); + + test(`user closes the ${kind} panel without visibility or route events restarting it`, { timeout: 5_000 }, async t => { + // Given a running panel with one completed data refresh + const host = createDataPanelHost(t, kind); + await host.start(); + if (kind === 'trading') await activateTrading(host); + else await completeCmcData(host); + + // When the user closes it, hides the document, and navigates while returning + host.element('close').click(); + const count = host.network.requests.length; + host.setHidden(true); + host.navigate('/zh-CN/futures/ETHUSDT'); + host.setHidden(false); + host.clock.tick(3_600_000); + + // Then it remains closed and none of the business timers request more data + assert.equal(host.panel().style.display, 'none'); + assert.equal(host.network.requests.length, count); + }); + + test(`user drags the ${kind} panel within the viewport and stops dragging after route removal`, { timeout: 5_000 }, async t => { + // Given a visible panel with a valid persisted position + const prefix = kind === 'trading' ? 'jh_binance_trading_data' : 'jh_binance_cmc_data'; + const host = createDataPanelHost(t, kind, { storage: { [`${prefix}_pos`]: '{"left":40,"top":80}' } }); + await host.start(); + if (kind === 'trading') await activateTrading(host); + else await completeCmcData(host); + const panel = host.panel(); + const mouse = (target, type, x, y) => target.dispatchEvent(new host.window.MouseEvent(type, { bubbles: true, clientX: x, clientY: y })); + + // When dragging updates the position and navigation removes the panel + mouse(host.element('header'), 'mousedown', 10, 10); + mouse(host.document, 'mousemove', 100, 120); + mouse(host.document, 'mousemove', 130, 150); + host.clock.tick(16); + mouse(host.document, 'mouseup', 130, 150); + host.window.dispatchEvent(new host.window.Event('beforeunload')); + host.window.dispatchEvent(new host.window.Event('resize')); + const beforeRemoval = panel.style.cssText; + host.navigate('/zh-CN/futures'); + mouse(host.document, 'mousemove', 500, 600); + mouse(host.document, 'mouseup', 500, 600); + + // Then the retained detached element no longer responds to drag events + assert.equal(host.panel(), null); + assert.equal(panel.style.cssText, beforeRemoval); + assert.deepEqual(Object.keys(JSON.parse(host.window.localStorage.getItem(`${prefix}_pos`))).sort(), ['left', 'top']); + }); +} + +test('user sees CMC API provenance and all valuation rows after a deterministic asset mapping', { timeout: 5_000 }, async t => { + // Given a Bitcoin futures route with a unique active CMC mapping + const host = createDataPanelHost(t, 'cmc'); + + // When the complete installed script receives map, detail, and holder responses + await host.start(); + await completeCmcData(host); + + // Then values, ranking, holder count, and source appear in the real panel DOM + assert.equal(host.element('symbol').textContent, 'BTC #1'); + assert.equal(host.element('rows').children.length, 12); + assert.match(host.element('rows').textContent, /流通市值\$1\.2万亿-1\.00%/); + assert.match(host.element('rows').textContent, /持有者1万/); + assert.match(host.element('rows').textContent, /Profile score85%/); + assert.equal(host.element('footer').querySelector('a').href, 'https://coinmarketcap.com/zh/currencies/bitcoin/'); + assert.match(host.element('footer').textContent, /CMC data-api/); +}); + +for (const scenario of [ + { name: 'missing', rows: [], error: 'CMC symbol not found: BTC' }, + { name: 'ambiguous', rows: [{ id: 1, symbol: 'BTC', slug: 'bitcoin', is_active: 1 }, { id: 2, symbol: 'BTC', slug: 'other', is_active: 1 }], error: 'CMC symbol ambiguous: BTC' }, + { name: 'inactive', rows: [{ id: 1, symbol: 'BTC', slug: 'bitcoin', is_active: 0 }], error: 'CMC symbol not found: BTC' }, +]) { + test(`user sees an explicit error for a ${scenario.name} CMC asset mapping`, { timeout: 5_000 }, async t => { + // Given the current symbol has no single valid mapping response + const host = createDataPanelHost(t, 'cmc'); + + // When the map endpoint returns the specified candidate set + await host.start(); + const request = await host.network.waitForRequest(request => request.url.pathname.endsWith('/map')); + request.respond({ data: scenario.rows }); + await host.rendered(() => host.element('rows').textContent.includes(scenario.error)); + + // Then the panel reports that exact reason without choosing another asset + assert.equal(host.element('rows').textContent, `读取失败${scenario.error}`); + assert.equal(host.network.requests.length, 1); + }); +} + +for (const outcome of ['http', 'network', 'timeout', 'json', 'statistics']) { + test(`user sees page-snapshot provenance when the CMC API has a ${outcome} failure`, { timeout: 5_000 }, async t => { + // Given the RAVE override resolves directly to its documented asset + const host = createDataPanelHost(t, 'cmc', { path: '/futures/RAVEUSDT' }); + const detail = cmcDetail({ id: 38967, symbol: 'RAVE', showTreasuriesFlag: true, treasuryHoldings: 1200 }); + + // When the API fails and the page snapshot supplies valid detail statistics + await host.start(); + const api = await host.network.waitForRequest(request => request.url.pathname.endsWith('/detail')); + if (outcome === 'http') api.respond({}, 503); + else if (outcome === 'network') api.fail('error'); + else if (outcome === 'timeout') api.fail('timeout'); + else if (outcome === 'json') api.respond('{invalid'); + else api.respond({ data: {} }); + const page = await host.network.waitForRequest(request => request.url.hostname === 'coinmarketcap.com'); + page.respond(``); + await host.rendered(() => host.element('footer').textContent.includes('CMC 页面快照')); + + // Then the source remains visibly a page snapshot and the override bypasses mapping + assert.equal(host.element('footer').querySelector('a').href, 'https://coinmarketcap.com/zh/currencies/ravedao/'); + assert.match(host.element('rows').textContent, /金库资产1200 RAVE/); + assert.equal(host.network.requests.length, 2); + }); +} + +test('user can refresh CMC data from the cached asset mapping without losing their current rows', { timeout: 5_000 }, async t => { + // Given one successful refresh with a cached symbol mapping + const host = createDataPanelHost(t, 'cmc'); + await host.start(); + await completeCmcData(host); + const previousRows = host.element('rows').textContent; + + // When a scheduled refresh starts and receives updated market statistics + host.clock.tick(30_000); + assert.equal(host.element('rows').textContent, previousRows); + const detail = cmcDetail({ profileCompletionScore: 95, holders: { holderCount: 50 } }); + detail.statistics.price = -123; + detail.statistics.rank = 'unknown'; + await completeCmcData(host, detail, { map: false, holder: { showFlag: false } }); + await host.rendered(() => host.element('rows').textContent.includes('-$123')); + + // Then the new value appears and no second map request was made + assert.match(host.element('rows').textContent, /价格-\$123/); + assert.equal(host.element('symbol').textContent, 'BTC'); + assert.match(host.element('rows').textContent, /Profile score95%/); + assert.equal(host.network.requests.filter(request => request.url.pathname.endsWith('/map')).length, 1); +}); diff --git a/test/dom/binance-data-panels-responses.test.js b/test/dom/binance-data-panels-responses.test.js new file mode 100644 index 0000000..727575f --- /dev/null +++ b/test/dom/binance-data-panels-responses.test.js @@ -0,0 +1,360 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + activateTradingData, afterDataMediaResponseTurn, completeCmcData, completeTradingBatch, + createDataPanelHost, cmcDetail, tradingDataset, +} from '../helpers/data-media-migration-host.js'; + +for (const scenario of [ + { name: 'an empty body', body: '', error: 'CMC symbol not found: BTC' }, + { name: 'a null payload', body: null, error: 'CMC symbol not found: BTC' }, + { name: 'a non-array map', body: { data: {} }, error: 'CMC symbol not found: BTC' }, + { name: 'only incomplete and inactive rows', body: { data: [null, { is_active: 1 }, { is_active: 0, symbol: 'BTC' }] }, error: 'CMC symbol not found: BTC' }, + { name: 'a matching asset without an ID', body: { data: [{ symbol: 'BTC', slug: 'bitcoin', is_active: 1 }] }, error: '无法识别当前合约' }, + { name: 'a matching asset without a slug', body: { data: [{ id: 1, symbol: 'BTC', is_active: 1 }] }, error: '无法识别当前合约' }, +]) { + test(`user sees an explicit asset error when CMC mapping returns ${scenario.name}`, { timeout: 5_000 }, async t => { + // Given the current contract requires one complete active asset mapping + const host = createDataPanelHost(t, 'cmc'); + await host.start(); + const mapping = await host.network.waitForRequest(request => request.url.pathname.endsWith('/map')); + + // When the upstream mapping cannot identify a usable current asset + mapping.respond(scenario.body); + await host.rendered(() => host.element('rows').textContent.includes(scenario.error)); + + // Then the precise failure is visible and no guessed detail or page request is issued + assert.equal(host.element('rows').textContent, `读取失败${scenario.error}`); + assert.equal(host.network.requests.length, 1); + assert.equal(host.element('symbol').textContent, 'BTCUSDT'); + }); +} + +test('user resolves the one complete active CMC asset among incomplete unrelated rows', { timeout: 5_000 }, async t => { + // Given the mapping endpoint returns null, inactive, and symbol-less rows beside Bitcoin + const host = createDataPanelHost(t, 'cmc'); + await host.start(); + const mapping = await host.network.waitForRequest(request => request.url.pathname.endsWith('/map')); + + // When the one active complete mapping and its detail endpoints succeed + mapping.respond({ data: [null, { is_active: 1 }, { id: 9, symbol: 'BTC', slug: 'inactive', is_active: 0 }, { id: 1, symbol: ' btc ', slug: ' bitcoin ', is_active: 1 }] }); + await completeCmcData(host, cmcDetail(), { map: false }); + + // Then the normalized Bitcoin mapping controls the public detail request and source link + assert.equal(host.network.requests[1].url.searchParams.get('id'), '1'); + assert.equal(host.element('symbol').textContent, 'BTC #1'); + assert.equal(host.element('footer').querySelector('a').href, 'https://coinmarketcap.com/zh/currencies/bitcoin/'); + assert.equal(host.network.requests.length, 3); +}); + +test('user keeps valid CMC valuation data when its detail cannot identify a holder endpoint', { timeout: 5_000 }, async t => { + // Given Bitcoin is mapped correctly but the detail response omits its holder lookup ID + const host = createDataPanelHost(t, 'cmc'); + await host.start(); + host.network.requests[0].respond({ data: [{ id: 1, symbol: 'BTC', slug: 'bitcoin', is_active: 1 }] }); + const request = await host.network.waitForRequest(request => request.url.pathname.endsWith('/detail')); + const detail = cmcDetail({ holders: { total: 17 } }); + delete detail.id; + + // When the valid valuation statistics render without a supplemental holder request + request.respond({ data: detail }); + await host.rendered(() => host.element('footer').textContent.includes('CMC data-api')); + + // Then the existing count and valuation rows remain usable without an invalid-ID request + assert.match(host.element('rows').textContent, /价格\$6万/); + assert.match(host.element('rows').textContent, /持有者17/); + assert.equal(host.network.requests.length, 2); + assert.equal(host.network.requests.some(request => request.url.pathname.endsWith('/show_holders')), false); +}); + +test('user keeps the new CMC symbol when the superseded mapping fails late', { timeout: 5_000 }, async t => { + // Given Bitcoin mapping is pending while the user switches to Ethereum + const host = createDataPanelHost(t, 'cmc'); + await host.start(); + const oldMapping = host.network.requests[0]; + host.navigate('/zh-CN/futures/ETHUSDT'); + await completeCmcData(host, cmcDetail({ id: 2, symbol: 'ETH' }), { symbol: 'ETH', slug: 'ethereum' }); + const rows = host.element('rows').innerHTML; + const footer = host.element('footer').innerHTML; + + // When the old Bitcoin request reports a transport failure after Ethereum has rendered + oldMapping.fail('error'); + await afterDataMediaResponseTurn(); + + // Then the stale rejection cannot replace current rows, identity, or provenance + assert.equal(host.element('rows').innerHTML, rows); + assert.equal(host.element('footer').innerHTML, footer); + assert.equal(host.element('symbol').textContent, 'ETH #1'); + assert.equal(host.network.requests.length, 4); +}); + +for (const scenario of [ + { name: 'HTTP rejection', respond: request => request.respond('', 403), error: 'CMC HTTP 403' }, + { name: 'network failure', respond: request => request.fail('error'), error: 'CMC request failed' }, + { name: 'timeout', respond: request => request.fail('timeout'), error: 'CMC request timeout' }, + { name: 'empty page', respond: request => request.respond(''), error: 'CMC page missing __NEXT_DATA__' }, + { name: 'absent statistics', respond: request => request.respond(''), error: 'CMC page missing detail statistics' }, +]) { + test(`user sees the exact CMC page-snapshot failure after a ${scenario.name}`, { timeout: 5_000 }, async t => { + // Given the API is unavailable for the documented RAVE asset override + const host = createDataPanelHost(t, 'cmc', { path: '/futures/RAVEUSDT' }); + await host.start(); + const api = await host.network.waitForRequest(request => request.url.pathname.endsWith('/detail')); + api.respond({}, 503); + const page = await host.network.waitForRequest(request => request.url.hostname === 'coinmarketcap.com'); + + // When the fallback page fails in the specified observable way + scenario.respond(page); + await host.rendered(() => host.element('rows').textContent.includes(scenario.error)); + + // Then the original page failure reason remains visible to the user + assert.equal(host.element('rows').textContent, `读取失败${scenario.error}`); + assert.equal(host.network.requests.length, 2); + assert.equal(host.element('footer').textContent, '来源:CoinMarketCap 中文页'); + }); +} + +for (const holderKey of ['total', 'count']) { + test(`user sees large and negative CMC supply values with the available holder ${holderKey}`, { timeout: 5_000 }, async t => { + // Given API details supply large token amounts and an alternate supported holder field + const host = createDataPanelHost(t, 'cmc'); + const detail = cmcDetail({ holders: { [holderKey]: 42 }, profileCompletionScore: { percentage: 'unavailable' }, latestUpdateTime: 'unavailable' }); + Object.assign(detail.statistics, { totalSupply: 1_200_000_000_000, maxSupply: 300_000_000, circulatingSupply: -20_000 }); + + // When detail data succeeds and the dedicated holder endpoint publishes no count + await host.start(); + await completeCmcData(host, detail, { holder: { showFlag: false } }); + + // Then unit formatting, negative signs, unavailable fields, and fallback holder identity are preserved + const text = host.element('rows').textContent; + assert.match(text, /总供应量1\.2万亿 BTC/); + assert.match(text, /最大供应量3亿 BTC/); + assert.match(text, /流通供应量-2万 BTC/); + assert.match(text, /持有者42/); + assert.match(text, /Profile score--/); + assert.match(host.element('footer').textContent, /CMC -- \/ 拉取/); + }); +} + +test('user sees explicit unavailable metrics when an otherwise valid CMC response omits statistics fields', { timeout: 5_000 }, async t => { + // Given a valid asset detail has no optional statistic, holder, timestamp, or score fields + const host = createDataPanelHost(t, 'cmc'); + const detail = { id: 1, statistics: {} }; + await host.start(); + + // When the detail response renders with no separately published holder count + await completeCmcData(host, detail, { holder: { showFlag: false } }); + + // Then unavailable values stay visible instead of becoming directional changes or guessed quantities + assert.equal(host.element('symbol').textContent, 'BTC'); + assert.equal([...host.element('rows').children].every(row => row.children[1].textContent === '--'), true); + assert.match(host.element('footer').textContent, /CMC -- \/ 拉取/); +}); + +test('user retains CMC detail rows when the optional holder endpoint times out', { timeout: 5_000 }, async t => { + // Given the map and detail APIs have already succeeded + const host = createDataPanelHost(t, 'cmc', { path: '/futures/RAVEUSDT' }); + await host.start(); + host.network.requests[0].respond({ data: cmcDetail({ id: 38967, symbol: 'RAVE' }) }); + const holder = await host.network.waitForRequest(request => request.url.pathname.endsWith('/show_holders')); + + // When only the supplemental holder request times out + holder.fail('timeout'); + await host.rendered(() => host.element('footer').textContent.includes('CMC data-api')); + + // Then the successful valuation details remain visible with an unavailable holder metric + assert.match(host.element('rows').textContent, /流通市值\$1\.2万亿/); + assert.match(host.element('rows').textContent, /持有者--/); + assert.equal(host.element('symbol').textContent, 'RAVE #1'); +}); + +test('user can force a new CMC refresh while scheduled refreshes avoid duplicating pending work', { timeout: 5_000 }, async t => { + // Given a rendered panel has a cached asset mapping + const host = createDataPanelHost(t, 'cmc'); + await host.start(); + await completeCmcData(host); + const detail = cmcDetail({ showTreasuriesFlag: true, treasuryHoldings: 1 }); + + // When manual refresh supersedes a pending request while the interval also becomes due + host.element('refresh').click(); + const old = await host.network.waitForRequest(request => !request.settled && request.url.pathname.endsWith('/detail')); + const count = host.network.requests.length; + host.clock.tick(30_000); + assert.equal(host.network.requests.length, count); + host.element('refresh').click(); + const current = await host.network.waitForRequest(request => request !== old && !request.settled && request.url.pathname.endsWith('/detail')); + current.respond({ data: { ...detail, statistics: { ...detail.statistics, price: 100 } } }); + await host.rendered(() => host.element('rows').textContent.includes('价格$100')); + old.respond({ data: { ...detail, statistics: { ...detail.statistics, price: 50 } } }); + await afterDataMediaResponseTurn(); + + // Then only the latest forced request controls the panel's visible price + assert.match(host.element('rows').textContent, /价格\$100/); + assert.doesNotMatch(host.element('rows').textContent, /价格\$50/); + assert.equal(host.network.requests.filter(request => request.url.pathname.endsWith('/map')).length, 1); +}); + +for (const outcome of ['http', 'network']) { + test(`user sees a trading endpoint remain unavailable after its single ${outcome} retry fails`, { timeout: 5_000 }, async t => { + // Given the trading panel starts with the current period available at other endpoints + const host = createDataPanelHost(t, 'trading'); + await host.start(); + host.network.requests[0].respond({ serverTime: Date.now() }); + const dataset = tradingDataset(Date.now()); + await completeTradingBatch(host, dataset, { status: { openInterestHist: 503 } }); + const retry = await host.network.waitForRequest(request => !request.settled && request.url.pathname.endsWith('/openInterestHist')); + + // When the only immediate retry fails through its declared transport result + if (outcome === 'http') retry.respond({}, 503); + else retry.fail(); + await host.rendered(panel => panel?.querySelector('[data-role="updated-at"]')?.textContent.startsWith('更新于')); + + // Then that row stays unavailable while unrelated successful endpoints retain their votes + assert.equal(host.element('rows').firstElementChild.children[1].textContent, '--'); + assert.match(host.element('composite').textContent, /偏多 6:0/); + assert.equal(host.network.requests.filter(request => request.url.pathname.endsWith('/openInterestHist')).length, 2); + }); +} + +test('user can render trading data with local time after server synchronization fails', { timeout: 5_000 }, async t => { + // Given the server-time endpoint returns an explicit HTTP failure + const host = createDataPanelHost(t, 'trading'); + await host.start(); + + // When synchronization fails but the seven data endpoints still succeed + host.network.requests[0].respond({}, 500); + await completeTradingBatch(host, tradingDataset(Date.now())); + await host.rendered(panel => panel?.querySelector('[data-role="updated-at"]')?.textContent.startsWith('更新于')); + + // Then current data remains visible and the local-time recovery reason is recorded + assert.match(host.element('composite').textContent, /偏多 7:0/); + assert.equal(host.errors.some(args => args.includes('获取服务器时间失败,使用本地时间')), true); +}); + +test('user sees no fabricated open-interest trend before enough history is available', { timeout: 5_000 }, async t => { + // Given the initial history has only one current open-interest data point + const host = createDataPanelHost(t, 'trading'); + const dataset = tradingDataset(Date.now()); + dataset.openInterestHist = [dataset.openInterestHist[0]]; + + // When the complete trading entrypoint renders this short history + await host.start(); + await activateTradingData(host, dataset); + + // Then the quantity has no directional arrow and only the other six indicators vote + assert.equal(host.element('rows').firstElementChild.children[1].textContent, '1.00M'); + assert.match(host.element('composite').textContent, /偏多 6:0/); +}); + +test('user retries only delayed period endpoints after the publication grace interval', { timeout: 5_000 }, async t => { + // Given the page opens on a new boundary while the exchange still serves the prior period + const host = createDataPanelHost(t, 'trading'); + const old = tradingDataset(Date.now() - 300_000); + await host.start(); + await activateTradingData(host, old); + host.clock.tick(5_000); + const mixed = tradingDataset(Date.now()); + mixed.basis = old.basis; + mixed.takerlongshortRatio = old.takerlongshortRatio; + await completeTradingBatch(host, mixed); + await afterDataMediaResponseTurn(); + const count = host.network.requests.length; + + // When the first retry deadline arrives and the two delayed endpoints catch up + host.clock.tick(9_999); + assert.equal(host.network.requests.length, count); + host.clock.tick(1); + const pending = host.network.requests.filter(request => !request.settled); + const current = tradingDataset(Date.now()); + pending.forEach(request => request.respond(current[request.url.pathname.split('/').at(-1)])); + await afterDataMediaResponseTurn(); + + // Then the retry fetches only the pending period endpoints and keeps funding untouched + assert.deepEqual(pending.map(request => request.url.pathname.split('/').at(-1)).sort(), ['basis', 'takerlongshortRatio']); + assert.equal(host.network.requests.filter(request => request.url.pathname.endsWith('/fundingRate')).length, 2); + assert.match(host.element('composite').textContent, /偏多 7:0/); +}); + +test('user keeps valid trading metrics visible while retrying their missing publication timestamp', { timeout: 5_000 }, async t => { + // Given the basis endpoint supplies a valid metric without the current period timestamp + const host = createDataPanelHost(t, 'trading'); + const dataset = tradingDataset(Date.now()); + delete dataset.basis[0].timestamp; + await host.start(); + await activateTradingData(host, dataset); + host.clock.tick(5_000); + await completeTradingBatch(host, dataset); + await afterDataMediaResponseTurn(); + const count = host.network.requests.length; + assert.match(host.element('composite').textContent, /偏多 7:0/); + + // When only the timestamp-less endpoint reaches its retry deadline and publishes a fresh value + host.clock.tick(9_999); + assert.equal(host.network.requests.length, count); + host.clock.tick(1); + const pending = host.network.requests.filter(request => !request.settled); + assert.deepEqual(pending.map(request => request.url.pathname.split('/').at(-1)), ['basis']); + pending[0].respond([{ timestamp: Date.now(), basisRate: '-0.02' }]); + await afterDataMediaResponseTurn(); + + // Then the fresh basis value changes its vote without retrying already current endpoints + assert.match(host.element('composite').textContent, /偏多 6:1/); + assert.equal(host.network.requests.length, count + 1); + assert.equal(host.network.requests.filter(request => request.url.pathname.endsWith('/fundingRate')).length, 2); +}); + +test('user receives the documented bounded retry schedule while period data remains delayed', { timeout: 5_000 }, async t => { + // Given a new cycle repeatedly receives the previous period's six endpoint timestamps + const host = createDataPanelHost(t, 'trading'); + const stale = tradingDataset(Date.now() - 300_000); + await host.start(); + await activateTradingData(host, stale); + host.clock.tick(5_000); + await completeTradingBatch(host, stale); + await afterDataMediaResponseTurn(); + const observed = []; + + // When each explicit retry deadline is reached without advancing endpoint timestamps + for (const delay of [10_000, 15_000, 20_000, 30_000]) { + const before = host.network.requests.length; + host.clock.tick(delay - 1); + assert.equal(host.network.requests.length, before); + host.clock.tick(1); + const requests = host.network.requests.filter(request => !request.settled); + observed.push({ delay, count: requests.length }); + requests.forEach(request => request.respond(stale[request.url.pathname.split('/').at(-1)])); + await afterDataMediaResponseTurn(); + } + + // Then retries use ten, fifteen, twenty, and thirty seconds with no funding re-fetch + assert.deepEqual(observed, [10_000, 15_000, 20_000, 30_000].map(delay => ({ delay, count: 6 }))); + assert.equal(host.network.requests.filter(request => request.url.pathname.endsWith('/fundingRate')).length, 2); +}); + +test('user stops retrying an expired period and resumes at the next publication window', { timeout: 5_000 }, async t => { + // Given installation occurs twenty seconds before the current period expires + const boundary = Date.UTC(2026, 8, 16); + const host = createDataPanelHost(t, 'trading', { now: boundary + 280_000 }); + const stale = tradingDataset(boundary - 300_000); + await host.start(); + await activateTradingData(host, stale); + host.clock.tick(0); + await completeTradingBatch(host, stale); + await afterDataMediaResponseTurn(); + host.clock.tick(10_000); + host.network.requests.filter(request => !request.settled).forEach(request => request.respond(stale[request.url.pathname.split('/').at(-1)])); + await afterDataMediaResponseTurn(); + const count = host.network.requests.length; + + // When the old window closes and the next boundary's five-second grace elapses + host.clock.tick(14_999); + assert.equal(host.network.requests.length, count); + host.clock.tick(1); + await completeTradingBatch(host, tradingDataset(Date.now())); + await afterDataMediaResponseTurn(); + + // Then one complete new-period batch replaces further retries of the expired window + assert.equal(host.network.requests.length, count + 7); + assert.equal(host.network.requests.filter(request => request.url.pathname.endsWith('/fundingRate')).length, 3); + assert.match(host.element('composite').textContent, /偏多 7:0/); +}); diff --git a/test/dom/binance-orderbook-trade/account-orders.test.js b/test/dom/binance-orderbook-trade/account-orders.test.js index 6738004..cc03587 100644 --- a/test/dom/binance-orderbook-trade/account-orders.test.js +++ b/test/dom/binance-orderbook-trade/account-orders.test.js @@ -7,12 +7,16 @@ import { findAccountOrdersTabByIdentity, findAccountPositionTab, findOpenOrdersBasicSubTab, + findOpenOrdersConditionalSubTab, findOpenOrdersSubTabByIdentity, findOpenOrdersTab, + findSelectedAccountOrdersTab, findSelectedOpenOrdersSubTab, + getAccountOrdersTabGroup, getAccountOrdersTabIdentity, getActiveOpenOrdersScope, getOpenOrdersSubTabIdentity, + isAccountOrdersTab, parseAccountPositionTabCount, waitForAccountOrdersMutationState, } from '../../../src/binance-orderbook-trade/dom/account-orders.js'; @@ -21,15 +25,19 @@ import { isVisibleElement, loadFixtureDom } from '../../helpers/dom.js'; const openOrdersHtml = await readFile(new URL('../../fixtures/binance-orderbook-trade/account-orders-open-orders.html', import.meta.url), 'utf8'); const positionHtml = await readFile(new URL('../../fixtures/binance-orderbook-trade/account-orders-position.html', import.meta.url), 'utf8'); -test('selects the bottom account-orders open-orders tab over unrelated tab groups', () => { +test("user selects the bottom account-orders open-orders tab over unrelated tab groups", () => { + // Given native account tabs and order panes are mounted const { window } = loadFixtureDom(openOrdersHtml); + // When the current account and order scope is resolved const tab = findOpenOrdersTab(window.document, { isVisibleElement }); + // Then selects the bottom account-orders open-orders tab over unrelated tab groups assert.equal(tab?.textContent.trim(), '当前委托(2)'); assert.equal(tab?.closest('#account-orders') != null, true); }); -test('reads a confirmed zero position count from the unique account tab group', () => { +test("user reads a confirmed zero position count from the unique account tab group", () => { + // Given native account tabs and order panes are mounted const { window } = loadFixtureDom(`
`); + // When the current account and order scope is resolved const tab = findAccountPositionTab(window.document, { isVisibleElement }); + // Then reads a confirmed zero position count from the unique account tab group assert.equal(tab?.textContent.trim(), '仓位(0)'); assert.equal(parseAccountPositionTabCount(tab?.textContent), 0); assert.equal(parseAccountPositionTabCount('Positions (12)'), 12); assert.equal(parseAccountPositionTabCount('仓位'), null); }); -test('rejects position counts when account tab groups are ambiguous', () => { +test("user rejects position counts when account tab groups are ambiguous", () => { + // Given native account tabs and order panes are mounted const accountGroup = (id) => `