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
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@
- Chrome/CDP 诊断资源必须限制在当前测量范围内:只启用本轮需要的 CDP domain,事件读取必须带过滤条件和数量上限,证据收集完成后销毁页面探针,并关闭本轮启用的所有 domain。不要在持久 `node_repl` 绑定中长期保留大批事件或完整脚本源码。
- Codex `cua_node` 进程长时间占用超过一个 CPU 核心,并不能证明 userscript 或目标页面仍在忙。应先根据父进程和工作目录确认 PID 归属,检查 JavaScript 事件循环是否真的繁忙,再做短时只读堆栈采样。2026-08-25 的故障中,REPL 事件循环占用只有约 `0.1%`,原生堆栈热点却集中在管道/文件读取、Buffer/UTF-8 转换、typed array 复制和垃圾回收;关闭 CDP domain 后没有恢复,而重置该任务的 `node_repl` 内核会终止高负载 helper,同时保留 Chrome 和 Binance 页面。内核重置会清空所有 REPL 绑定并要求重新连接 Chrome,所以只能在浏览器验证节点完成后使用;清理后必须确认热点 PID 已消失,不要把直接结束 Node 进程作为第一步。

## Git Worktree 使用边界

- 默认不创建 worktree。单 Agent、串行任务、主工作区可以安全切换分支时,直接在主项目目录创建 feature branch 开发。
- 项目或改动“看起来复杂”不是创建 worktree 的理由。必须有具体的并行、隔离或并排验证需求。
- 只在以下情况使用 worktree:同时开发或验证多个分支;长时间测试/CDP/审查期间需要处理另一任务;多 Agent 并行且需要写入隔离;主工作区有用户未提交改动、无法安全 checkout;或必须并排比较两个版本。
- 创建前先说明本次的具体必要性。如果不能指出上述需求,就不创建。
- 确实需要时,手动 worktree 放在主项目同级目录,命名为 `<repo>-<task>`;合并并授权清理后,删除 worktree、已合并本地/远程分支并 prune 元数据,不留空容器目录。

## Validation

- `binance-orderbook-trade` 最低门槛:`npm test`、`npm run build:binance-orderbook-trade`、`npm run check:binance-orderbook-trade`。
Expand Down
10 changes: 10 additions & 0 deletions docs/binance-orderbook-trade-ui-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ Do not generate the Cartesian product. Maintain a mandatory risk-core matrix for
business invariants, then generate pairwise or three-way covering combinations for
the remaining axes. Production regressions become permanent named scenarios.

The deterministic cancel matrix currently reduces 1,536 Cartesian combinations to
19 scenarios while proving complete pairwise coverage. The generator is stable and
seedless: the same ordered axes always produce the same named vectors. A unit test
fails if a future axis/value change leaves any required pair uncovered.

## Required Invariants

Every cancel-current-symbol scenario must assert all applicable invariants:
Expand Down Expand Up @@ -103,6 +108,11 @@ Failed L2 tests retain:
- before/after control geometry;
- final fixture state and event ledger.

The Playwright auto fixture writes these JSON attachments only on failure. Visual
regression uses semantic layout/style snapshots for the canonical open-expanded,
close-expanded, and collapsed panels; this avoids platform font rasterization noise
while still detecting group movement, size changes, disabled styling, and state text.

Live tests additionally retain a test-owned order ledger keyed by symbol, side,
price, quantity, and creation time. Concurrent user orders are not inferred to be
test orders from count changes alone.
Expand Down
93 changes: 93 additions & 0 deletions e2e/binance-orderbook/helpers/covering-array.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
function combinations(items, size, start = 0, prefix = [], result = []) {
if (prefix.length === size) {
result.push(prefix);
return result;
}
for (let index = start; index <= items.length - (size - prefix.length); index += 1) {
combinations(items, size, index + 1, [...prefix, items[index]], result);
}
return result;
}

function cartesianEntries(entries, index = 0, current = {}, result = []) {
if (index === entries.length) {
result.push(current);
return result;
}
const [key, values] = entries[index];
for (const value of values) {
cartesianEntries(entries, index + 1, { ...current, [key]: value }, result);
}
return result;
}

function tupleKey(axisKeys, scenario) {
return JSON.stringify(axisKeys.map((key) => [key, scenario[key]]));
}

export function enumerateCartesian(axes) {
const entries = Object.entries(axes);
if (!entries.length || entries.some(([, values]) => !Array.isArray(values) || !values.length)) {
throw new Error('Covering-array axes must contain non-empty value arrays');
}
return cartesianEntries(entries);
}

export function listCoveredTuples(scenario, axisKeys, strength) {
if (!Number.isInteger(strength) || strength < 1 || strength > axisKeys.length) {
throw new Error(`Invalid covering-array strength: ${strength}`);
}
return combinations(axisKeys, strength).map((keys) => tupleKey(keys, scenario));
}

/**
* Greedily selects the first candidate covering the most remaining tuples.
* Candidate and axis order provide a stable tie-break, so the matrix is reviewable
* and reproducible without a random seed while still avoiding the Cartesian product.
*/
export function generateCoveringArray({ axes, strength = 2, isValid = () => true }) {
const axisKeys = Object.keys(axes);
const candidates = enumerateCartesian(axes).filter(isValid);
if (!candidates.length) throw new Error('Covering-array constraints rejected every scenario');

const tuplesByCandidate = candidates.map((candidate) => (
listCoveredTuples(candidate, axisKeys, strength)
));
const uncovered = new Set(tuplesByCandidate.flat());
const selected = [];
const available = new Set(candidates.map((_, index) => index));

while (uncovered.size) {
let bestIndex = -1;
let bestCoverage = -1;
for (const candidateIndex of available) {
const coverage = tuplesByCandidate[candidateIndex]
.reduce((total, tuple) => total + Number(uncovered.has(tuple)), 0);
if (coverage > bestCoverage) {
bestCoverage = coverage;
bestIndex = candidateIndex;
}
}
if (bestIndex < 0 || bestCoverage <= 0) {
throw new Error(`Unable to cover ${uncovered.size} remaining tuples`);
}
selected.push(candidates[bestIndex]);
available.delete(bestIndex);
for (const tuple of tuplesByCandidate[bestIndex]) uncovered.delete(tuple);
}

return selected;
}

export function findMissingCoverage({ axes, scenarios, strength = 2, isValid = () => true }) {
const axisKeys = Object.keys(axes);
const required = new Set(
enumerateCartesian(axes)
.filter(isValid)
.flatMap((scenario) => listCoveredTuples(scenario, axisKeys, strength))
);
for (const scenario of scenarios) {
for (const tuple of listCoveredTuples(scenario, axisKeys, strength)) required.delete(tuple);
}
return Array.from(required);
}
21 changes: 19 additions & 2 deletions e2e/binance-orderbook/helpers/userscript-page.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,33 @@
import { fileURLToPath } from 'node:url';
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';

import { renderBinanceFuturesFixture } from '../fixtures/binance-futures.js';

const USERSCRIPT_PATH = fileURLToPath(
new URL('../../../scripts/binance-orderbook-trade.user.js', import.meta.url),
);
const evidenceByPage = new WeakMap();

function readUserscriptVersion(source) {
const match = source.match(/^\/\/\s*@version\s+(\S+)/m);
if (!match) throw new Error('Generated userscript is missing @version metadata');
return match[1];
}

export function readScenarioEvidence(page) {
return evidenceByPage.get(page) || null;
}

export async function openUserscriptScenario(page, scenario) {
const errors = [];
page.on('pageerror', (error) => errors.push(String(error?.stack || error)));
const userscriptSource = await readFile(USERSCRIPT_PATH, 'utf8');
const userscript = {
version: readUserscriptVersion(userscriptSource),
sha256: createHash('sha256').update(userscriptSource).digest('hex'),
};
evidenceByPage.set(page, { scenario, userscript, errors });
await page.route('https://www.binance.com/**', async (route) => {
const url = new URL(route.request().url());
if (url.pathname === '/__binance_orderbook_userscript__.js') {
Expand Down Expand Up @@ -79,11 +96,11 @@ export async function openUserscriptScenario(page, scenario) {

await page.goto(`https://www.binance.com/zh-CN/futures/${scenario.currentSymbol}`);
await page.locator('#jh-binance-close-qty-multiplier-panel').waitFor({ state: 'visible' });
await page.getByRole('button', { name: '撤本币挂单' }).waitFor({ state: 'visible' });
await page.locator('#jh-binance-ladder-toggle').waitFor({ state: 'visible' });
await page.evaluate(() => new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
}));
return { errors };
return { errors, userscript };
}

export async function readFixtureState(page) {
Expand Down
85 changes: 85 additions & 0 deletions e2e/binance-orderbook/helpers/visual-contract.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
function round(value) {
return Math.round(value * 10) / 10;
}

export async function readPanelVisualContract(page) {
return page.locator('#jh-binance-close-qty-multiplier-panel').evaluate((panel) => {
const rootRect = panel.getBoundingClientRect();
const relativeRect = (element) => {
const rect = element.getBoundingClientRect();
return {
top: Math.round((rect.top - rootRect.top) * 10) / 10,
width: Math.round(rect.width * 10) / 10,
height: Math.round(rect.height * 10) / 10,
};
};
const styleContract = (element) => {
const style = getComputedStyle(element);
return {
color: style.color,
backgroundColor: style.backgroundColor,
borderColor: style.borderColor,
borderRadius: style.borderRadius,
fontSize: style.fontSize,
fontWeight: style.fontWeight,
opacity: style.opacity,
};
};
const controlKey = (element, index) => (
element.id
|| element.getAttribute('data-ladder-action')
|| element.getAttribute('data-ladder-stop')
|| element.getAttribute('data-ladder-cancel-symbol')
|| element.getAttribute('data-orderbook-precision-value')
|| `${element.getAttribute('data-ladder-group') || 'control'}:${element.getAttribute('data-ladder-value') || index}`
);
const controls = Array.from(panel.querySelectorAll([
'[data-side-selector] button',
'#jh-binance-close-qty-multiplier-input',
'#jh-binance-close-qty-multiplier-dec',
'#jh-binance-close-qty-multiplier-inc',
'[data-orderbook-precision-value]',
'#jh-binance-ladder-toggle',
'[data-ladder-group]',
'[data-ladder-action]',
'[data-ladder-stop]',
'[data-ladder-cancel-symbol]',
].join(','))).map((element, index) => ({
key: controlKey(element, index),
text: (element.textContent || '').replace(/\s+/g, ' ').trim(),
disabled: element.matches(':disabled'),
checked: element.getAttribute('aria-checked'),
pressed: element.getAttribute('aria-pressed'),
rect: relativeRect(element),
style: styleContract(element),
}));
const groups = Object.fromEntries(
Array.from(panel.querySelectorAll('[data-panel-group]')).map((element) => [
element.getAttribute('data-panel-group'),
{
rect: relativeRect(element),
display: getComputedStyle(element).display,
visibility: getComputedStyle(element).visibility,
},
])
);
return {
panel: {
width: Math.round(rootRect.width * 10) / 10,
height: Math.round(rootRect.height * 10) / 10,
padding: getComputedStyle(panel).padding,
borderRadius: getComputedStyle(panel).borderRadius,
},
groups,
controls,
text: {
mode: panel.querySelector('#jh-binance-trade-mode-hint')?.textContent || '',
formula: panel.querySelector('[data-multiplier-calculation]')?.textContent || '',
precision: panel.querySelector('[data-panel-group="precision"]')?.textContent || '',
status: panel.querySelector('#jh-binance-ladder-status')?.textContent || '',
},
};
}).then((contract) => JSON.parse(JSON.stringify(contract), (_, value) => (
typeof value === 'number' ? round(value) : value
)));
}
41 changes: 41 additions & 0 deletions e2e/binance-orderbook/scenarios/cancel-covering-matrix.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { generateCoveringArray } from '../helpers/covering-array.js';
import {
ORDER_SETS,
POSITION_SETS,
createCancelScenario,
} from './cancel-current-symbol.js';

export const CANCEL_MATRIX_AXES = Object.freeze({
positionSet: ['none', 'current', 'other', 'both'],
orderSet: ['none', 'current', 'other', 'both'],
accountTab: ['positions', 'openOrders', 'history'],
openOrdersSubTab: ['basic', 'conditional'],
hideOtherSymbols: [false, true],
showOrders: [false, true],
dialogOutcome: ['cancel', 'confirm'],
mutationDelayMs: [0, 40],
});

const vectors = generateCoveringArray({ axes: CANCEL_MATRIX_AXES, strength: 2 });

export const CANCEL_COVERING_SCENARIOS = Object.freeze(vectors.map((vector, index) => ({
id: `pairwise-${String(index + 1).padStart(2, '0')}`,
vector,
scenario: createCancelScenario({
name: `cancel-${String(index + 1).padStart(2, '0')}`,
matrix: {
id: `pairwise-${String(index + 1).padStart(2, '0')}`,
strength: 2,
vector,
},
positions: POSITION_SETS[vector.positionSet],
orders: ORDER_SETS[vector.orderSet],
ui: {
accountTab: vector.accountTab,
openOrdersSubTab: vector.openOrdersSubTab,
hideOtherSymbols: vector.hideOtherSymbols,
showOrders: vector.showOrders,
},
host: { mutationDelayMs: vector.mutationDelayMs },
}),
})));
83 changes: 83 additions & 0 deletions e2e/binance-orderbook/specs/cancel-covering-matrix.pw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { test, expect } from '../test.js';

import { CANCEL_COVERING_SCENARIOS } from '../scenarios/cancel-covering-matrix.js';
import {
openUserscriptScenario,
readFixtureState,
} from '../helpers/userscript-page.js';
import {
assertResponsiveInteraction,
assertStableGeometry,
finishInteractionProbe,
installInteractionProbe,
} from '../helpers/interaction-probe.js';

const CANCEL_BUTTON_SELECTOR = '[data-ladder-cancel-symbol="true"]';

function currentSymbolOrders(scenario) {
return scenario.orders.filter((order) => order.symbol === scenario.currentSymbol);
}

function otherSymbolOrders(scenario) {
return scenario.orders.filter((order) => order.symbol !== scenario.currentSymbol);
}

async function expectRestoredState(page, scenario) {
await expect.poll(async () => {
const state = await readFixtureState(page);
return {
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,
});
}

for (const entry of CANCEL_COVERING_SCENARIOS) {
test(`${entry.id} preserves the cancel-current-symbol invariants`, async ({ page }) => {
const { scenario, vector } = entry;
const hasCurrentOrders = currentSymbolOrders(scenario).length > 0;
const { errors } = await openUserscriptScenario(page, scenario);
await installInteractionProbe(page, CANCEL_BUTTON_SELECTOR);

await page.getByRole('button', { name: '撤本币挂单' }).click();
if (!hasCurrentOrders) {
await expect(page.getByRole('button', { name: '无挂单' })).toBeVisible();
} else {
await expect(page.getByRole('dialog')).toBeVisible();
await expect.poll(async () => (await readFixtureState(page)).showOrders).toBe(false);
await page.getByRole('button', {
name: vector.dialogOutcome === 'confirm' ? '确认' : '取消',
}).click();
await expect(page.getByText(
vector.dialogOutcome === 'confirm'
? `${scenario.currentSymbol} 撤单流程结束,已恢复筛选状态`
: `${scenario.currentSymbol} 已取消撤单,已恢复页面状态`,
)).toBeVisible();
}

await expectRestoredState(page, scenario);
const probe = await finishInteractionProbe(page);
assertResponsiveInteraction(expect, probe);
assertStableGeometry(expect, probe.baseline, probe.current);

const state = await readFixtureState(page);
const expectedOrders = hasCurrentOrders && vector.dialogOutcome === 'confirm'
? otherSymbolOrders(scenario)
: scenario.orders;
expect(state.orders).toEqual(expectedOrders);
expect(state.events.filter((event) => event.type === 'dialog-opened')).toHaveLength(
hasCurrentOrders ? 1 : 0,
);
expect(state.events.filter((event) => event.type === 'cancel-requested')).toHaveLength(
hasCurrentOrders && vector.dialogOutcome === 'confirm' ? 1 : 0,
);
expect(errors).toEqual([]);
});
}
2 changes: 1 addition & 1 deletion e2e/binance-orderbook/specs/cancel-current-symbol.pw.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test, expect } from '@playwright/test';
import { test, expect } from '../test.js';

import {
OTHER_SYMBOL,
Expand Down
Loading
Loading