632 lines
30 KiB
JavaScript
632 lines
30 KiB
JavaScript
// Differential E2E runner: Node panel vs Go panel.
|
|
//
|
|
// node run.mjs auto: formal mode when a Go binary is
|
|
// resolvable, otherwise selftest + SKIP
|
|
// node run.mjs --selftest Node vs Node (validates the harness
|
|
// itself; must report SELFTEST PASS)
|
|
// node run.mjs --bin <path> formal mode against this Go binary
|
|
// GO_PANEL_BIN=<path> node run.mjs formal mode against $GO_PANEL_BIN
|
|
// node run.mjs --keep keep temp dirs for debugging
|
|
//
|
|
// Binary resolution order: --bin > $GO_PANEL_BIN > panel-go/
|
|
// nexusgate-panel-linux-amd64 (built by the coordinator). When no binary
|
|
// exists, only the Node-vs-Node sanity runs and the formal mode is reported
|
|
// as SKIP (exit code still reflects the selftest result).
|
|
//
|
|
// Both sides start from byte-identical copies of seed.json (see seed.mjs;
|
|
// generated on demand when missing) and execute the same phase-2 script.
|
|
// Every response is fed through a per-side learning Normalizer (lib.mjs)
|
|
// before comparison, so random ids / tokens / ports / keys line up as
|
|
// <prefix>#n placeholders. Failures never abort the run: every step is
|
|
// executed and the first differing JSON path is reported per step.
|
|
|
|
import fs from 'node:fs';
|
|
import http from 'node:http';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
import {
|
|
ADMIN_USERNAME, DEFAULT_GO_BIN, DIFFTEST_DIR, Normalizer, PanelClient,
|
|
Reporter, UA_DEFAULT, completeJob, fetchAllSubscriptions, firstDiff,
|
|
freePort, makeTempDir, pollOnce, startPanel, stopPanel, waitHealth,
|
|
} from './lib.mjs';
|
|
import { generateSeed } from './seed.mjs';
|
|
|
|
const view = (res) => ({ status: res.status, body: res.json !== null && res.json !== undefined ? res.json : res.text });
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Side lifecycle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function makeSide({ kind, bin, seedPath, label }) {
|
|
const tmpDir = makeTempDir(`nexusgate-diff-${label}-`);
|
|
const dataFile = path.join(tmpDir, 'nexusgate.json');
|
|
fs.copyFileSync(seedPath, dataFile);
|
|
const port = await freePort();
|
|
const proc = startPanel({ kind, bin, dataFile, port });
|
|
await waitHealth(proc);
|
|
const side = {
|
|
label, kind, bin, tmpDir, dataFile, port,
|
|
proc, base: proc.base,
|
|
client: new PanelClient(proc.base),
|
|
norm: new Normalizer({ panelOrigin: proc.base }),
|
|
ids: {}, agentKeys: {}, subTokens: {}, pendingJob: null,
|
|
};
|
|
return side;
|
|
}
|
|
|
|
async function restartSide(side) {
|
|
await stopPanel(side.proc);
|
|
side.proc = startPanel({ kind: side.kind, bin: side.bin, dataFile: side.dataFile, port: side.port });
|
|
side.base = side.proc.base;
|
|
await waitHealth(side.proc);
|
|
side.client = new PanelClient(side.base);
|
|
side.norm = new Normalizer({ panelOrigin: side.base });
|
|
side.pendingJob = null;
|
|
}
|
|
|
|
async function destroySide(side, keep) {
|
|
await stopPanel(side.proc).catch(() => {});
|
|
if (!keep) fs.rmSync(side.tmpDir, { recursive: true, force: true });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Step runner: executes the same async step body against both sides and
|
|
// compares the normalized results.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class StepRunner {
|
|
constructor(reporter, sideA, sideB) {
|
|
this.reporter = reporter;
|
|
this.sideA = sideA;
|
|
this.sideB = sideB;
|
|
}
|
|
|
|
async step(name, run) {
|
|
let ra; let rb; let ea = null; let eb = null;
|
|
try { ra = await run(this.sideA); } catch (error) { ea = error; }
|
|
try { rb = await run(this.sideB); } catch (error) { eb = error; }
|
|
if (ea || eb) {
|
|
this.reporter.fail(name, `exception A=${ea ? ea.message : '-'} B=${eb ? eb.message : '-'}`);
|
|
return { ra, rb };
|
|
}
|
|
const na = this.sideA.norm.process(ra);
|
|
const nb = this.sideB.norm.process(rb);
|
|
this.reporter.compare(name, na, nb);
|
|
return { ra, rb };
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase 2 script (identical for selftest and formal mode)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function runPhase2(runner, { seedLog, seedExpect }) {
|
|
const seedIds = seedLog.meta.ids;
|
|
|
|
// Seed-state wiring: raw ids and agent keys are identical on both sides
|
|
// because both DBs are copies of the same seed file.
|
|
for (const side of [runner.sideA, runner.sideB]) {
|
|
side.ids.entryServer = seedIds.entryServer;
|
|
side.ids.exitServer = seedIds.exitServer;
|
|
side.ids.n1 = seedIds.n1;
|
|
side.ids.n2 = seedIds.n2;
|
|
side.agentKeys.entry = seedLog.meta.agentKeys.entry;
|
|
side.agentKeys.exit = seedLog.meta.agentKeys.exit;
|
|
}
|
|
|
|
await runner.step('p2/login', async (side) => view(await side.client.login()));
|
|
await runner.step('p2/session', async (side) => view(await side.client.admin('GET', '/api/session')));
|
|
await runner.step('p2/heartbeat-seed-agents', async (side) => {
|
|
const entry = await side.client.agentPost('/api/agent/heartbeat', side.agentKeys.entry, { version: '0.6.8', system: {} });
|
|
const exit = await side.client.agentPost('/api/agent/heartbeat', side.agentKeys.exit, { version: '0.6.8', system: {} });
|
|
return { entry: view(entry), exit: view(exit) };
|
|
});
|
|
await runner.step('p2/overview', async (side) => view(await side.client.admin('GET', '/api/overview')));
|
|
await runner.step('p2/list-servers', async (side) => view(await side.client.admin('GET', '/api/servers')));
|
|
await runner.step('p2/list-customers', async (side) => {
|
|
const res = await side.client.admin('GET', '/api/customers');
|
|
for (const customer of res.json.customers) {
|
|
const logical = customer.name.toLowerCase();
|
|
side.ids[logical] = customer.id;
|
|
side.subTokens[logical] = customer.subscriptionToken;
|
|
}
|
|
return view(res);
|
|
});
|
|
await runner.step('p2/list-nodes', async (side) => view(await side.client.admin('GET', '/api/nodes')));
|
|
await runner.step('p2/list-jobs', async (side) => view(await side.client.admin('GET', '/api/jobs')));
|
|
|
|
await runner.step('p2/create-server-s3', async (side) => {
|
|
const res = await side.client.admin('POST', '/api/servers', {
|
|
name: 'S3', role: 'relay', region: '', publicAddress: 's3.example.com',
|
|
publicAddressV6: '', tlsDomain: '', portRangeStart: 20000, portRangeEnd: 50000,
|
|
});
|
|
if (res.json && res.json.server) side.ids.s3 = res.json.server.id;
|
|
return view(res);
|
|
});
|
|
await runner.step('p2/enroll-s3', async (side) => {
|
|
const ticket = await side.client.admin('POST', `/api/servers/${side.ids.s3}/enrollment-token`, {});
|
|
const enrolled = await side.client.request('POST', '/api/agent/enroll', {
|
|
body: { token: ticket.json.token, hostname: 'diff-agent-s3', version: '0.6.8', system: {} },
|
|
cookie: null,
|
|
});
|
|
if (enrolled.json && enrolled.json.agentKey) side.agentKeys.s3 = enrolled.json.agentKey;
|
|
return { ticket: view(ticket), enroll: view(enrolled) };
|
|
});
|
|
await runner.step('p2/create-customer-carol', async (side) => {
|
|
const res = await side.client.admin('POST', '/api/customers', { name: 'Carol' });
|
|
if (res.json && res.json.customer) {
|
|
side.ids.carol = res.json.customer.id;
|
|
side.subTokens.carol = res.json.customer.subscriptionToken;
|
|
}
|
|
return view(res);
|
|
});
|
|
await runner.step('p2/create-node-n3', async (side) => {
|
|
const res = await side.client.admin('POST', '/api/nodes', {
|
|
name: 'N3', serverId: side.ids.s3, topology: 'direct',
|
|
protocol: 'vless-reality', realityServerName: 'www.amd.com',
|
|
});
|
|
if (res.json && res.json.node) side.ids.n3 = res.json.node.id;
|
|
return view(res);
|
|
});
|
|
await runner.step('p2/assign-n3-carol', async (side) =>
|
|
view(await side.client.admin('POST', `/api/nodes/${side.ids.n3}/assign`, { customerId: side.ids.carol })));
|
|
await runner.step('p2/deploy-n3', async (side) =>
|
|
view(await side.client.admin('POST', `/api/nodes/${side.ids.n3}/deploy`, {})));
|
|
await runner.step('p2/poll-n3-apply', async (side) => {
|
|
const job = await pollOnce(side.client, side.agentKeys.s3);
|
|
side.pendingJob = job;
|
|
return { job };
|
|
});
|
|
await runner.step('p2/complete-n3-apply', async (side) => {
|
|
if (!side.pendingJob) return { skipped: 'no job was polled' };
|
|
return view(await completeJob(side.client, side.agentKeys.s3, side.pendingJob));
|
|
});
|
|
await runner.step('p2/list-nodes-n3-active', async (side) => view(await side.client.admin('GET', '/api/nodes')));
|
|
|
|
await runner.step('p2/carol-subscriptions', async (side) =>
|
|
fetchAllSubscriptions(side.client, side.subTokens.carol));
|
|
await runner.step('p2/alice-subscriptions', async (side) => {
|
|
const subs = await fetchAllSubscriptions(side.client, side.subTokens.alice);
|
|
// Bodies must also match the golden seed expectations byte-for-byte
|
|
// (comparison form): both DBs are copies of the seed, and nothing in
|
|
// phase 2 up to this point changes Alice's node credentials. The
|
|
// subscription-userinfo header is excluded here because the seed
|
|
// capture predates the seed usage report.
|
|
const rawEqSeed = {};
|
|
for (const [key, sub] of Object.entries(subs)) {
|
|
const expected = seedExpect.alice[key];
|
|
rawEqSeed[key] = Boolean(expected) && sub.status === expected.status && sub.body === expected.body;
|
|
}
|
|
return { ...subs, rawEqSeed };
|
|
});
|
|
|
|
await runner.step('p2/usage-report-alice', async (side) =>
|
|
view(await side.client.agentPost('/api/agent/usage', side.agentKeys.entry, {
|
|
epoch: 'diff-phase2-epoch-1',
|
|
samples: [{ kind: 'user', id: side.ids.alice, uplink: 500, downlink: 700 }],
|
|
})));
|
|
await runner.step('p2/list-customers-after-usage', async (side) =>
|
|
view(await side.client.admin('GET', '/api/customers')));
|
|
|
|
// Heartbeat reconciliation: the S3 agent reports holding no resources,
|
|
// so the panel must re-queue the apply for N3 (which it believes is
|
|
// deployed there) with an equivalent payload.
|
|
await runner.step('p2/heartbeat-s3-reconcile', async (side) =>
|
|
view(await side.client.agentPost('/api/agent/heartbeat', side.agentKeys.s3, {
|
|
version: '0.6.8', system: {}, resources: [],
|
|
})));
|
|
await runner.step('p2/poll-n3-reconciled-apply', async (side) => {
|
|
const job = await pollOnce(side.client, side.agentKeys.s3);
|
|
side.pendingJob = job;
|
|
return { job };
|
|
});
|
|
await runner.step('p2/complete-n3-reconciled', async (side) => {
|
|
if (!side.pendingJob) return { skipped: 'no reconciled job was polled' };
|
|
return view(await completeJob(side.client, side.agentKeys.s3, side.pendingJob));
|
|
});
|
|
|
|
// Remove N3: undeploy -> agent polls the delete job -> complete.
|
|
await runner.step('p2/remove-n3', async (side) =>
|
|
view(await side.client.admin('POST', `/api/nodes/${side.ids.n3}/remove`, {})));
|
|
await runner.step('p2/poll-n3-delete', async (side) => {
|
|
const job = await pollOnce(side.client, side.agentKeys.s3);
|
|
side.pendingJob = job;
|
|
return { job };
|
|
});
|
|
await runner.step('p2/complete-n3-delete', async (side) => {
|
|
if (!side.pendingJob) return { skipped: 'no delete job was polled' };
|
|
return view(await completeJob(side.client, side.agentKeys.s3, side.pendingJob));
|
|
});
|
|
await runner.step('p2/list-nodes-after-remove', async (side) => view(await side.client.admin('GET', '/api/nodes')));
|
|
|
|
// S3 forget flow: an online server cannot be forgotten (409), before or
|
|
// after its node association is deleted; the name confirmation is
|
|
// validated too (400). Cleanup finishes through the normal DELETE route.
|
|
// (A successful forget requires the server to be offline, which the
|
|
// panel only derives after >180s without a heartbeat — not reachable
|
|
// inside this run; see README.)
|
|
await runner.step('p2/forget-s3-online-with-node', async (side) =>
|
|
view(await side.client.admin('POST', `/api/servers/${side.ids.s3}/forget`, { confirm: 'FORGET', name: 'S3' })));
|
|
await runner.step('p2/delete-node-n3', async (side) =>
|
|
view(await side.client.admin('DELETE', `/api/nodes/${side.ids.n3}`)));
|
|
await runner.step('p2/forget-s3-wrong-name', async (side) =>
|
|
view(await side.client.admin('POST', `/api/servers/${side.ids.s3}/forget`, { confirm: 'FORGET', name: 'NotS3' })));
|
|
await runner.step('p2/forget-s3-online', async (side) =>
|
|
view(await side.client.admin('POST', `/api/servers/${side.ids.s3}/forget`, { confirm: 'FORGET', name: 'S3' })));
|
|
await runner.step('p2/delete-server-s3', async (side) =>
|
|
view(await side.client.admin('DELETE', `/api/servers/${side.ids.s3}`)));
|
|
await runner.step('p2/list-servers-final', async (side) => view(await side.client.admin('GET', '/api/servers')));
|
|
|
|
await runner.step('p2/backup', async (side) => {
|
|
const res = await side.client.admin('GET', '/api/backup');
|
|
let parsed = null;
|
|
try { parsed = JSON.parse(res.text); } catch { /* reported via status/body below */ }
|
|
return { status: res.status, body: parsed !== null ? parsed : res.text };
|
|
});
|
|
|
|
// --- error cases ---------------------------------------------------------
|
|
await runner.step('err/unauthenticated-401', async (side) =>
|
|
view(await side.client.request('GET', '/api/customers', { cookie: null })));
|
|
await runner.step('err/csrf-missing-403', async (side) =>
|
|
view(await side.client.admin('POST', '/api/customers', { name: 'ShouldNotExist' }, { csrf: false })));
|
|
await runner.step('err/login-failed-401', async (side) =>
|
|
view(await side.client.request('POST', '/api/auth/login', {
|
|
body: { username: ADMIN_USERNAME, password: 'wrong-password' }, cookie: null,
|
|
})));
|
|
await runner.step('err/node-not-found-404', async (side) =>
|
|
view(await side.client.admin('POST', '/api/nodes/nod_00000000-0000-0000-0000-000000000000/deploy', {})));
|
|
await runner.step('err/subscription-bad-token-404', async (side) => {
|
|
const res = await side.client.getSubscription('A'.repeat(43), 'raw', UA_DEFAULT);
|
|
return { status: res.status, body: res.body };
|
|
});
|
|
await runner.step('err/subscription-info-page', async (side) => {
|
|
const res = await side.client.getSubscription(side.subTokens.alice, 'info', UA_DEFAULT);
|
|
return { status: res.status, body: res.body };
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase 3: UI-reskin features — node reorder, presence/speed, /api/system,
|
|
// customer-form node alignment, credentials, subscription aggregation.
|
|
// Time-derived values (speeds, /api/system numbers) are compared by shape
|
|
// only; everything else is compared exactly like phase 2.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const AGG_FIXTURE_BODY = Buffer.from([
|
|
'ss://YWVzLTI1Ni1nY206ZXh0LXB3@ext.example.com:8388#ExtOne',
|
|
'vless://22222222-2222-2222-2222-222222222222@ext2.example.com:443?security=none#ExtTwo',
|
|
'trojan://x@bad.example.com:443#Dropped',
|
|
'',
|
|
].join('\n')).toString('base64');
|
|
const AGG_MANUAL_LINK = 'ss://YWVzLTI1Ni1nY206bWFudWFsLXB3@manual.example.com:8388#ManualOne';
|
|
|
|
async function runPhase3(runner) {
|
|
// --- node reorder + subscription order ----------------------------------
|
|
await runner.step('p3/reorder-nodes', async (side) => {
|
|
const res = await side.client.admin('POST', '/api/nodes/reorder', { ids: [side.ids.n2, side.ids.n1] });
|
|
const list = await side.client.admin('GET', '/api/nodes');
|
|
return {
|
|
reorder: view(res),
|
|
order: list.json.nodes.map((n) => n.name),
|
|
sortIndexes: list.json.nodes.map((n) => n.sortIndex),
|
|
};
|
|
});
|
|
await runner.step('p3/alice-sub-order-after-reorder', async (side) => {
|
|
const res = await side.client.getSubscription(side.subTokens.alice, 'raw', UA_DEFAULT);
|
|
const names = res.body.split('\n').filter(Boolean).map((line) => {
|
|
const idx = line.lastIndexOf('#');
|
|
return idx >= 0 ? decodeURIComponent(line.slice(idx + 1)) : line;
|
|
});
|
|
return { status: res.status, names };
|
|
});
|
|
|
|
// --- presence & speed: two usage reports ~6s apart -----------------------
|
|
await runner.step('p3/usage-report-1', async (side) =>
|
|
view(await side.client.agentPost('/api/agent/usage', side.agentKeys.entry, {
|
|
epoch: 'diff-phase2-epoch-1',
|
|
samples: [
|
|
{ kind: 'user', id: side.ids.alice, uplink: 1500, downlink: 2100 },
|
|
{ kind: 'inbound', id: side.ids.n1, uplink: 5000, downlink: 6000 },
|
|
],
|
|
})));
|
|
await runner.step('p3/usage-report-2-after-6s', async (side) => {
|
|
await sleep(6000);
|
|
return view(await side.client.agentPost('/api/agent/usage', side.agentKeys.entry, {
|
|
epoch: 'diff-phase2-epoch-1',
|
|
samples: [
|
|
{ kind: 'user', id: side.ids.alice, uplink: 4500, downlink: 6300 },
|
|
{ kind: 'inbound', id: side.ids.n1, uplink: 8000, downlink: 9000 },
|
|
],
|
|
}));
|
|
});
|
|
await runner.step('p3/presence-projection', async (side) => {
|
|
const customers = await side.client.admin('GET', '/api/customers');
|
|
const nodes = await side.client.admin('GET', '/api/nodes');
|
|
const nameOf = (id) => { const c = customers.json.customers.find((x) => x.id === id); return c ? c.name : '?'; };
|
|
return {
|
|
customers: customers.json.customers.map((c) => ({
|
|
name: c.name, online: c.online,
|
|
speedPositive: (c.speedUpBps || 0) > 0 && (c.speedDownBps || 0) > 0,
|
|
hasLastActiveAt: Boolean(c.lastActiveAt),
|
|
})),
|
|
nodes: nodes.json.nodes.map((n) => ({
|
|
name: n.name, clientCount: n.clientCount, onlineCount: n.onlineCount,
|
|
onlineNames: (n.onlineCustomerIds || []).map(nameOf),
|
|
speedPositive: (n.speedUpBps || 0) > 0,
|
|
})),
|
|
};
|
|
});
|
|
await runner.step('p3/system-shape', async (side) => {
|
|
const res = await side.client.admin('GET', '/api/system');
|
|
const s = res.json || {};
|
|
return {
|
|
status: res.status, version: s.version,
|
|
hostKeys: s.host ? Object.keys(s.host).sort() : null,
|
|
networkKeys: s.network ? Object.keys(s.network).sort() : null,
|
|
todayKeys: s.today ? Object.keys(s.today).sort() : null,
|
|
ipsKeys: s.ips ? Object.keys(s.ips).sort() : null,
|
|
historyIsArray: Array.isArray(s.history),
|
|
connectionsIsNumber: Boolean(s.network) && typeof s.network.connections === 'number',
|
|
panelMemPositive: s.panelMemBytes > 0,
|
|
startedAtPresent: typeof s.startedAt === 'string',
|
|
};
|
|
});
|
|
|
|
// --- customer-form node alignment (on Carol: no golden subscriptions) ----
|
|
await runner.step('p3/align-carol-add-n1', async (side) => {
|
|
const res = await side.client.admin('PATCH', `/api/customers/${side.ids.carol}`, { nodeIds: [side.ids.n1] });
|
|
const nodes = await side.client.admin('GET', '/api/nodes');
|
|
const mine = nodes.json.nodeClients.filter((nc) => nc.customerId === side.ids.carol);
|
|
if (mine.length) side.ids.carolNc = mine[0].id;
|
|
return {
|
|
status: res.status, count: mine.length,
|
|
nodeNames: mine.map((nc) => { const n = nodes.json.nodes.find((x) => x.id === nc.nodeId); return n ? n.name : '?'; }),
|
|
statuses: mine.map((nc) => nc.status),
|
|
};
|
|
});
|
|
|
|
// --- credentials ----------------------------------------------------------
|
|
await runner.step('p3/credentials-get', async (side) => {
|
|
const res = await side.client.admin('GET', `/api/customers/${side.ids.carol}/credentials`);
|
|
return {
|
|
status: res.status,
|
|
hasToken: typeof res.json.subscriptionToken === 'string' && res.json.subscriptionToken.length > 10,
|
|
items: (res.json.items || []).map((entry) => ({
|
|
nodeName: entry.nodeName, protocol: entry.protocol, status: entry.status,
|
|
hasClientId: Boolean(entry.clientId), hasPassword: Boolean(entry.password),
|
|
hasShortId: Boolean(entry.shortId), username: entry.username,
|
|
})),
|
|
};
|
|
});
|
|
await runner.step('p3/credentials-regenerate', async (side) => {
|
|
const before = await side.client.admin('GET', `/api/customers/${side.ids.carol}/credentials`);
|
|
const regen = await side.client.admin('POST', `/api/customers/${side.ids.carol}/credentials/${side.ids.carolNc}/regenerate`, {});
|
|
const after = await side.client.admin('GET', `/api/customers/${side.ids.carol}/credentials`);
|
|
const b = (before.json.items || [])[0] || {};
|
|
const a = (after.json.items || [])[0] || {};
|
|
return {
|
|
status: regen.status,
|
|
changed: b.clientId !== a.clientId || b.password !== a.password,
|
|
itemNodeName: regen.json && regen.json.item ? regen.json.item.nodeName : null,
|
|
itemHasClientId: Boolean(regen.json && regen.json.item && regen.json.item.clientId),
|
|
};
|
|
});
|
|
await runner.step('p3/align-carol-remove-all', async (side) => {
|
|
const res = await side.client.admin('PATCH', `/api/customers/${side.ids.carol}`, { nodeIds: [] });
|
|
const nodes = await side.client.admin('GET', '/api/nodes');
|
|
return {
|
|
status: res.status,
|
|
bindingsForCarol: nodes.json.nodeClients.filter((nc) => nc.customerId === side.ids.carol).length,
|
|
};
|
|
});
|
|
|
|
// --- subscription aggregation ---------------------------------------------
|
|
// Both panels must allow private subscription sources to reach the
|
|
// loopback fixture: flip the setting in each side's data file and
|
|
// restart (there is deliberately no settings API).
|
|
const fixture = http.createServer((req, res) => {
|
|
res.writeHead(200, { 'content-type': 'text/plain' });
|
|
res.end(AGG_FIXTURE_BODY);
|
|
});
|
|
await new Promise((resolve) => fixture.listen(0, '127.0.0.1', resolve));
|
|
const fixtureUrl = `http://127.0.0.1:${fixture.address().port}/sub`;
|
|
try {
|
|
for (const side of [runner.sideA, runner.sideB]) {
|
|
await stopPanel(side.proc);
|
|
const db = JSON.parse(fs.readFileSync(side.dataFile, 'utf8'));
|
|
db.settings = db.settings || {};
|
|
db.settings.allowPrivateSubSources = true;
|
|
fs.writeFileSync(side.dataFile, JSON.stringify(db));
|
|
await restartSide(side);
|
|
}
|
|
await runner.step('p3/agg-relogin', async (side) => view(await side.client.login()));
|
|
await runner.step('p3/agg-create-dave', async (side) => {
|
|
const res = await side.client.admin('POST', '/api/customers', {
|
|
name: 'Dave',
|
|
manualLinks: [{ uri: AGG_MANUAL_LINK, remark: '手动一号', enabled: true }],
|
|
externalSources: [{ url: fixtureUrl, remark: '机场', enabled: true }],
|
|
});
|
|
if (res.json && res.json.customer) {
|
|
side.ids.dave = res.json.customer.id;
|
|
side.subTokens.dave = res.json.customer.subscriptionToken;
|
|
const source = (res.json.customer.externalSources || [])[0];
|
|
if (source) side.ids.daveSrc = source.id;
|
|
}
|
|
return {
|
|
status: res.status,
|
|
manualCount: res.json && res.json.customer ? (res.json.customer.manualLinks || []).length : null,
|
|
sourceCount: res.json && res.json.customer ? (res.json.customer.externalSources || []).length : null,
|
|
};
|
|
});
|
|
await runner.step('p3/agg-refresh', async (side) =>
|
|
view(await side.client.admin('POST', `/api/customers/${side.ids.dave}/external-sources/${side.ids.daveSrc}/refresh`, {})));
|
|
await runner.step('p3/agg-customer-status', async (side) => {
|
|
const res = await side.client.admin('GET', '/api/customers');
|
|
const dave = res.json.customers.find((c) => c.id === side.ids.dave);
|
|
const status = dave && dave.externalStatus ? Object.values(dave.externalStatus)[0] : null;
|
|
return {
|
|
hasExternalStatus: Boolean(dave && dave.externalStatus),
|
|
count: status ? status.count : null,
|
|
dropped: status ? status.droppedCount : null,
|
|
failCount: status ? status.failCount : null,
|
|
hasOkAt: Boolean(status && status.okAt),
|
|
lastError: status ? status.lastError : 'missing',
|
|
};
|
|
});
|
|
await runner.step('p3/agg-subscriptions', async (side) =>
|
|
fetchAllSubscriptions(side.client, side.subTokens.dave));
|
|
} finally {
|
|
fixture.close();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DB interoperability: swap the two panels' DB files and keep working.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function runInterop(reporter, sideA, sideB, { seedExpect }) {
|
|
const statsRunner = new StepRunner(reporter, sideA, sideB);
|
|
await statsRunner.step('interop/db-stats-before-swap', async (side) => {
|
|
const db = JSON.parse(fs.readFileSync(side.dataFile, 'utf8'));
|
|
const alice = db.customers.find((item) => item.name === 'Alice');
|
|
return {
|
|
keyStoreCount: Object.keys(db.keyStore || {}).length,
|
|
jobsCount: db.jobs.length,
|
|
aliceUsedBytes: alice ? alice.usedBytes : null,
|
|
};
|
|
});
|
|
|
|
// Swap the database files and restart each implementation on the other's
|
|
// data. Fresh clients + normalizers: appearance order restarts here.
|
|
const aRaw = fs.readFileSync(sideA.dataFile);
|
|
const bRaw = fs.readFileSync(sideB.dataFile);
|
|
await stopPanel(sideA.proc);
|
|
await stopPanel(sideB.proc);
|
|
fs.writeFileSync(sideA.dataFile, bRaw);
|
|
fs.writeFileSync(sideB.dataFile, aRaw);
|
|
await restartSide(sideA);
|
|
await restartSide(sideB);
|
|
|
|
const runner = new StepRunner(reporter, sideA, sideB);
|
|
await runner.step('interop/login', async (side) => view(await side.client.login()));
|
|
await runner.step('interop/list-servers', async (side) => view(await side.client.admin('GET', '/api/servers')));
|
|
await runner.step('interop/list-customers', async (side) => {
|
|
const res = await side.client.admin('GET', '/api/customers');
|
|
for (const customer of res.json.customers) {
|
|
const logical = customer.name.toLowerCase();
|
|
side.ids[logical] = customer.id;
|
|
side.subTokens[logical] = customer.subscriptionToken;
|
|
}
|
|
return view(res);
|
|
});
|
|
await runner.step('interop/list-nodes', async (side) => view(await side.client.admin('GET', '/api/nodes')));
|
|
await runner.step('interop/list-jobs', async (side) => view(await side.client.admin('GET', '/api/jobs')));
|
|
await runner.step('interop/alice-subscriptions', async (side) => {
|
|
const subs = await fetchAllSubscriptions(side.client, side.subTokens.alice);
|
|
const rawEqSeed = {};
|
|
for (const [key, sub] of Object.entries(subs)) {
|
|
const expected = seedExpect.alice[key];
|
|
rawEqSeed[key] = Boolean(expected) && sub.status === expected.status && sub.body === expected.body;
|
|
}
|
|
return { ...subs, rawEqSeed };
|
|
});
|
|
await runner.step('interop/patch-alice-notes', async (side) =>
|
|
view(await side.client.admin('PATCH', `/api/customers/${side.ids.alice}`, { notes: 'interop-ok' })));
|
|
await runner.step('interop/list-customers-after-notes', async (side) =>
|
|
view(await side.client.admin('GET', '/api/customers')));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Modes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function runPair({ reporter, kindA, kindB, bin, seedPath, seedLog, seedExpect, keep, label }) {
|
|
let sideA = null;
|
|
let sideB = null;
|
|
try {
|
|
sideA = await makeSide({ kind: kindA, bin, seedPath, label: `${label}-a` });
|
|
sideB = await makeSide({ kind: kindB, bin, seedPath, label: `${label}-b` });
|
|
} catch (error) {
|
|
reporter.fail(`setup/${label}`, error.message);
|
|
if (sideA) await destroySide(sideA, keep);
|
|
if (sideB) await destroySide(sideB, keep);
|
|
return false;
|
|
}
|
|
const failuresBefore = reporter.failed;
|
|
try {
|
|
const runner = new StepRunner(reporter, sideA, sideB);
|
|
await runPhase2(runner, { seedLog, seedExpect });
|
|
await runPhase3(runner);
|
|
await runInterop(reporter, sideA, sideB, { seedExpect });
|
|
} finally {
|
|
await destroySide(sideA, keep);
|
|
await destroySide(sideB, keep);
|
|
}
|
|
return reporter.failed === failuresBefore;
|
|
}
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
let argBin = null;
|
|
let seedPath = path.join(DIFFTEST_DIR, 'seed.json');
|
|
let forceSelftest = false;
|
|
let keep = false;
|
|
for (let i = 0; i < args.length; i += 1) {
|
|
if (args[i] === '--bin' && args[i + 1]) { argBin = args[i + 1]; i += 1; }
|
|
else if (args[i] === '--seed' && args[i + 1]) { seedPath = path.resolve(args[i + 1]); i += 1; }
|
|
else if (args[i] === '--selftest') forceSelftest = true;
|
|
else if (args[i] === '--keep') keep = true;
|
|
}
|
|
const goBin = argBin || process.env.GO_PANEL_BIN || DEFAULT_GO_BIN;
|
|
const goBinExists = fs.existsSync(goBin);
|
|
|
|
if (!fs.existsSync(seedPath)) {
|
|
console.log(`[run] seed not found at ${seedPath}; generating it with the Node panel first`);
|
|
await generateSeed({ outDir: path.dirname(seedPath), quiet: false });
|
|
}
|
|
const seedLog = JSON.parse(fs.readFileSync(path.join(path.dirname(seedPath), 'seed-log.json'), 'utf8'));
|
|
const seedExpect = JSON.parse(fs.readFileSync(path.join(path.dirname(seedPath), 'seed-expect.json'), 'utf8'));
|
|
|
|
const reporter = new Reporter();
|
|
let selftestOk = null;
|
|
let formalOk = null;
|
|
|
|
if (forceSelftest || !goBinExists) {
|
|
console.log('[run] SELFTEST: Node vs Node (same seed, same phase-2 script)');
|
|
selftestOk = await runPair({
|
|
reporter, kindA: 'node', kindB: 'node', seedPath, seedLog, seedExpect, keep, label: 'self',
|
|
});
|
|
reporter.summary('selftest');
|
|
console.log(`SELFTEST ${selftestOk ? 'PASS' : 'FAIL'}`);
|
|
}
|
|
|
|
if (!forceSelftest) {
|
|
if (goBinExists) {
|
|
console.log(`[run] FORMAL: Node vs Go (${goBin})`);
|
|
formalOk = await runPair({
|
|
reporter, kindA: 'node', kindB: 'go', bin: goBin, seedPath, seedLog, seedExpect, keep, label: 'formal',
|
|
});
|
|
reporter.summary('formal');
|
|
console.log(`FORMAL ${formalOk ? 'PASS' : 'FAIL'}`);
|
|
} else {
|
|
reporter.skip('formal/go-panel', `Go panel binary not found at ${goBin} (pass --bin <path> or set GO_PANEL_BIN); ran Node-vs-Node selftest only`);
|
|
console.log(`FORMAL SKIP (Go panel binary not found at ${goBin})`);
|
|
reporter.summary('formal');
|
|
}
|
|
}
|
|
|
|
const failed = reporter.failed > 0 || selftestOk === false || formalOk === false;
|
|
process.exitCode = failed ? 1 : 0;
|
|
}
|
|
|
|
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|
|
|
|
export { firstDiff };
|