Files

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 };