Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion docs/binance-orderbook-trade-ui-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <ISO timestamp>`. 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

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
139 changes: 139 additions & 0 deletions scripts/userscript-release-contract.mjs
Original file line number Diff line number Diff line change
@@ -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 <artifact> [--compare|--compare-mcp-readback <installed-source>]');
}
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`);
}
82 changes: 82 additions & 0 deletions test/unit/userscript-release-contract.test.js
Original file line number Diff line number Diff line change
@@ -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/,
);
});
Loading