testronaut 1.10.1 → 1.11.0

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.
package/bin/cli.js CHANGED
@@ -57,6 +57,7 @@ import { discoverMissionFiles } from '../core/missionDiscovery.js';
57
57
  import { loadMissionModule } from '../core/missionLoader.js';
58
58
  import { loadConfig } from '../core/config.js';
59
59
  import { matchesTagFilter, normalizeTagMatch, normalizeTags } from '../core/tags.js';
60
+ import { createTerminalUi } from './terminalUi.js';
60
61
 
61
62
  // Keep PW browsers inside the project to avoid global cache skew
62
63
  process.env.PLAYWRIGHT_BROWSERS_PATH = process.env.PLAYWRIGHT_BROWSERS_PATH || '0';
@@ -78,6 +79,7 @@ const jsonOutput = args.includes('--json');
78
79
  const quietOutput = args.includes('--quiet') || jsonOutput;
79
80
  args = args.filter(arg => arg !== '--json' && arg !== '--quiet');
80
81
  const writeOutput = value => process.stdout.write(`${typeof value === 'string' ? value : JSON.stringify(value, null, 2)}\n`);
82
+ const terminalUi = createTerminalUi({ quiet: quietOutput });
81
83
  if (quietOutput) {
82
84
  console.log = () => {};
83
85
  console.warn = () => {};
@@ -879,14 +881,14 @@ if (args.length === 1 && args[0] === 'list') {
879
881
  const listedMissions = [];
880
882
  const listTags = cliTags ?? normalizeTags(tagConfig?.tags, { allowUntagged: true });
881
883
  const listTagMatch = normalizeTagMatch(cliTagMatch ?? tagConfig?.tagMatch);
882
- if (!jsonOutput) console.log(`Missions in ${path.relative(process.cwd(), missionsRoot) || '.'}:`);
884
+ if (!jsonOutput) terminalUi.phase('preflight', `Missions in ${path.relative(process.cwd(), missionsRoot) || '.'}`);
883
885
  for (const file of discoveredMissions) {
884
886
  try {
885
887
  const mission = await loadMissionModule(path.resolve(missionsRoot, file));
886
888
  const tags = normalizeTags(mission.tags);
887
889
  const selected = matchesTagFilter(tags, listTags, listTagMatch);
888
890
  listedMissions.push({ file, tags, selected });
889
- if (!jsonOutput) console.log(` ${selected ? '✓' : '·'} ${file}${tags.length ? ` [${tags.join(', ')}]` : ''}`);
891
+ if (!jsonOutput) terminalUi.mission(selected, file, tags);
890
892
  } catch (error) {
891
893
  console.log(` ${file} [could not load: ${error.message}]`);
892
894
  process.exitCode = 1;
@@ -943,6 +945,17 @@ if (dryRun) {
943
945
  return;
944
946
  }
945
947
 
948
+ if (!jsonOutput) {
949
+ const previewModel = resolveProviderModel({ cwd: process.cwd() });
950
+ terminalUi.banner({
951
+ version: CLI_VERSION,
952
+ provider: previewModel.provider,
953
+ model: previewModel.model,
954
+ session: sessionMode,
955
+ });
956
+ terminalUi.phase('preflight', `${explicitFiles ? args.length : discoveredMissions.length} mission file(s) queued`);
957
+ }
958
+
946
959
  const runFile = async (filePath) => {
947
960
  const modulePath = resolveMissionPath(filePath, { cwd: process.cwd(), missionsRoot });
948
961
  try {
@@ -960,6 +973,7 @@ const runFile = async (filePath) => {
960
973
  }
961
974
 
962
975
  if (typeof missionsModule.executeMission === 'function') {
976
+ if (!jsonOutput) terminalUi.phase('launch', path.relative(process.cwd(), modulePath) || filePath);
963
977
  const result = await missionsModule.executeMission();
964
978
  if (result == null) {
965
979
  console.error(`❌ Mission did not return a result: ${filePath}`);
@@ -1053,8 +1067,10 @@ report.tags = normalizeTags(flatMissions.flatMap(m =>
1053
1067
 
1054
1068
  fs.mkdirSync(outputDir, { recursive: true });
1055
1069
  fs.writeFileSync(path.join(outputDir, `${runId}.json`), JSON.stringify(report, null, 2));
1056
- generateHtmlReport(report, path.join(outputDir, `${runId}.html`));
1070
+ const htmlReportPath = path.join(outputDir, `${runId}.html`);
1071
+ generateHtmlReport(report, htmlReportPath);
1057
1072
  if (jsonOutput) writeOutput(report);
1073
+ else terminalUi.summary({ ...report.summary, reportPath: path.relative(process.cwd(), htmlReportPath) });
1058
1074
 
1059
1075
  try {
1060
1076
  if (!process.env.TN_KEEP_TMP && fs.existsSync(TMP_DIR)) {
@@ -0,0 +1,51 @@
1
+ const ANSI = {
2
+ reset: '\u001b[0m',
3
+ bold: '\u001b[1m',
4
+ dim: '\u001b[2m',
5
+ orange: '\u001b[38;5;208m',
6
+ cyan: '\u001b[38;5;81m',
7
+ teal: '\u001b[38;5;43m',
8
+ red: '\u001b[38;5;203m',
9
+ cream: '\u001b[38;5;230m',
10
+ };
11
+
12
+ export function shouldUseColor(stream = process.stdout, env = process.env) {
13
+ return Boolean(stream?.isTTY) && !('NO_COLOR' in env) && env.TERM !== 'dumb';
14
+ }
15
+
16
+ export function createTerminalUi({
17
+ stream = process.stdout,
18
+ color = shouldUseColor(stream),
19
+ quiet = false,
20
+ } = {}) {
21
+ const paint = (codes, value) => color ? `${codes}${value}${ANSI.reset}` : String(value);
22
+ const write = value => {
23
+ if (!quiet) stream.write(`${value}\n`);
24
+ };
25
+ const label = value => paint(`${ANSI.bold}${ANSI.orange}`, value.padEnd(10));
26
+
27
+ return {
28
+ banner({ version, provider, model, session }) {
29
+ write(paint(`${ANSI.bold}${ANSI.cream}`, '🧑‍🚀 TESTRONAUT / MISSION RUNNER'));
30
+ write(paint(ANSI.dim, ` v${version} • ${provider}/${model} • ${session}`));
31
+ write(paint(ANSI.dim, ' ─────────────────────────────────────────'));
32
+ },
33
+ phase(name, message) {
34
+ write(`${label(name.toUpperCase())}${paint(ANSI.cyan, message)}`);
35
+ },
36
+ mission(selected, file, tags = []) {
37
+ const marker = selected ? paint(ANSI.teal, '●') : paint(ANSI.dim, '○');
38
+ const tagText = tags.length ? paint(ANSI.dim, ` ${tags.map(tag => `#${tag}`).join(' ')}`) : '';
39
+ write(` ${marker} ${file}${tagText}`);
40
+ },
41
+ summary({ totalMissions = 0, passed = 0, failed = 0, reportPath }) {
42
+ const status = failed === 0
43
+ ? paint(`${ANSI.bold}${ANSI.teal}`, 'MISSION COMPLETE')
44
+ : paint(`${ANSI.bold}${ANSI.red}`, 'MISSION NEEDS REVIEW');
45
+ write('');
46
+ write(`${label('COMPLETE')}${status}`);
47
+ write(` ${paint(ANSI.teal, `${passed} passed`)} ${paint(failed ? ANSI.red : ANSI.dim, `${failed} failed`)} ${paint(ANSI.dim, `${totalMissions} total`)}`);
48
+ if (reportPath) write(paint(ANSI.dim, ` Report: ${reportPath}`));
49
+ },
50
+ };
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "1.10.1",
3
+ "version": "1.11.0",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "bin/cli.js"
@@ -51,7 +51,7 @@
51
51
  "cli"
52
52
  ],
53
53
  "devDependencies": {
54
- "@types/node": "^25.9.3",
54
+ "@types/node": "^26.5.0",
55
55
  "periapsis": "^2.0.1",
56
56
  "vitest": "^4.1.11"
57
57
  },
@@ -271,11 +271,11 @@ describe('cli helpers', () => {
271
271
  });
272
272
 
273
273
  it('returns "testronaut" when argv[1] basename is testronaut (global install)', () => {
274
- expect(detectCliName(undefined, '/usr/local/bin/testronaut')).toBe('testronaut');
274
+ expect(detectCliName(null, '/usr/local/bin/testronaut')).toBe('testronaut');
275
275
  });
276
276
 
277
277
  it('returns "npx testronaut" when argv[1] is cli.js (direct node invocation)', () => {
278
- expect(detectCliName(undefined, '/home/user/testronaut-cli/bin/cli.js')).toBe('npx testronaut');
278
+ expect(detectCliName(null, '/home/user/testronaut-cli/bin/cli.js')).toBe('npx testronaut');
279
279
  });
280
280
 
281
281
  it('npm_command=exec takes precedence over a global-bin argv[1]', () => {
@@ -283,11 +283,11 @@ describe('cli helpers', () => {
283
283
  });
284
284
 
285
285
  it('returns "npx testronaut" when argv[1] is undefined', () => {
286
- expect(detectCliName(undefined, undefined)).toBe('npx testronaut');
286
+ expect(detectCliName(null, null)).toBe('npx testronaut');
287
287
  });
288
288
 
289
289
  it('returns "npx testronaut" when argv[1] is empty string', () => {
290
- expect(detectCliName(undefined, '')).toBe('npx testronaut');
290
+ expect(detectCliName(null, '')).toBe('npx testronaut');
291
291
  });
292
292
  });
293
293
  });
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createTerminalUi, shouldUseColor } from '../../bin/terminalUi.js';
3
+
4
+ function capture() {
5
+ let output = '';
6
+ return {
7
+ stream: { isTTY: false, write(value) { output += value; } },
8
+ read: () => output,
9
+ };
10
+ }
11
+
12
+ describe('terminal UI', () => {
13
+ it('renders a readable color-free mission lifecycle', () => {
14
+ const target = capture();
15
+ const ui = createTerminalUi({ stream: target.stream, color: false });
16
+
17
+ ui.banner({ version: '1.2.3', provider: 'openai', model: 'gpt-test', session: 'isolated' });
18
+ ui.phase('launch', 'login.mission.js');
19
+ ui.mission(true, 'login.mission.js', ['smoke']);
20
+ ui.summary({ totalMissions: 1, passed: 1, failed: 0, reportPath: 'reports/run.html' });
21
+
22
+ expect(target.read()).toContain('🧑‍🚀 TESTRONAUT / MISSION RUNNER');
23
+ expect(target.read()).toContain('LAUNCH login.mission.js');
24
+ expect(target.read()).toContain('#smoke');
25
+ expect(target.read()).toContain('MISSION COMPLETE');
26
+ expect(target.read()).not.toContain('\u001b[');
27
+ });
28
+
29
+ it('honors terminal capability and NO_COLOR', () => {
30
+ expect(shouldUseColor({ isTTY: true }, {})).toBe(true);
31
+ expect(shouldUseColor({ isTTY: true }, { NO_COLOR: '' })).toBe(false);
32
+ expect(shouldUseColor({ isTTY: false }, {})).toBe(false);
33
+ });
34
+ });
@@ -55,6 +55,9 @@ describe('generateHtmlReport', () => {
55
55
  const html = fs.readFileSync(outPath, 'utf8');
56
56
 
57
57
  expect(html).toContain('Testronaut Report');
58
+ expect(html).toContain('Mission Control · Flight Report');
59
+ expect(html).toContain('--orange:#ff8a43');
60
+ expect(html).toContain('--cyan:#79d7df');
58
61
  expect(html).toContain('Mission A');
59
62
  expect(html).toContain('Turn 1 (re-attempt 1/5)');
60
63
  expect(html).toContain('⚠️ Turn Issues');
@@ -37,4 +37,21 @@ describe('tools/turnLoopUtils', () => {
37
37
  logSpy.mockRestore();
38
38
  }
39
39
  });
40
+
41
+ it('uses the mission transmission frame without changing result data', () => {
42
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
43
+ try {
44
+ expect(finalResponseHandler({ content: 'SUCCESS: Docking complete' })).toEqual({
45
+ finalMessage: 'SUCCESS: Docking complete',
46
+ success: true,
47
+ });
48
+ const printed = logSpy.mock.calls.flat().join('\n');
49
+ expect(printed).toContain('🧑‍🚀 MISSION TRANSMISSION / SUCCESS');
50
+ expect(printed).toContain('│ SUCCESS: Docking complete');
51
+ expect(printed).toContain('END TRANSMISSION');
52
+ expect(printed).not.toContain('FINAL AGENT RESPONSE');
53
+ } finally {
54
+ logSpy.mockRestore();
55
+ }
56
+ });
40
57
  });
@@ -196,55 +196,71 @@ export function generateHtmlReport(report, outputPath) {
196
196
  <title>Testronaut Report – ${esc(runId)}</title>
197
197
  <meta name="viewport" content="width=device-width, initial-scale=1" />
198
198
  <style>
199
- /* ===== Brand + surface tokens (match splash/app) ===== */
199
+ /* ===== Testronaut launch-control tokens ===== */
200
200
  :root{
201
- --bg-top:#0b1022;
202
- --bg-mid:#0f172a;
203
- --bg-btm:#0b1022;
204
-
205
- --hairline:rgba(255,255,255,.12);
206
- --hairline-strong:rgba(255,255,255,.18);
201
+ --bg-top:#050b18;
202
+ --bg-mid:#081426;
203
+ --bg-btm:#030712;
204
+ --panel:rgba(8,24,45,.78);
205
+ --panel-strong:rgba(7,19,36,.94);
206
+ --orange:#ff8a43;
207
+ --amber:#ffc15b;
208
+ --cyan:#79d7df;
209
+
210
+ --hairline:rgba(121,215,223,.20);
211
+ --hairline-strong:rgba(121,215,223,.34);
207
212
 
208
213
  --text:rgba(255,255,255,.92);
209
214
  --text-muted:rgba(255,255,255,.65);
210
215
 
211
- --ok:#22c55e; /* green-500 */
212
- --bad:#ef4444; /* red-500 */
216
+ --ok:#39d9aa;
217
+ --bad:#ff6678;
213
218
 
214
219
  --chip-ok-bg:rgba(34,197,94,.14);
215
220
  --chip-bad-bg:rgba(239,68,68,.16);
216
- --chip-border:rgba(255,255,255,.22);
221
+ --chip-border:rgba(121,215,223,.30);
217
222
  --chip-ok-border:rgba(34,197,94,.40);
218
223
  --chip-bad-border:rgba(239,68,68,.45);
219
224
  }
220
225
 
221
226
  /* ===== Base ===== */
222
- html,body{height:100%;}
227
+ html,body{min-height:100%;}
223
228
  body{
224
229
  margin:0; padding:24px;
225
230
  font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Helvetica,Arial;
226
- background: linear-gradient(180deg,var(--bg-top) 0%,var(--bg-mid) 60%,var(--bg-btm) 100%);
231
+ background:
232
+ radial-gradient(circle at 84% 8%,rgba(255,138,67,.16),transparent 25rem),
233
+ radial-gradient(circle at 10% 32%,rgba(121,215,223,.10),transparent 28rem),
234
+ linear-gradient(180deg,var(--bg-top) 0%,var(--bg-mid) 60%,var(--bg-btm) 100%);
235
+ background-attachment:fixed;
227
236
  color: var(--text);
228
237
  -webkit-font-smoothing: antialiased;
229
238
  -moz-osx-font-smoothing: grayscale;
230
239
  }
240
+ body::before{
241
+ content:"";position:fixed;inset:0;pointer-events:none;opacity:.22;
242
+ background-image:linear-gradient(rgba(121,215,223,.08) 1px,transparent 1px),linear-gradient(90deg,rgba(121,215,223,.08) 1px,transparent 1px);
243
+ background-size:42px 42px;mask-image:linear-gradient(to bottom,black,transparent 82%);
244
+ }
231
245
 
232
246
  /* ===== Utilities ===== */
233
247
  .gradient-text{
234
- background: linear-gradient(90deg,#60a5fa,#a78bfa,#34d399);
248
+ background: linear-gradient(90deg,#fff7e7,var(--amber),var(--orange));
235
249
  -webkit-background-clip:text; background-clip:text; color:transparent;
236
250
  }
237
251
  .glass{
238
- background: rgba(255,255,255,.05);
252
+ background: var(--panel);
239
253
  border: 1px solid var(--hairline);
240
254
  border-radius:16px;
241
255
  backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
242
- box-shadow: 0 10px 30px rgba(59,130,246,.20);
256
+ box-shadow: 0 18px 45px rgba(0,0,0,.28), inset 0 1px rgba(255,255,255,.035);
243
257
  }
244
258
 
245
259
  /* ===== Header ===== */
246
- .header{ max-width:960px; margin:0 auto 16px; padding:16px 18px; }
247
- h1{ margin:0 0 6px; font-size:22px; font-weight:800; color:#fff; }
260
+ .header{ max-width:960px; margin:0 auto 16px; padding:22px 24px; position:relative; overflow:hidden; }
261
+ .header::after{content:"";position:absolute;right:0;top:0;width:92px;height:4px;background:linear-gradient(90deg,var(--orange),var(--amber));}
262
+ .eyebrow{margin-bottom:7px;color:var(--cyan);font-size:11px;font-weight:800;letter-spacing:.18em;text-transform:uppercase;}
263
+ h1{ margin:0 0 12px; font-size:27px; font-weight:850; color:#fff; letter-spacing:-.02em; }
248
264
  .run-meta{ color:var(--text-muted); display:grid; gap:4px; }
249
265
  .provider-meta{ display:inline-flex; align-items:center; gap:7px; }
250
266
  .provider-logo{ width:20px; height:20px; color:var(--text); flex:none; }
@@ -258,6 +274,7 @@ export function generateHtmlReport(report, outputPath) {
258
274
  padding:6px 12px; border-radius:999px; font-size:12px; color:var(--text);
259
275
  letter-spacing:.2px;
260
276
  }
277
+ .pill:first-child{border-color:rgba(255,138,67,.5);box-shadow:inset 3px 0 var(--orange);}
261
278
  .pill.ok{ background: var(--chip-ok-bg); color: var(--ok); border-color: var(--chip-ok-border); font-weight:700; }
262
279
  .pill.bad{ background: var(--chip-bad-bg); color: var(--bad); border-color: var(--chip-bad-border); font-weight:700; }
263
280
  .tag-filter{max-width:928px;margin:0 auto 20px;padding:18px 16px;background:rgba(2,6,23,.30);}
@@ -267,7 +284,7 @@ export function generateHtmlReport(report, outputPath) {
267
284
  .match-count{margin-left:auto;color:var(--text-muted);font-size:12px;}
268
285
  .tag-buttons,.mission-tags{display:flex;gap:6px;flex-wrap:wrap;align-items:center;}
269
286
  .tag-button,.tag-small{border:1px solid var(--chip-border);background:rgba(255,255,255,.06);color:var(--text);border-radius:999px;padding:5px 10px;font-size:11px;font-weight:700;}
270
- .tag-button{cursor:pointer;transition:transform .15s ease,background .15s ease}.tag-button:hover{transform:translateY(-1px);background:rgba(255,255,255,.11)}.tag-button.active{box-shadow:0 0 0 2px rgba(96,165,250,.38);}
287
+ .tag-button{cursor:pointer;transition:transform .15s ease,background .15s ease}.tag-button:hover{transform:translateY(-1px);background:rgba(121,215,223,.12)}.tag-button.active{box-shadow:0 0 0 2px rgba(255,138,67,.48);}
271
288
  .filter-controls{display:flex;gap:16px;align-items:center;margin-top:14px;font-size:12px;color:var(--text-muted);flex-wrap:wrap;}
272
289
  .filter-controls select{color:var(--text);background:#111a31;border:1px solid var(--chip-border);border-radius:8px;padding:6px 24px 6px 8px;}
273
290
  .show-control{cursor:help;display:flex;align-items:center;gap:5px;}
@@ -277,12 +294,12 @@ export function generateHtmlReport(report, outputPath) {
277
294
  @media(max-width:640px){.match-count{width:100%;margin-left:0}.tag-filter{padding:14px 12px}.meta{display:none}}
278
295
 
279
296
  /* ===== Disclosure blocks ===== */
280
- details{ background: rgba(255,255,255,.05); border:1px solid var(--hairline); border-radius:16px; margin:10px 0; overflow:hidden; }
297
+ details{ background: var(--panel); border:1px solid var(--hairline); border-radius:16px; margin:10px 0; overflow:hidden; }
281
298
  summary{
282
299
  cursor:pointer; padding:14px 16px; display:flex; align-items:center; gap:12px;
283
300
  color:#fff; background: rgba(255,255,255,.04);
284
301
  }
285
- .mission-group > summary{ font-weight:800; font-size:15px; letter-spacing:.2px; }
302
+ .mission-group > summary{ font-weight:800; font-size:15px; letter-spacing:.2px; border-left:3px solid var(--orange); }
286
303
  .mission-group > summary:hover{ background: rgba(255,255,255,.08); }
287
304
 
288
305
  .group-body{ padding:12px; display:grid; gap:10px; }
@@ -336,7 +353,7 @@ export function generateHtmlReport(report, outputPath) {
336
353
  .tokens{ color: var(--text-muted); font-size:12px; margin-left:auto; font-variant-numeric: tabular-nums; }
337
354
 
338
355
  .events{
339
- background: rgba(2,6,23,.9); /* near #020617 */
356
+ background: var(--panel-strong);
340
357
  color: rgba(255,255,255,.92);
341
358
  padding:12px; border-radius:12px; margin:12px 0 0;
342
359
  font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New";
@@ -357,6 +374,7 @@ export function generateHtmlReport(report, outputPath) {
357
374
  </head>
358
375
  <body>
359
376
  <div class="header glass">
377
+ <div class="eyebrow">Mission Control · Flight Report</div>
360
378
  <h1>🧑‍🚀 <span class="gradient-text">Testronaut Report</span></h1>
361
379
  <div class="run-meta">
362
380
  <div class="provider-meta"><span class="provider-logo" title="${esc(provider.name)}" aria-label="${esc(provider.name)}">${provider.logo}</span><strong>${esc(llm.model ?? '—')}</strong></div>
@@ -5,17 +5,13 @@ export const finalResponseHandler = (msg) => {
5
5
  const safeContent = redactPasswordInText(content);
6
6
  const final = content.trim().replace(/^[#*_`\s]+/, '');
7
7
  const verdict = final.match(/^(?:(?:result|final)\s*:\s*)?(success|failure)\b/i)?.[1]?.toLowerCase();
8
- if (verdict === 'success') {
9
- console.log('\n┏━ FINAL AGENT RESPONSE ━━━━━━━━━━━━━━━━━━━');
10
- console.log(safeContent);
11
- console.log('┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
12
- return { finalMessage: safeContent, success: true};
13
- }
14
- if (verdict === 'failure') {
15
- console.log('\n┏━ FINAL AGENT RESPONSE ━━━━━━━━━━━━━━━━━━━');
16
- console.log(safeContent);
17
- console.log('┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
18
- return { finalMessage: safeContent, success: false};
8
+ if (verdict === 'success' || verdict === 'failure') {
9
+ const success = verdict === 'success';
10
+ const signal = success ? 'SUCCESS' : 'FAILURE';
11
+ console.log(`\n╭─ 🧑‍🚀 MISSION TRANSMISSION / ${signal} ─────────────────`);
12
+ safeContent.split('\n').forEach(line => console.log(`│ ${line}`));
13
+ console.log('╰─ END TRANSMISSION ─────────────────────────────');
14
+ return { finalMessage: safeContent, success };
19
15
  }
20
16
  return null;
21
17
  }