From babf2d75320c27b63dc8e6a10385b5c840e99741 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:38:39 +0800 Subject: [PATCH] test: verify Tampermonkey release contract --- docs/binance-orderbook-trade-ui-automation.md | 51 ++++++- package.json | 1 + scripts/userscript-release-contract.mjs | 139 ++++++++++++++++++ test/unit/userscript-release-contract.test.js | 82 +++++++++++ 4 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 scripts/userscript-release-contract.mjs create mode 100644 test/unit/userscript-release-contract.test.js diff --git a/docs/binance-orderbook-trade-ui-automation.md b/docs/binance-orderbook-trade-ui-automation.md index f1e9fa7..d744ec3 100644 --- a/docs/binance-orderbook-trade-ui-automation.md +++ b/docs/binance-orderbook-trade-ui-automation.md @@ -140,7 +140,56 @@ test orders from count changes alone. - Sync the generated artifact through the existing Tampermonkey MCP path. - Read back installed source and version. -- Verify injection timing and the minimum critical paths in an isolated Chrome profile. +- Verify injection timing and the minimum critical paths in the connected Chrome + profile that owns the Tampermonkey installation. + +The local artifact identity is produced with: + +```bash +npm run inspect:binance-orderbook-trade +``` + +The JSON contract records the exact version, namespace, `@run-at`, install URLs, +matches, SHA-256, byte count, and character count. The MCP workflow must locate the +existing script by the exact `binance.orderbook.trade` namespace, patch that path, +read the same path back, and require an exact source match. A matching version alone +is insufficient because two different bundles can carry the same metadata version. + +When a read-back source is available as a file, the same contract can compare it +without extension UI inspection: + +```bash +node scripts/userscript-release-contract.mjs \ + scripts/binance-orderbook-trade.user.js \ + --compare /path/to/tampermonkey-readback.user.js +``` + +The current MCP text response appends a transport-only footer in this form: +`---` followed by `Last modified: `. It is not part of the installed +userscript. Save the complete MCP response and use `--compare-mcp-readback` when the +response includes that footer. The parser removes only this exact, validated footer; +an unrecognized response shape fails instead of silently trimming source text. + +The MCP write uses the last-read modification token when the server provides one. +A concurrent-edit error must stop the release check; it must not overwrite a newer +manual edit or create a second script with `put`. + +L2 owns isolated and deterministic host-state coverage. L3 intentionally uses the +connected Chrome profile because that is where the MCP-managed Tampermonkey script +is installed. Its browser checks must therefore be non-financial, restore any UI +state they change, and avoid assertions that depend on account positions or orders. +The minimum L3 browser evidence is: + +1. the exact Tampermonkey script id is present in a `Debugger.scriptParsed` URL; +2. that parse event precedes `Page.domContentEventFired` after a hard reload; +3. `Debugger.getScriptSource` contains the exact generated artifact; +4. the injected panel is visible; and +5. one reversible panel interaction changes state and restores the original state. + +Enable only the CDP domains needed for this evidence. Disable them immediately after +collection when the browser bridge supports the corresponding command. A domain that +the bridge cannot disable remains scoped to the claimed tab session and must not be +used as a reason to retain event buffers or persistent probes. ### Stage 4: Live Binance Acceptance and Baselines diff --git a/package.json b/package.json index 54d20e8..b136f26 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "audit:brooks-media": "node scripts/brooks-media-audit.mjs", "import:brooks-media-index": "node scripts/brooks-media-import-index.mjs", "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", "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", "check:m3u8-downloader": "node --check scripts/m3u8-downloader.user.js", diff --git a/scripts/userscript-release-contract.mjs b/scripts/userscript-release-contract.mjs new file mode 100644 index 0000000..b43809b --- /dev/null +++ b/scripts/userscript-release-contract.mjs @@ -0,0 +1,139 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const METADATA_START = '// ==UserScript=='; +const METADATA_END = '// ==/UserScript=='; + +export function parseUserscriptMetadata(source) { + if (!source.startsWith(`${METADATA_START}\n`)) { + throw new Error('Userscript metadata must start at the first byte'); + } + + const endIndex = source.indexOf(`\n${METADATA_END}`); + if (endIndex === -1) { + throw new Error('Userscript metadata block is not closed'); + } + + const metadata = new Map(); + const lines = source.slice(METADATA_START.length + 1, endIndex).split('\n'); + for (const line of lines) { + const match = line.match(/^\/\/ @([\w-]+)\s+(.+)$/); + if (!match) continue; + const [, key, value] = match; + const values = metadata.get(key) || []; + values.push(value.trim()); + metadata.set(key, values); + } + return metadata; +} + +function requireSingleMetadata(metadata, key) { + const values = metadata.get(key) || []; + if (values.length !== 1) { + throw new Error(`Expected exactly one @${key}, found ${values.length}`); + } + return values[0]; +} + +export function createUserscriptReleaseContract(source, artifactPath) { + const metadata = parseUserscriptMetadata(source); + const version = requireSingleMetadata(metadata, 'version'); + if (!/^\d+\.\d+\.\d+$/.test(version)) { + throw new Error(`Invalid semantic userscript version: ${version}`); + } + + const matches = metadata.get('match') || []; + if (matches.length === 0) { + throw new Error('Expected at least one @match'); + } + + return Object.freeze({ + artifact: artifactPath, + name: requireSingleMetadata(metadata, 'name'), + namespace: requireSingleMetadata(metadata, 'namespace'), + version, + runAt: requireSingleMetadata(metadata, 'run-at'), + updateURL: requireSingleMetadata(metadata, 'updateURL'), + downloadURL: requireSingleMetadata(metadata, 'downloadURL'), + matches, + sha256: createHash('sha256').update(source).digest('hex'), + bytes: Buffer.byteLength(source), + characters: source.length, + }); +} + +export function compareUserscriptSources(expectedSource, actualSource) { + const expected = createUserscriptReleaseContract(expectedSource, 'expected'); + const actual = createUserscriptReleaseContract(actualSource, 'actual'); + return Object.freeze({ + exactSourceMatch: expectedSource === actualSource, + expected, + actual, + }); +} + +export function parseTampermonkeyMcpReadback(readback) { + if (readback.trimStart().startsWith('{')) { + const payload = JSON.parse(readback); + if (typeof payload.value !== 'string') { + throw new Error('Tampermonkey MCP JSON read-back is missing the source value'); + } + return Object.freeze({ + source: payload.value, + lastModified: payload.lastModified ?? null, + }); + } + + const footerMatch = readback.match(/\n\n---\nLast modified: ([^\n]+)$/); + if (!footerMatch) { + throw new Error('Tampermonkey MCP text read-back is missing its transport footer'); + } + const lastModified = footerMatch[1]; + if (Number.isNaN(Date.parse(lastModified))) { + throw new Error(`Invalid Tampermonkey MCP modification time: ${lastModified}`); + } + return Object.freeze({ + source: readback.slice(0, footerMatch.index), + lastModified, + }); +} + +function parseArguments(args) { + if (args.length === 0 || args.length > 3) { + throw new Error('Usage: node scripts/userscript-release-contract.mjs [--compare|--compare-mcp-readback ]'); + } + const comparisonMode = args[1] || null; + if (args.length > 1 && (args.length !== 3 || !['--compare', '--compare-mcp-readback'].includes(comparisonMode))) { + throw new Error('Expected --compare or --compare-mcp-readback followed by an installed-source path'); + } + return { artifact: args[0], comparison: args[2] || null, comparisonMode }; +} + +export async function runUserscriptReleaseContract(args) { + const { artifact, comparison, comparisonMode } = parseArguments(args); + const artifactPath = resolve(artifact); + const source = await readFile(artifactPath, 'utf8'); + if (!comparison) { + return createUserscriptReleaseContract(source, artifactPath); + } + + const comparisonPath = resolve(comparison); + const comparisonText = await readFile(comparisonPath, 'utf8'); + const installedSource = comparisonMode === '--compare-mcp-readback' + ? parseTampermonkeyMcpReadback(comparisonText).source + : comparisonText; + const result = compareUserscriptSources(source, installedSource); + if (!result.exactSourceMatch) { + throw new Error( + `Installed source mismatch: expected ${result.expected.sha256}, received ${result.actual.sha256}`, + ); + } + return Object.freeze({ ...result, installedSource: comparisonPath }); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + const result = await runUserscriptReleaseContract(process.argv.slice(2)); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} diff --git a/test/unit/userscript-release-contract.test.js b/test/unit/userscript-release-contract.test.js new file mode 100644 index 0000000..96307af --- /dev/null +++ b/test/unit/userscript-release-contract.test.js @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { + compareUserscriptSources, + createUserscriptReleaseContract, + parseTampermonkeyMcpReadback, + parseUserscriptMetadata, +} from '../../scripts/userscript-release-contract.mjs'; + +const artifactPath = new URL('../../scripts/binance-orderbook-trade.user.js', import.meta.url); +const source = await readFile(artifactPath, 'utf8'); + +test('release contract identifies the generated Binance orderbook artifact', () => { + const contract = createUserscriptReleaseContract(source, artifactPath.pathname); + + assert.equal(contract.name, '【自写】Binance 订单簿单击下单'); + assert.equal(contract.namespace, 'binance.orderbook.trade'); + assert.match(contract.version, /^\d+\.\d+\.\d+$/); + assert.equal(contract.runAt, 'document-start'); + assert.equal(contract.updateURL, contract.downloadURL); + assert.deepEqual(contract.matches, [ + 'https://www.binance.com/*/futures/*', + 'https://www.binance.com/futures/*', + ]); + assert.match(contract.sha256, /^[a-f0-9]{64}$/); + assert.equal(contract.bytes, Buffer.byteLength(source)); + assert.equal(contract.characters, source.length); +}); + +test('release contract requires metadata at the first byte', () => { + assert.throws( + () => parseUserscriptMetadata(`\n${source}`), + /metadata must start at the first byte/, + ); +}); + +test('release contract rejects duplicate identity metadata', () => { + const duplicate = source.replace('// @namespace binance.orderbook.trade', [ + '// @namespace binance.orderbook.trade', + '// @namespace duplicate.namespace', + ].join('\n')); + + assert.throws( + () => createUserscriptReleaseContract(duplicate, artifactPath.pathname), + /Expected exactly one @namespace, found 2/, + ); +}); + +test('source comparison reports exact equality and divergent hashes', () => { + const equal = compareUserscriptSources(source, source); + assert.equal(equal.exactSourceMatch, true); + assert.equal(equal.actual.sha256, equal.expected.sha256); + + const changed = source.replace('// @description ', '// @description changed '); + const divergent = compareUserscriptSources(source, changed); + assert.equal(divergent.exactSourceMatch, false); + assert.notEqual(divergent.actual.sha256, divergent.expected.sha256); +}); + +test('Tampermonkey MCP text read-back separates the transport modification footer', () => { + const readback = `${source}\n\n---\nLast modified: 2026-08-26T06:52:12.240Z`; + const parsed = parseTampermonkeyMcpReadback(readback); + + assert.equal(parsed.source, source); + assert.equal(parsed.lastModified, '2026-08-26T06:52:12.240Z'); +}); + +test('Tampermonkey MCP JSON read-back requires an explicit source value', () => { + const parsed = parseTampermonkeyMcpReadback(JSON.stringify({ + value: source, + lastModified: 1_777_184_732, + })); + assert.equal(parsed.source, source); + assert.equal(parsed.lastModified, 1_777_184_732); + + assert.throws( + () => parseTampermonkeyMcpReadback(JSON.stringify({ lastModified: 1_777_184_732 })), + /missing the source value/, + ); +});