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,74 @@
|
|||||||
|
# amo 下一版修改清单
|
||||||
|
|
||||||
|
用户 2026-10-21 上线当晚起陆续口述,攒齐后统一施工。
|
||||||
|
|
||||||
|
## 0. 施工口径变更(2026-10-21 04:36 用户拍板)
|
||||||
|
|
||||||
|
Node 实现(server.js / lib / agent.js)冻结退役:本版起 **Go 单一真源**,只改 panel-go + agent-go + 共用前端,不再双写、不再对 Node 做 difftest;验收金标准改为冻结 Go 现状行为作回归基线。Node 代码留仓作历史与回滚参考,不删。
|
||||||
|
|
||||||
|
## 1. 客户页顶部加统计条(2026-10-21 03:53,用户附 3x-ui 截图)
|
||||||
|
|
||||||
|
照 3x-ui 客户端页顶部那排,在 amo「客户与订阅」页上部加统计条,六个数:
|
||||||
|
- 客户端(总数)
|
||||||
|
- 在线(当前在线数,沿用现有 3 分钟 presence 窗口口径)
|
||||||
|
- 耗尽(流量已用满的客户数)
|
||||||
|
- 即将耗尽(接近用满的客户数,阈值口径施工时对齐 3x-ui)
|
||||||
|
- 关闭(已停用客户数)
|
||||||
|
- 启用(已启用客户数)
|
||||||
|
|
||||||
|
出处:用户在路由器 3x-ui(:55889/rogue)客户端页截图,顶栏现网数字为 26 / 6 / 0 / 0 / 0 / 26。
|
||||||
|
|
||||||
|
## 2. 客户凭据改为全节点共用一套(2026-10-21 03:56,用户附 amo 凭据页截图)
|
||||||
|
|
||||||
|
现状(amo):凭据长在每个节点绑定上——同一客户在 HWSG / ZouSG-V6 / ZouSG-V4 / LEGJP-V4 / LEGJP-V6 各有一套不同的 UUID、密码、Reality Short ID。
|
||||||
|
|
||||||
|
目标(照 3x-ui):凭据收归客户级——一个客户一套 UUID + 密码 + Short ID,名下所有节点共用;节点绑定不再各存一套。
|
||||||
|
|
||||||
|
实证(2026-10-21 查用户 2026-10-20 发的路由器 3x-ui 备份库,只读):clients 表 uuid/password/sub_id 只存一份(email 唯一);client_inbounds 是纯关联表,无任何凭据列。26 个客户中 19 个挂了多个入站,最多的 rogue 挂 20 个入站、共用同一套凭据。此模型与日后 3x-ui→amo 正式迁移正好 1:1 对齐。
|
||||||
|
|
||||||
|
施工前要定的点:
|
||||||
|
- 节点侧 Reality 密钥对是服务器身份,不动,仍按节点托管(keyStore)。
|
||||||
|
- 存量不是问题:用户 2026-10-21 确认现网 amo 里服务器/客户/节点全是测试数据(随便加的),切换凭据模型时测试数据直接按新模型重建,无需收敛迁移;真实用户仍在 3x-ui 侧(待正式迁移)。
|
||||||
|
|
||||||
|
## 3. 内容区左右间距调小到与 3x-ui 一致(2026-10-21 04:02,用户附 amo 节点页 + 3x-ui 主机页对照图)
|
||||||
|
|
||||||
|
amo 各页主内容区两侧留白偏大;把左右间距(padding/最大宽度约束)调小,调到与 3x-ui 一样的贴边宽度。所有页面统一,不是一页一调。
|
||||||
|
|
||||||
|
## 4. 服务器页「核心版本」加真实探测(2026-10-21 04:11 用户)
|
||||||
|
|
||||||
|
现状:服务器页核心版本恒显"—"(agent 心跳没报这个字段)。
|
||||||
|
改法:agent 探测本机 sing-box 版本随心跳上报,面板落到核心版本列显示。
|
||||||
|
实现口径(已与用户对过内存账):版本探测只在 agent 启动 / 引擎安装更新 / 二进制文件变化时跑一次 `sing-box version` 并缓存,心跳只多带一个短字符串字段;不许每轮心跳起子进程。老 agent 无此字段时继续显"—",向后兼容。
|
||||||
|
|
||||||
|
## 5. 删除「延迟」(2026-10-21 04:12 用户)
|
||||||
|
|
||||||
|
服务器页的延迟列删掉,统计条里的"平均延迟"一并去掉;系统状态页如有同名占位同删。
|
||||||
|
理由(用户原话口径):延迟要不断探测上报、没什么用、还费内存——明确不做延迟探测功能,占位列也不要留。
|
||||||
|
|
||||||
|
## 6. 行操作小图标加悬停名称提示(2026-10-21 04:15,用户附客户行图标特写)
|
||||||
|
|
||||||
|
列表行内的功能小图标(二维码、详情、重置、编辑、删除等)鼠标悬停时显示对应功能名称(tooltip/title)。客户页先行,全站同类图标统一补齐。
|
||||||
|
样式照 3x-ui(用户 2026-10-21 附特写):黑底白字圆角气泡带小尖角、出现在图标上方;被悬停的图标本身带浅灰背景高亮。名称与 3x-ui 对齐:二维码 / 详情(i 图标)/ 重置流量(⇄ 图标)/ 编辑 / 删除。
|
||||||
|
|
||||||
|
## 7. 节点端口「范围内随机」保存后反复重掷(2026-10-21 04:19,用户附编辑节点截图)
|
||||||
|
|
||||||
|
现状:新建节点时入口端口为"范围内随机",分配到随机端口;若不手工改成固定,之后每次编辑该节点(哪怕只改其他参数)点保存,端口都会被重新随机分配成另一个——端口静默漂移,客户端随之失效。
|
||||||
|
|
||||||
|
要求:随机只在新建时掷一次;已分配的端口在后续编辑中必须保持不变,除非用户显式改端口模式/数值。实现口径施工时定(如:编辑既有节点时端口模式默认按已分配端口固定呈现),验收要点:编辑既有节点任意字段保存后端口逐字不变。
|
||||||
|
|
||||||
|
## 8. 中转模型与 3x-ui 不对齐:缺"A 入站经内网 IP 中转到 B 节点"(2026-10-21 04:26,用户附中转节点编辑截图)
|
||||||
|
|
||||||
|
现状:amo 的中转是"入口 → 出口设备"模型(转发拓扑 + 出口 SOCKS5),与 3x-ui 的"出站 + 路由规则"完全不同。
|
||||||
|
缺口:无法实现用户在 3x-ui 上的既有用法——A 服务器的入站节点通过内网 IP 直接中转到 B 服务器的节点(线路鸡 → 落地鸡内网中转)。
|
||||||
|
要求:下一版补齐这种中转形态(这是用户现网在用的模式,迁移前置之一)。
|
||||||
|
|
||||||
|
设计方向(2026-10-21 04:28 已与用户对过):出口目标二选一——① 受管设备(现状,面板管两边);② 新增"手动落地端点":填地址(内网 IP/域名)+ 端口 + 协议 + 凭据(VLESS=UUID、SS2022=密码、SOCKS5=账密),面板只在 A 侧生成出站+路由(sing-box outbound + route),B 侧不要求装 agent、可以是 3x-ui 的现成节点。凭据只存主控、随部署下发。
|
||||||
|
补充(用户 2026-10-21 确认的理解):第三方/别人提供的节点同样可作落地——只要有 IP+协议+ID+密码即可,来源不限(自家内网鸡、3x-ui 节点、别人给的节点都行);前提是 A 能直达该地址端口。一个 vless:// / ss:// 分享链接本身就含全这四样,施工时可顺带做"粘贴链接自动填表"。
|
||||||
|
|
||||||
|
## 9. 删除右上角头像圆图标(2026-10-21 04:34,用户附顶栏特写)
|
||||||
|
|
||||||
|
顶栏右侧的圆形头像(显用户名首字母"G")是纯装饰 span,无任何点击事件(退出登录在侧栏底部另有按钮)——删掉。已查证同排的「刷新」按钮有效(绑定 load() 重新拉数重渲染,与 45 秒自动刷新同一套),保留不动。
|
||||||
|
|
||||||
|
## 10. 删除顶栏全局搜索框(2026-10-21 04:39,用户附顶栏截图)
|
||||||
|
|
||||||
|
顶栏"搜索服务器、客户、节点…"输入框是写死 disabled 的摆设、无任何功能;且每页都有自己的可用搜索框,此框多余——删除(顶栏共用一处,删一处全站生效)。
|
||||||
@@ -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