aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/test/test-node-start.js
blob: 1e91419403a957d2db9108992ee8b71d3b06afbd (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
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);
});