test(panel-go): freeze Go golden behavior baseline (Node retired, difftest superseded)
Single-source Go from this round on: golden.mjs replays a fixed API script against a Go panel seeded from the difftest seed DB and diffs every response (learning-normalized) against golden.json. 25 sections: lists, credentials, all subscription formats, deploy job payloads, usage/presence, UI-style node PATCH, /api/system shape, backup, error cases.
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
# Go 面板金标准回归基线(golden)
|
||||
|
||||
2026-10-21 起 Node 实现冻结退役、Go 单一真源,difftest(Node 差分)随之退役。
|
||||
本目录把 **Go 面板在种子库上的当前行为**冻结为回归基线,替代 difftest 的
|
||||
守门角色:
|
||||
|
||||
- `golden.mjs` — 抓取/校验脚本。复用 `../difftest/` 的种子库(`seed.json`)
|
||||
与学习式 Normalizer(`../difftest/lib.mjs`):以种子副本启动一个 Go 面板,
|
||||
按固定剧本驱动(登录 → 种子 agent 心跳 → 列表 → 凭据 → 订阅全格式 →
|
||||
建服务器/注册 agent → 建客户/节点/分配/部署并 drain 部署任务 → 用量上报
|
||||
两轮 → 呈现投影 → UI 式全量 PATCH 节点 → `/api/system` 结构 → 备份导出 →
|
||||
错误用例),全部响应归一化后存入 `golden.json`(25 个 section)。
|
||||
- `golden.json` — 冻结的行为快照(2026-10-21,基线提交时的 Go 现状,
|
||||
二进制由 HEAD `156a31c` 构建)。
|
||||
|
||||
用法:
|
||||
|
||||
```bash
|
||||
node golden.mjs capture [--bin /path/to/nexusgate-panel] # 重新冻结(仅在确认行为变更为预期时)
|
||||
node golden.mjs verify [--bin /path/to/nexusgate-panel] # 与基线比对,有差异退出码 1 并打印首个 diff 路径
|
||||
```
|
||||
|
||||
约定:
|
||||
|
||||
- verify 出现 diff 时,先判定是**预期变更**(本版 9 项修改各自的预期 diff
|
||||
在 NEXT-ROUND-REPORT.md 中逐项登记)还是回归;预期变更在对应项提交里
|
||||
一并重新 capture 并在提交说明中注明 section 名。
|
||||
- `/api/system` 的数值多为实时采样,脚本只保留结构与确定性计数
|
||||
(nodesTotal/customersTotal/online* /connections),其余数值与 IP 压成
|
||||
占位;uptime 类键同样压平。其余 section 的数值都是剧本确定性结果,
|
||||
逐字比对。
|
||||
- 剧本对时间的依赖已被窗口设计吸收(presence 180s 窗口、心跳在线判定),
|
||||
正常复跑应恒 PASS;偶发 FAIL 先复跑一次区分抖动与真回归。
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,248 @@
|
||||
// NexusGate Go panel golden-behavior harness.
|
||||
//
|
||||
// Freezes the CURRENT Go panel behavior as a regression baseline now that the
|
||||
// Node implementation is retired (2026-10-21 decision): a single Go panel is
|
||||
// started on a copy of the difftest seed DB, driven through a fixed script of
|
||||
// API calls, and every response is passed through the difftest learning
|
||||
// Normalizer (volatile ids / secrets / ports / timestamps become placeholders)
|
||||
// before being stored in golden.json. `verify` replays the same script and
|
||||
// diffs against the frozen file; any diff must be either an intended change
|
||||
// (re-capture and note it in the commit) or a regression.
|
||||
//
|
||||
// Usage:
|
||||
// node golden.mjs capture [--bin /path/to/nexusgate-panel]
|
||||
// node golden.mjs verify [--bin /path/to/nexusgate-panel] [--golden golden.json]
|
||||
//
|
||||
// The binary defaults to ../nexusgate-panel-linux-amd64 (same resolution as
|
||||
// the difftest runner). The repo data/ directory is never touched.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
startPanel, waitHealth, stopPanel, makeTempDir, freePort, PanelClient,
|
||||
Normalizer, firstDiff, fetchAllSubscriptions, drainAgentJobs, pollOnce,
|
||||
completeJob, DEFAULT_GO_BIN, DIFFTEST_DIR,
|
||||
} from '../difftest/lib.mjs';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SEED = path.join(DIFFTEST_DIR, 'seed.json');
|
||||
const SEED_LOG = path.join(DIFFTEST_DIR, 'seed-log.json');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { mode: argv[2], bin: process.env.GO_PANEL_BIN || DEFAULT_GO_BIN, golden: path.join(HERE, 'golden.json') };
|
||||
for (let i = 3; i < argv.length; i += 1) {
|
||||
if (argv[i] === '--bin') args.bin = argv[++i];
|
||||
else if (argv[i] === '--golden') args.golden = argv[++i];
|
||||
else throw new Error(`unknown argument: ${argv[i]}`);
|
||||
}
|
||||
if (!['capture', 'verify'].includes(args.mode)) throw new Error('mode must be capture or verify');
|
||||
return args;
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// View of an admin/agent HTTP response, mirroring difftest's view().
|
||||
function view(res) {
|
||||
return { status: res.status, body: res.json !== null && res.json !== undefined ? res.json : res.text };
|
||||
}
|
||||
|
||||
// Keys whose numeric values are wall-clock derived and therefore not part of
|
||||
// the behavior contract; squashed wherever they appear.
|
||||
const VOLATILE_NUM_KEYS = /uptime|serverTimeMs|nowMs/i;
|
||||
function squashVolatile(value) {
|
||||
if (Array.isArray(value)) return value.map(squashVolatile);
|
||||
if (value && typeof value === 'object') {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
out[k] = typeof v === 'number' && VOLATILE_NUM_KEYS.test(k) ? '<num>' : squashVolatile(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// /api/system: almost every number is a live sample. Only structural counts
|
||||
// that follow deterministically from the script are kept verbatim.
|
||||
const SYSTEM_KEEP_NUM = new Set(['nodesTotal', 'customersTotal', 'onlineNodes', 'onlineCustomers', 'connections']);
|
||||
function squashSystem(value, keyName = null) {
|
||||
if (typeof value === 'number') return keyName && SYSTEM_KEEP_NUM.has(keyName) ? value : '<num>';
|
||||
if (typeof value === 'string' && /^\d{1,3}(\.\d{1,3}){3}$/.test(value)) return '<ip>';
|
||||
if (Array.isArray(value)) return value.map((v) => squashSystem(v, keyName));
|
||||
if (value && typeof value === 'object') {
|
||||
const out = {};
|
||||
for (const k of Object.keys(value).sort()) out[k] = squashSystem(value[k], k);
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function runScript(bin) {
|
||||
const seedLog = JSON.parse(fs.readFileSync(SEED_LOG, 'utf8'));
|
||||
const seedIds = seedLog.meta.ids;
|
||||
const tmp = makeTempDir('nexusgate-golden-');
|
||||
const dataFile = path.join(tmp, 'nexusgate.json');
|
||||
fs.copyFileSync(SEED, dataFile);
|
||||
const port = await freePort();
|
||||
const proc = startPanel({ kind: 'go', bin, dataFile, port });
|
||||
const N = new Normalizer();
|
||||
const sections = {};
|
||||
const cap = (name, value) => { sections[name] = N.process(squashVolatile(structuredClone(value))); };
|
||||
try {
|
||||
await waitHealth(proc);
|
||||
const client = new PanelClient(proc.base);
|
||||
const ids = { entryServer: seedIds.entryServer, exitServer: seedIds.exitServer, n1: seedIds.n1, n2: seedIds.n2, alice: seedIds.alice, bob: seedIds.bob };
|
||||
const agentKeys = { entry: seedLog.meta.agentKeys.entry, exit: seedLog.meta.agentKeys.exit };
|
||||
const subTokens = {};
|
||||
|
||||
cap('login', view(await client.login()));
|
||||
cap('session', view(await client.admin('GET', '/api/session')));
|
||||
|
||||
// Old-shape heartbeats (no engine object at all): the panel must keep
|
||||
// accepting them and simply have no engine state to show.
|
||||
cap('heartbeatSeedAgents', {
|
||||
entry: view(await client.agentPost('/api/agent/heartbeat', agentKeys.entry, { version: '0.6.8', system: {} })),
|
||||
exit: view(await client.agentPost('/api/agent/heartbeat', agentKeys.exit, { version: '0.6.8', system: {} })),
|
||||
});
|
||||
|
||||
const customersRes = await client.admin('GET', '/api/customers');
|
||||
for (const c of customersRes.json.customers) subTokens[c.name.toLowerCase()] = c.subscriptionToken;
|
||||
const nodesRes = await client.admin('GET', '/api/nodes');
|
||||
cap('lists', {
|
||||
overview: view(await client.admin('GET', '/api/overview')),
|
||||
servers: view(await client.admin('GET', '/api/servers')),
|
||||
customers: view(customersRes),
|
||||
nodes: view(nodesRes),
|
||||
jobs: view(await client.admin('GET', '/api/jobs')),
|
||||
});
|
||||
|
||||
cap('credentials', {
|
||||
alice: view(await client.admin('GET', `/api/customers/${ids.alice}/credentials`)),
|
||||
bob: view(await client.admin('GET', `/api/customers/${ids.bob}/credentials`)),
|
||||
});
|
||||
|
||||
cap('subscriptionsSeed', {
|
||||
alice: await fetchAllSubscriptions(client, subTokens.alice),
|
||||
bob: await fetchAllSubscriptions(client, subTokens.bob),
|
||||
});
|
||||
|
||||
// New server + agent enrolment.
|
||||
const s3res = await client.admin('POST', '/api/servers', {
|
||||
name: 'S3', role: 'relay', region: '', publicAddress: 's3.example.com',
|
||||
publicAddressV6: '', tlsDomain: '', portRangeStart: 20000, portRangeEnd: 50000,
|
||||
});
|
||||
ids.s3 = s3res.json.server.id;
|
||||
cap('createServerS3', view(s3res));
|
||||
const ticket = await client.admin('POST', `/api/servers/${ids.s3}/enrollment-token`, {});
|
||||
const enrolled = await client.request('POST', '/api/agent/enroll', {
|
||||
body: { token: ticket.json.token, hostname: 'golden-agent-s3', version: '0.6.8', system: {} },
|
||||
cookie: null,
|
||||
});
|
||||
agentKeys.s3 = enrolled.json.agentKey;
|
||||
cap('enrollS3', { ticket: view(ticket), enroll: view(enrolled) });
|
||||
|
||||
const carolRes = await client.admin('POST', '/api/customers', { name: 'Carol' });
|
||||
ids.carol = carolRes.json.customer.id;
|
||||
subTokens.carol = carolRes.json.customer.subscriptionToken;
|
||||
cap('createCarol', view(carolRes));
|
||||
|
||||
const n3res = await client.admin('POST', '/api/nodes', {
|
||||
name: 'N3', serverId: ids.s3, topology: 'direct',
|
||||
protocol: 'vless-reality', realityServerName: 'www.amd.com',
|
||||
});
|
||||
ids.n3 = n3res.json.node.id;
|
||||
cap('createNodeN3', view(n3res));
|
||||
cap('assignN3', view(await client.admin('POST', `/api/nodes/${ids.n3}/assign`, { customerId: ids.carol })));
|
||||
cap('deployN3', view(await client.admin('POST', `/api/nodes/${ids.n3}/deploy`, {})));
|
||||
const drainedS3 = await drainAgentJobs(client, agentKeys.s3);
|
||||
cap('deployN3Jobs', drainedS3.map(({ job, complete }) => ({ job, complete })));
|
||||
cap('nodesAfterDeploy', view(await client.admin('GET', '/api/nodes')));
|
||||
cap('carolSubscriptions', await fetchAllSubscriptions(client, subTokens.carol));
|
||||
|
||||
// Usage + presence/speed projection (two cumulative reports 6s apart).
|
||||
const usageBody = (u1, d1, u2, d2) => ({
|
||||
epoch: 'golden-epoch-1',
|
||||
samples: [
|
||||
{ kind: 'user', id: ids.alice, uplink: u1, downlink: d1 },
|
||||
{ kind: 'inbound', id: ids.n1, uplink: u2, downlink: d2 },
|
||||
],
|
||||
});
|
||||
cap('usageReport1', view(await client.agentPost('/api/agent/usage', agentKeys.entry, usageBody(1500, 2100, 5000, 6000))));
|
||||
await sleep(6000);
|
||||
cap('usageReport2', view(await client.agentPost('/api/agent/usage', agentKeys.entry, usageBody(4500, 6300, 8000, 9000))));
|
||||
const customers2 = await client.admin('GET', '/api/customers');
|
||||
const nodes2 = await client.admin('GET', '/api/nodes');
|
||||
const nameOf = (id) => { const c = customers2.json.customers.find((x) => x.id === id); return c ? c.name : '?'; };
|
||||
sections.presenceProjection = {
|
||||
customers: customers2.json.customers.map((c) => ({
|
||||
name: c.name, online: c.online,
|
||||
speedPositive: (c.speedUpBps || 0) > 0 && (c.speedDownBps || 0) > 0,
|
||||
hasLastActiveAt: Boolean(c.lastActiveAt),
|
||||
})),
|
||||
nodes: nodes2.json.nodes.map((n) => ({
|
||||
name: n.name, clientCount: n.clientCount, onlineCount: n.onlineCount,
|
||||
onlineNames: (n.onlineCustomerIds || []).map(nameOf),
|
||||
speedPositive: (n.speedUpBps || 0) > 0,
|
||||
})),
|
||||
};
|
||||
cap('customersAfterUsage', view(customers2));
|
||||
|
||||
// UI-style full-payload PATCH of the seed forward node N1 (name change
|
||||
// only). The browser form always submits portMode + port(null in random
|
||||
// mode) + serverId; this is the exact traffic shape behind the port
|
||||
// re-roll bug, frozen here so the fix shows up as a deliberate diff.
|
||||
const n1 = nodes2.json.nodes.find((n) => n.id === ids.n1);
|
||||
const uiPatch = {
|
||||
name: 'N1-renamed', serverId: n1.serverId, protocol: n1.protocol,
|
||||
portMode: n1.portMode, port: n1.portMode === 'fixed' ? n1.port : null,
|
||||
networkMode: n1.networkMode, topology: n1.topology,
|
||||
exitServerId: n1.exitServerId, exitProtocol: n1.exitProtocol,
|
||||
exitPortMode: n1.exitPortMode, exitPort: n1.exitPortMode === 'fixed' ? n1.exitPort : null,
|
||||
realityServerName: n1.realityServerName || '', realityDestPort: n1.realityDestPort || null,
|
||||
};
|
||||
cap('nodePatchUiStyle', view(await client.admin('PATCH', `/api/nodes/${ids.n1}`, uiPatch)));
|
||||
const drainedEntry = await drainAgentJobs(client, agentKeys.entry);
|
||||
const drainedExit = await drainAgentJobs(client, agentKeys.exit);
|
||||
cap('nodePatchJobs', {
|
||||
entry: drainedEntry.map(({ job, complete }) => ({ job, complete })),
|
||||
exit: drainedExit.map(({ job, complete }) => ({ job, complete })),
|
||||
});
|
||||
cap('nodesAfterPatch', view(await client.admin('GET', '/api/nodes')));
|
||||
|
||||
const systemRes = await client.admin('GET', '/api/system');
|
||||
sections.systemShape = { status: systemRes.status, body: squashSystem(N.process(structuredClone(systemRes.json))) };
|
||||
|
||||
cap('backup', view(await client.admin('GET', '/api/backup')));
|
||||
|
||||
const anon = new PanelClient(proc.base);
|
||||
cap('errors', {
|
||||
unauthenticated: view(await anon.request('GET', '/api/customers')),
|
||||
loginFailure: view(await anon.request('POST', '/api/auth/login', { body: { username: 'admin', password: 'wrong-password' }, cookie: null })),
|
||||
missingNode: view(await client.admin('GET', '/api/nodes/nod_00000000-0000-0000-0000-000000000000')),
|
||||
badSubscription: await (async () => {
|
||||
const r = await client.getSubscription('nosuchtoken000000', 'raw', 'NexusGate-Golden/1.0');
|
||||
return { status: r.status, body: r.body };
|
||||
})(),
|
||||
});
|
||||
|
||||
return sections;
|
||||
} finally {
|
||||
await stopPanel(proc);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv);
|
||||
if (!fs.existsSync(args.bin)) { console.error(`Go panel binary not found: ${args.bin}`); process.exit(2); }
|
||||
const sections = await runScript(args.bin);
|
||||
if (args.mode === 'capture') {
|
||||
const payload = { meta: { capturedAt: new Date().toISOString(), note: 'Go panel golden baseline; see README.md in this directory.' }, sections };
|
||||
fs.writeFileSync(args.golden, `${JSON.stringify(payload, null, 2)}\n`);
|
||||
console.log(`CAPTURED ${Object.keys(sections).length} sections -> ${args.golden}`);
|
||||
} else {
|
||||
const golden = JSON.parse(fs.readFileSync(args.golden, 'utf8'));
|
||||
const diff = firstDiff(golden.sections, sections);
|
||||
if (!diff) { console.log('GOLDEN PASS — behavior identical to frozen baseline'); process.exit(0); }
|
||||
console.log(`GOLDEN DIFF at ${diff.at}\n golden: ${diff.a}\n actual: ${diff.b}`);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user