diff options
Diffstat (limited to 'packages/meshbay-client/test')
| -rw-r--r-- | packages/meshbay-client/test/test-node-start.js | 268 |
1 files changed, 268 insertions, 0 deletions
diff --git a/packages/meshbay-client/test/test-node-start.js b/packages/meshbay-client/test/test-node-start.js new file mode 100644 index 0000000..1e91419 --- /dev/null +++ b/packages/meshbay-client/test/test-node-start.js @@ -0,0 +1,268 @@ +#!/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); +}); |