testronaut 1.0.18 → 1.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/cli.js +143 -1
  2. package/package.json +1 -1
package/bin/cli.js CHANGED
@@ -8,6 +8,12 @@ import { generateHtmlReport } from '../tools/generateHtmlReport.js';
8
8
  import inquirer from 'inquirer';
9
9
  import fetch from 'node-fetch';
10
10
  import crypto from 'crypto';
11
+ import http from 'http';
12
+ import { exec as execCmd } from 'child_process';
13
+ import { promisify } from 'util';
14
+ const exec = promisify(execCmd);
15
+ import url from 'url';
16
+
11
17
 
12
18
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
19
 
@@ -30,6 +36,8 @@ Usage:
30
36
  npx testronaut <file> Run a specific mission file (e.g., login.mission.js)
31
37
  npx testronaut login Log in and store session token
32
38
  npx testronaut upload Upload the most recent report JSON
39
+ npx testronaut serve Serve & open the most recent HTML report (read-only)
40
+ npx testronaut view Alias of 'serve'
33
41
 
34
42
  Options:
35
43
  --init Scaffold project folders and a welcome mission
@@ -39,6 +47,7 @@ Examples:
39
47
  npx testronaut
40
48
  npx testronaut login
41
49
  npx testronaut upload
50
+ npx testronaut serve
42
51
  npx testronaut --init
43
52
  `;
44
53
 
@@ -65,6 +74,14 @@ if (args.includes('upload')) {
65
74
  process.exit(0);
66
75
  }
67
76
 
77
+ // Handle the serve/view command
78
+ if (args.includes('serve') || args.includes('view')) {
79
+ await serveLatestReport();
80
+ // Keep process alive until user stops it
81
+ console.log('Press Ctrl+C to stop the server.');
82
+ await new Promise(() => {}); // ✅ never resolves; Ctrl+C will terminate
83
+ }
84
+
68
85
  if (!fs.existsSync(missionsDir)) {
69
86
  console.error('❌ No `missions` directory found.');
70
87
  process.exit(1);
@@ -178,7 +195,7 @@ async function handleLogin() {
178
195
  const response = await fetch(`${apiBase}/api/user/cli`, { // Replace with your actual URL
179
196
  method: 'POST',
180
197
  headers: {
181
- 'Content-Type': 'application/json',
198
+ 'Content-Type': 'application/x-www-form-urlencoded',
182
199
  },
183
200
  body: formData.toString(),
184
201
  });
@@ -371,3 +388,128 @@ async function uploadReport() {
371
388
  console.log('✅ All screenshots uploaded.');
372
389
  }
373
390
  }
391
+
392
+
393
+ function guessMimeType(p) {
394
+ const ext = path.extname(p).toLowerCase();
395
+ if (ext === '.html' || ext === '.htm') return 'text/html; charset=utf-8';
396
+ if (ext === '.json') return 'application/json; charset=utf-8';
397
+ if (ext === '.js') return 'text/javascript; charset=utf-8';
398
+ if (ext === '.css') return 'text/css; charset=utf-8';
399
+ if (ext === '.png') return 'image/png';
400
+ if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
401
+ if (ext === '.webp') return 'image/webp';
402
+ if (ext === '.gif') return 'image/gif';
403
+ if (ext === '.svg') return 'image/svg+xml';
404
+ return 'application/octet-stream';
405
+ }
406
+
407
+ function safeJoin(root, relUrlPath) {
408
+ // Prevent path traversal and keep within root
409
+ const decoded = decodeURIComponent(relUrlPath);
410
+ const clean = decoded.replace(/^\/+/, ''); // strip leading slash
411
+ const resolved = path.resolve(root, clean);
412
+ if (!resolved.startsWith(path.resolve(root))) {
413
+ return null; // attempted escape
414
+ }
415
+ return resolved;
416
+ }
417
+
418
+ async function openInBrowser(targetUrl) {
419
+ const platform = process.platform;
420
+ const quoted = `"${targetUrl}"`;
421
+ if (platform === 'darwin') return exec(`open ${quoted}`);
422
+ if (platform === 'win32') return exec(`start "" ${quoted}`);
423
+ return exec(`xdg-open ${quoted}`).catch(() => {}); // best-effort on Linux
424
+ }
425
+
426
+ function findLatestReportPair(reportDir) {
427
+ if (!fs.existsSync(reportDir)) return null;
428
+ const files = fs.readdirSync(reportDir).filter(f => f.endsWith('.html'));
429
+ if (!files.length) return null;
430
+
431
+ // Expecting run_<timestamp>.html — sort by numeric timestamp descending
432
+ files.sort((a, b) => {
433
+ const ta = parseInt(a.split('_')[1]) || 0;
434
+ const tb = parseInt(b.split('_')[1]) || 0;
435
+ return tb - ta;
436
+ });
437
+
438
+ const latestHtml = files[0];
439
+ const latestBase = path.basename(latestHtml, '.html');
440
+ const jsonCandidate = `${latestBase}.json`;
441
+
442
+ return {
443
+ htmlFile: latestHtml,
444
+ jsonFile: fs.existsSync(path.join(reportDir, jsonCandidate)) ? jsonCandidate : null,
445
+ };
446
+ }
447
+
448
+ async function serveLatestReport() {
449
+ const reportDir = path.resolve(process.cwd(), 'missions/mission_reports');
450
+
451
+ const latest = findLatestReportPair(reportDir);
452
+ if (!latest) {
453
+ console.error('❌ No HTML reports found in missions/mission_reports.');
454
+ process.exit(1);
455
+ }
456
+
457
+ const server = http.createServer((req, res) => {
458
+ try {
459
+ // Default route -> redirect to latest report
460
+ const parsed = url.parse(req.url || '/');
461
+ let pathname = parsed.pathname || '/';
462
+
463
+ if (pathname === '/' || pathname === '') {
464
+ res.statusCode = 302;
465
+ res.setHeader('Location', `/${latest.htmlFile}`);
466
+ res.end();
467
+ return;
468
+ }
469
+
470
+ const targetPath = safeJoin(reportDir, pathname);
471
+ if (!targetPath) {
472
+ res.statusCode = 400;
473
+ res.end('Bad Request');
474
+ return;
475
+ }
476
+
477
+ // If the path is a directory, try index.html (not expected, but harmless)
478
+ let toServe = targetPath;
479
+ if (fs.existsSync(toServe) && fs.statSync(toServe).isDirectory()) {
480
+ toServe = path.join(toServe, 'index.html');
481
+ }
482
+
483
+ if (!fs.existsSync(toServe) || !fs.statSync(toServe).isFile()) {
484
+ res.statusCode = 404;
485
+ res.end('Not Found');
486
+ return;
487
+ }
488
+
489
+ res.statusCode = 200;
490
+ res.setHeader('Content-Type', guessMimeType(toServe));
491
+ fs.createReadStream(toServe).pipe(res);
492
+ } catch (err) {
493
+ res.statusCode = 500;
494
+ res.end('Internal Server Error');
495
+ }
496
+ });
497
+
498
+ // Listen on a random free port
499
+ await new Promise((resolve, reject) => {
500
+ server.once('error', reject);
501
+ server.listen(0, '127.0.0.1', resolve);
502
+ });
503
+
504
+ const address = server.address();
505
+ const port = typeof address === 'object' && address ? address.port : 0;
506
+ const baseUrl = `http://127.0.0.1:${port}`;
507
+ const reportUrl = `${baseUrl}/${latest.htmlFile}`;
508
+
509
+ console.log(`📄 Serving reports from: ${path.relative(process.cwd(), reportDir)}`);
510
+ console.log(`🔗 Opening latest: ${latest.htmlFile}`);
511
+ console.log(`🌐 ${reportUrl}`);
512
+
513
+ // Best-effort auto-open
514
+ await openInBrowser(reportUrl).catch(() => {});
515
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "1.0.18",
3
+ "version": "1.0.19",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "./bin/cli.js"