#!/usr/bin/env node /** * Integration test for the node:start logic in main.js. * * Exercises each step of the startup sequence on the real system: * 1. probeNode — can we reach a running daemon? * 2. systemctl enable --now — does the unit start the daemon? * 3. is-active check — did the unit crash (e.g. wrong ExecStart path)? * 4. direct start fallback — find binary in PATH, spawn it * 5. probeNode poll — does the daemon respond after direct start? * * Run: node test/test-node-start.js * * The test leaves the daemon running on success so subsequent Electron * testing can pick it up. Pass --cleanup to kill it after the test. */ 'use strict'; const { execFile, spawn } = require('node:child_process'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const CLEANUP = process.argv.includes('--cleanup'); let passed = 0; let failed = 0; function ok(label) { passed++; console.log(` \x1b[32m✓\x1b[0m ${label}`); } function fail(label, detail) { failed++; console.log(` \x1b[31m✗\x1b[0m ${label}`); if (detail) console.log(` ${detail}`); } function info(msg) { console.log(` \x1b[36mℹ\x1b[0m ${msg}`); } // ── helpers (same logic as main.js) ────────────────────────────────────────── function nodeConfigPath() { return path.join(os.homedir(), '.config', 'meshbay', 'node.toml'); } function readNodeConfig() { try { const text = fs.readFileSync(nodeConfigPath(), 'utf8'); let dataDir = path.join(os.homedir(), '.local', 'share', 'meshbay'); let uiPort = 18000; const dataMatch = text.match(/^\s*data_dir\s*=\s*"([^"]+)"/m); if (dataMatch) dataDir = dataMatch[1].replace(/^~/, os.homedir()); const portMatch = text.match(/^\s*ui_port\s*=\s*(\d+)/m); if (portMatch) uiPort = parseInt(portMatch[1], 10); return { dataDir, uiPort }; } catch { return null; } } function readNodeToken(dataDir) { try { return fs.readFileSync(path.join(dataDir, 'ui-token'), 'utf8').trim(); } catch { return null; } } async function probeNode() { const nc = readNodeConfig(); const dataDir = nc ? nc.dataDir : path.join(os.homedir(), '.local', 'share', 'meshbay'); const port = nc ? nc.uiPort : 18000; const token = readNodeToken(dataDir); if (!token) return null; try { const r = await fetch( `http://127.0.0.1:${port}/api/status?t=${token}`, { signal: AbortSignal.timeout(3000) }); if (!r.ok) return null; const status = await r.json(); return { port, pk_node_ed25519: status.pk_node_ed25519 || '' }; } catch { return null; } } function exec(cmd, args) { return new Promise((resolve) => { execFile(cmd, args, (err, stdout, stderr) => { resolve({ err, stdout: stdout?.trim(), stderr: stderr?.trim() }); }); }); } async function pollProbe(seconds) { const deadline = Date.now() + seconds * 1000; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 500)); const result = await probeNode(); if (result) return result; } return null; } // ── kill any running daemon ────────────────────────────────────────────────── async function killDaemon() { const { stdout } = await exec('pgrep', ['-f', 'meshbay.node.daemon']); if (stdout) { for (const pid of stdout.split('\n').filter(Boolean)) { try { process.kill(Number(pid), 'SIGTERM'); } catch {} } await new Promise((r) => setTimeout(r, 1000)); } await exec('systemctl', ['--user', 'stop', 'meshbay-node']); } // ── tests ──────────────────────────────────────────────────────────────────── async function main() { console.log('\nnode:start integration tests\n'); // ── Prerequisites ── console.log('Prerequisites:'); const configFile = nodeConfigPath(); if (fs.existsSync(configFile)) { ok(`Config exists: ${configFile}`); } else { fail(`Config missing: ${configFile}`); process.exit(1); } const nc = readNodeConfig(); info(`dataDir = ${nc.dataDir}`); info(`uiPort = ${nc.uiPort}`); const { stdout: binPath } = await exec('which', ['meshbay-node']); if (binPath) { ok(`Binary found: ${binPath}`); } else { fail('meshbay-node not in PATH'); process.exit(1); } const unitExists = fs.existsSync( path.join(os.homedir(), '.config', 'systemd', 'user', 'meshbay-node.service')); info(`systemd unit installed: ${unitExists}`); if (unitExists) { const { stdout: execStart } = await exec('systemctl', [ '--user', 'show', 'meshbay-node', '--property=ExecStart']); const execPath = execStart?.match(/path=([^ ;]+)/)?.[1] || '?'; const execExists = fs.existsSync(execPath); if (execExists) { ok(`Unit ExecStart binary exists: ${execPath}`); } else { info(`Unit ExecStart binary MISSING: ${execPath} (fallback will be needed)`); } } // ── Test 1: probeNode when daemon is stopped ── console.log('\n1. probeNode with daemon stopped:'); await killDaemon(); const probeDown = await probeNode(); if (!probeDown) { ok('probeNode returns null when daemon is stopped'); } else { fail('probeNode returned a result but daemon should be stopped', JSON.stringify(probeDown)); } // ── Test 2: systemctl enable --now ── if (unitExists) { console.log('\n2. systemctl enable --now:'); const { err: sysErr, stderr: sysStderr } = await exec( 'systemctl', ['--user', 'enable', '--now', 'meshbay-node']); if (sysErr) { info(`systemctl returned error: ${sysStderr || sysErr.message}`); } else { ok('systemctl enable --now returned 0'); } // Wait a moment, then check await new Promise((r) => setTimeout(r, 2000)); const { err: activeErr, stdout: activeState } = await exec( 'systemctl', ['--user', 'is-active', 'meshbay-node']); info(`is-active: "${activeState}" (exit ${activeErr ? activeErr.code : 0})`); const isActive = !activeErr; if (isActive) { ok('Service is active'); const probeUp = await pollProbe(5); if (probeUp) { ok(`probeNode succeeds (pk: ${probeUp.pk_node_ed25519?.slice(0, 16)}...)`); } else { fail('probeNode returned null despite service being active'); } } else { info('Service is NOT active — this is expected in dev mode (ExecStart mismatch)'); // Verify is-failed vs is-active difference (the bug we fixed) const { err: failedErr, stdout: failedState } = await exec( 'systemctl', ['--user', 'is-failed', 'meshbay-node']); info(`is-failed: "${failedState}" (exit ${failedErr ? failedErr.code : 0})`); const isFailed = !failedErr; if (!isFailed && activeState === 'activating') { ok('Confirmed bug scenario: is-failed=false while is-active=activating (auto-restart loop)'); info('Old code would NOT fall back; new code uses is-active and DOES fall back'); } // Stop the broken unit await exec('systemctl', ['--user', 'stop', 'meshbay-node']); await exec('systemctl', ['--user', 'disable', 'meshbay-node']); ok('Stopped and disabled broken unit'); } } else { console.log('\n2. systemctl (skipped — no unit file)'); } // ── Test 3: Direct start fallback ── console.log('\n3. Direct start fallback:'); await killDaemon(); await new Promise((r) => setTimeout(r, 500)); const child = spawn(binPath, ['--config', configFile], { detached: true, stdio: 'ignore', }); child.unref(); ok(`Spawned ${binPath} --config ${configFile} (pid ${child.pid})`); const probeAfter = await pollProbe(15); if (probeAfter) { ok(`probeNode succeeds after direct start (pk: ${probeAfter.pk_node_ed25519?.slice(0, 16)}...)`); } else { fail('probeNode returned null after 15 seconds of polling'); // Debug info const token = readNodeToken(nc.dataDir); info(`Token file present: ${!!token}`); const { stdout: pgrep } = await exec('pgrep', ['-af', 'meshbay']); info(`meshbay processes: ${pgrep || 'none'}`); } // ── Test 4: probeNode when daemon is already running ── console.log('\n4. probeNode when daemon already running:'); if (probeAfter) { const probeAgain = await probeNode(); if (probeAgain) { ok('probeNode returns immediately for running daemon'); } else { fail('probeNode returned null for running daemon'); } } else { info('Skipped — daemon did not start'); } // ── Cleanup ── if (CLEANUP) { console.log('\nCleanup:'); await killDaemon(); ok('Daemon stopped'); } else { info('\nDaemon left running for Electron testing. Pass --cleanup to stop it.'); } // ── Summary ── console.log(`\n${passed} passed, ${failed} failed\n`); process.exit(failed > 0 ? 1 : 0); } main().catch((err) => { console.error(err); process.exit(1); });