aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/test/test-node-start.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-client/test/test-node-start.js')
-rw-r--r--packages/meshbay-client/test/test-node-start.js268
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);
+});