// 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 formal mode against this Go binary // GO_PANEL_BIN= 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 // #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 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 };