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:
aaaamoooo
2026-10-20 20:45:33 +00:00
parent 156a31c131
commit d576219a27
4 changed files with 3846 additions and 0 deletions
+33
View File
@@ -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
+248
View File
@@ -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);
}