From 5eac017b04b54c88a55a5c1681be62a109b0ba2d Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:04:50 +0800 Subject: [PATCH] test: assemble live captures from probe bundles --- docs/binance-orderbook-trade-ui-automation.md | 14 +++ .../helpers/live-capture-builder.js | 57 +++++++--- package.json | 1 + scripts/binance-live-capture.mjs | 41 +++++++ .../unit/binance-live-capture-builder.test.js | 12 +++ test/unit/binance-live-capture-cli.test.js | 102 ++++++++++++++++++ 6 files changed, 215 insertions(+), 12 deletions(-) create mode 100644 scripts/binance-live-capture.mjs create mode 100644 test/unit/binance-live-capture-cli.test.js diff --git a/docs/binance-orderbook-trade-ui-automation.md b/docs/binance-orderbook-trade-ui-automation.md index b92eaa9..6597fa5 100644 --- a/docs/binance-orderbook-trade-ui-automation.md +++ b/docs/binance-orderbook-trade-ui-automation.md @@ -248,6 +248,20 @@ wall-clock segment from the recorded semantic events and verifies that the dialo action matches the declared scenario kind. Do not manually transcribe timing values from console output, screenshots, or chat into a capture. +Persist the environment, scenario parameters, raw probe snapshots, capacity evidence, +test-order ledgers, and restoration assertions as one strict raw bundle. Assemble the +validated capture with: + +```bash +npm run assemble:binance-orderbook-live -- \ + /path/to/raw-bundle.json \ + /path/to/capture.json +``` + +The assembler rejects unknown bundle fields and refuses to overwrite its input file +or an existing capture. This command is the standard boundary between CDP collection +and capture summary; do not replace it with an ad hoc Node snippet. + The current checked-in L4 reference is the isolated zero-order HYPEUSDT run: - `e2e/binance-orderbook/live-baselines/no-orders-2026-08-27.capture.json` diff --git a/e2e/binance-orderbook/helpers/live-capture-builder.js b/e2e/binance-orderbook/helpers/live-capture-builder.js index 4dffd53..1240bab 100644 --- a/e2e/binance-orderbook/helpers/live-capture-builder.js +++ b/e2e/binance-orderbook/helpers/live-capture-builder.js @@ -10,6 +10,14 @@ function assertRecord(value, path) { } } +function assertExactKeys(record, expectedKeys, path) { + const actual = Object.keys(record).sort(); + const expected = [...expectedKeys].sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${path} keys must be exactly: ${expected.join(', ')}`); + } +} + function readUniqueEvent(probe, kind) { const matches = probe.events.filter((event) => event.kind === kind); if (matches.length !== 1) { @@ -81,13 +89,22 @@ function buildSegments(parameters, probe) { }; } -export function buildLivePerformanceSample({ - parameters, - probe, - capacityEvidence, - testOrderLedger, - stateRestored, -}) { +export function buildLivePerformanceSample(input) { + assertRecord(input, 'sample input'); + assertExactKeys(input, [ + 'parameters', + 'probe', + 'capacityEvidence', + 'testOrderLedger', + 'stateRestored', + ], 'sample input'); + const { + parameters, + probe, + capacityEvidence, + testOrderLedger, + stateRestored, + } = input; assertRecord(parameters, 'parameters'); validateLivePerformanceProbeSnapshot(probe); assertRecord(testOrderLedger, 'testOrderLedger'); @@ -112,7 +129,10 @@ export function buildLivePerformanceSample({ }); } -export function buildLivePerformanceCapture({ capturedAt, environment, scenarios }) { +export function buildLivePerformanceCapture(input) { + assertRecord(input, 'input'); + assertExactKeys(input, ['capturedAt', 'environment', 'scenarios'], 'input'); + const { capturedAt, environment, scenarios } = input; if (!Array.isArray(scenarios) || scenarios.length === 0) { throw new Error('scenarios must contain at least one scenario'); } @@ -122,6 +142,11 @@ export function buildLivePerformanceCapture({ capturedAt, environment, scenarios environment: structuredClone(environment), scenarios: scenarios.map((scenario, scenarioIndex) => { assertRecord(scenario, `scenarios[${scenarioIndex}]`); + assertExactKeys( + scenario, + ['name', 'parameters', 'samples'], + `scenarios[${scenarioIndex}]`, + ); if (!Array.isArray(scenario.samples)) { throw new Error(`scenarios[${scenarioIndex}].samples must be an array`); } @@ -129,10 +154,18 @@ export function buildLivePerformanceCapture({ capturedAt, environment, scenarios name: scenario.name, parameters: structuredClone(scenario.parameters), applicableSegments: getLiveScenarioSegments(scenario.parameters?.kind), - samples: scenario.samples.map((sample) => buildLivePerformanceSample({ - parameters: scenario.parameters, - ...sample, - })), + samples: scenario.samples.map((sample, sampleIndex) => { + assertRecord(sample, `scenarios[${scenarioIndex}].samples[${sampleIndex}]`); + assertExactKeys( + sample, + ['probe', 'capacityEvidence', 'testOrderLedger', 'stateRestored'], + `scenarios[${scenarioIndex}].samples[${sampleIndex}]`, + ); + return buildLivePerformanceSample({ + parameters: scenario.parameters, + ...sample, + }); + }), }; }), }; diff --git a/package.json b/package.json index db19c45..1853e72 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "download:brooks-media": "node scripts/brooks-media-download.mjs", "inspect:binance-orderbook-trade": "node scripts/userscript-release-contract.mjs scripts/binance-orderbook-trade.user.js", "verify:binance-orderbook-stage3": "node scripts/binance-stage3-evidence.mjs", + "assemble:binance-orderbook-live": "node scripts/binance-live-capture.mjs", "summarize:binance-orderbook-live": "node scripts/binance-live-performance.mjs", "check:binance-userscripts": "node --check scripts/binance-orderbook-trade.user.js && node --check scripts/binance-trading-data.user.js && node --check scripts/binance-coinmarketcap-data.user.js", "check:binance-orderbook-trade": "node --check scripts/binance-orderbook-trade.user.js", diff --git a/scripts/binance-live-capture.mjs b/scripts/binance-live-capture.mjs new file mode 100644 index 0000000..dfdd21a --- /dev/null +++ b/scripts/binance-live-capture.mjs @@ -0,0 +1,41 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + buildLivePerformanceCapture, +} from '../e2e/binance-orderbook/helpers/live-capture-builder.js'; + +function parseArguments(args) { + if (args.length !== 2) { + throw new Error('Usage: node scripts/binance-live-capture.mjs '); + } + const inputPath = resolve(args[0]); + const outputPath = resolve(args[1]); + if (inputPath === outputPath) throw new Error('input and output paths must differ'); + return { inputPath, outputPath }; +} + +export async function runLiveCaptureAssemblyCli(args) { + const { inputPath, outputPath } = parseArguments(args); + const input = JSON.parse(await readFile(inputPath, 'utf8')); + const capture = buildLivePerformanceCapture(input); + await writeFile(outputPath, `${JSON.stringify(capture, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + }); + return Object.freeze({ + inputPath, + outputPath, + scenarioCount: capture.scenarios.length, + sampleCount: capture.scenarios.reduce( + (total, scenario) => total + scenario.samples.length, + 0, + ), + }); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + const result = await runLiveCaptureAssemblyCli(process.argv.slice(2)); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} diff --git a/test/unit/binance-live-capture-builder.test.js b/test/unit/binance-live-capture-builder.test.js index 2afe154..ffedae2 100644 --- a/test/unit/binance-live-capture-builder.test.js +++ b/test/unit/binance-live-capture-builder.test.js @@ -144,3 +144,15 @@ test('builds and validates a complete three-sample capture without manual timing 'clickToFinalReady', ]); }); + +test('rejects unknown raw-bundle fields instead of silently dropping them', () => { + assert.throws( + () => buildLivePerformanceCapture({ + capturedAt: '2026-08-27T01:00:00.000Z', + environment: {}, + scenarios: [], + ignored: true, + }), + /input keys must be exactly/, + ); +}); diff --git a/test/unit/binance-live-capture-cli.test.js b/test/unit/binance-live-capture-cli.test.js new file mode 100644 index 0000000..040d5e3 --- /dev/null +++ b/test/unit/binance-live-capture-cli.test.js @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { runLiveCaptureAssemblyCli } from '../../scripts/binance-live-capture.mjs'; + +function probeSnapshot(firstFeedbackMs) { + return { + schemaVersion: 1, + sessionId: `probe-${firstFeedbackMs}`, + scenarioName: 'cancel-current-symbol-no-orders', + capturedAt: '2026-08-27T01:00:00.000Z', + timeOriginMs: 1000, + armedAtMonotonicMs: 10, + startedAtMonotonicMs: 20, + startedAtWallClock: '2026-08-27T01:00:00.010Z', + finishedAtMonotonicMs: 120, + performanceSupport: { longTask: true, longAnimationFrame: true }, + dropped: { events: 0, errors: 0, longTasks: 0, longAnimationFrames: 0 }, + events: [ + { kind: 'cancel-click', atMs: 0, detail: null }, + { kind: 'first-feedback', atMs: firstFeedbackMs, detail: null }, + { kind: 'finished', atMs: 100, detail: null }, + ], + errors: [], + longTasks: [], + longAnimationFrames: [], + clickSemanticSignature: '{}', + lastSemanticSignature: '{}', + lastSemanticState: {}, + firstFeedbackCaptured: true, + }; +} + +function rawBundle() { + return { + capturedAt: '2026-08-27T01:00:00.000Z', + environment: { + browser: 'Chrome 151.0.0.0', + os: 'macOS (MacIntel)', + route: 'https://www.binance.com/zh-CN/futures/HYPEUSDT', + symbol: 'HYPEUSDT', + userscriptSha256: 'a'.repeat(64), + userscriptVersion: '2.7.126', + }, + scenarios: [{ + name: 'cancel-current-symbol-no-orders', + parameters: { kind: 'no-orders' }, + samples: [3, 4, 5].map((firstFeedbackMs) => ({ + probe: probeSnapshot(firstFeedbackMs), + capacityEvidence: null, + testOrderLedger: { created: [], fills: [], residual: [] }, + stateRestored: true, + })), + }], + }; +} + +test('CLI assembles a strict capture file from a raw probe bundle', async (context) => { + const directory = await mkdtemp(join(tmpdir(), 'binance-live-capture-')); + context.after(() => rm(directory, { recursive: true, force: true })); + const inputPath = join(directory, 'raw-bundle.json'); + const outputPath = join(directory, 'capture.json'); + await writeFile(inputPath, `${JSON.stringify(rawBundle(), null, 2)}\n`); + + const result = await runLiveCaptureAssemblyCli([inputPath, outputPath]); + const capture = JSON.parse(await readFile(outputPath, 'utf8')); + + assert.equal(result.outputPath, outputPath); + assert.equal(result.scenarioCount, 1); + assert.equal(result.sampleCount, 3); + assert.deepEqual(capture.scenarios[0].samples[0].segmentsMs, { + clickToFirstFeedback: 3, + clickToFinalReady: 100, + }); +}); + +test('CLI refuses to overwrite its raw input bundle', async () => { + await assert.rejects( + runLiveCaptureAssemblyCli(['/tmp/live-bundle.json', '/tmp/live-bundle.json']), + /input and output paths must differ/, + ); +}); + +test('CLI preserves an existing capture instead of overwriting evidence', async (context) => { + const directory = await mkdtemp(join(tmpdir(), 'binance-live-capture-existing-')); + context.after(() => rm(directory, { recursive: true, force: true })); + const inputPath = join(directory, 'raw-bundle.json'); + const outputPath = join(directory, 'capture.json'); + await Promise.all([ + writeFile(inputPath, `${JSON.stringify(rawBundle(), null, 2)}\n`), + writeFile(outputPath, 'existing evidence\n'), + ]); + + await assert.rejects( + runLiveCaptureAssemblyCli([inputPath, outputPath]), + (error) => error.code === 'EEXIST', + ); + assert.equal(await readFile(outputPath, 'utf8'), 'existing evidence\n'); +});