testronaut 1.12.0 โ†’ 1.12.2

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/README.md CHANGED
@@ -230,6 +230,10 @@ testronaut upload run_1788745106444
230
230
  testronaut upload run_1788745106444.json --no-upload-screenshots
231
231
  ```
232
232
 
233
+ The local HTML report stays focused on inspecting one run without a service
234
+ dependency. Upload a report to Mission Control for filterable per-mission and
235
+ per-turn token, estimated-cost, and duration charts, including cumulative views.
236
+
233
237
  Chain missions together:
234
238
  ```js
235
239
  await runMissions({
@@ -239,6 +243,34 @@ await runMissions({
239
243
  }, "Contact Form Flow");
240
244
  ```
241
245
 
246
+ Control the order of an automatically discovered suite in
247
+ `testronaut-config.json`:
248
+
249
+ ```json
250
+ {
251
+ "missions": {
252
+ "include": ["**/*.mission.js"],
253
+ "order": [
254
+ "login.mission.js",
255
+ "account/**/*.mission.js",
256
+ "*",
257
+ "logout.mission.js"
258
+ ]
259
+ }
260
+ }
261
+ ```
262
+
263
+ Order entries are applied from top to bottom. Each pattern's matches remain
264
+ alphabetical, `*` places missions not claimed by another entry, and unlisted
265
+ missions are appended alphabetically. An exact filename that is not discovered
266
+ produces a warning. Without `missions.order`, discovery remains alphabetical.
267
+ Mission filenames supplied directly on the command line run in argument order.
268
+
269
+ `maxTurns` counts working turns. If a mission spends the full budget without a
270
+ verdict, Testronaut makes one additional tool-free closure request so the report
271
+ ends with an explicit success or failure. JSON and HTML reports include elapsed
272
+ milliseconds for each turn and mission plus the duration of the complete suite.
273
+
242
274
  ---
243
275
 
244
276
  ## ๐Ÿงฐ Developer Mode (Staging API)
package/bin/cli.js CHANGED
@@ -951,7 +951,12 @@ if (args.includes('serve') || args.includes('view')) {
951
951
  await new Promise(() => {}); // โœ… never resolves; Ctrl+C will terminate
952
952
  }
953
953
 
954
- const { root: missionsRoot, files: discoveredMissions } = await discoverMissionFiles({ cwd: process.cwd() });
954
+ const {
955
+ root: missionsRoot,
956
+ files: discoveredMissions,
957
+ warnings: missionDiscoveryWarnings = [],
958
+ } = await discoverMissionFiles({ cwd: process.cwd() });
959
+ for (const warning of missionDiscoveryWarnings) console.warn(`โš ๏ธ ${warning}`);
955
960
  const tagConfig = await loadConfig(process.cwd());
956
961
  const optimizationPolicy = resolveOptimizationPolicy({ config: tagConfig });
957
962
  if (!optimizationPolicy.valid) {
@@ -1144,14 +1149,19 @@ const runFile = async (filePath) => {
1144
1149
 
1145
1150
  if (typeof missionsModule.executeMission === 'function') {
1146
1151
  if (!jsonOutput) terminalUi.phase('launch', path.relative(process.cwd(), modulePath) || filePath);
1152
+ const missionFileStartTime = Date.now();
1147
1153
  const result = await missionsModule.executeMission();
1154
+ const missionFileEndTime = Date.now();
1148
1155
  if (result == null) {
1149
1156
  console.error(`โŒ Mission did not return a result: ${filePath}`);
1150
1157
  return false;
1151
1158
  }
1152
1159
  allResults.push({
1153
1160
  file: path.relative(process.cwd(), modulePath) || path.basename(modulePath),
1154
- result
1161
+ result,
1162
+ startTime: missionFileStartTime,
1163
+ endTime: missionFileEndTime,
1164
+ durationMs: Math.max(0, missionFileEndTime - missionFileStartTime),
1155
1165
  });
1156
1166
  const missionResults = Array.isArray(result) ? result : [result];
1157
1167
  return !missionResults.some(item => item?.status === 'failed');
@@ -1206,6 +1216,9 @@ const flatMissions = allResults.flatMap(entry => {
1206
1216
  ...m,
1207
1217
  steps,
1208
1218
  file: entry.file,
1219
+ startTime: m.startTime ?? entry.startTime,
1220
+ endTime: m.endTime ?? entry.endTime,
1221
+ durationMs: m.durationMs ?? entry.durationMs,
1209
1222
  };
1210
1223
  const jevShadow = summarizeJevUsage(steps);
1211
1224
  return jevShadow.evaluations ? { ...mission, jevShadow } : mission;
@@ -1226,6 +1239,7 @@ const report = {
1226
1239
  cli: { version: CLI_VERSION },
1227
1240
  startTime: startTime.toISOString(),
1228
1241
  endTime: endTime.toISOString(),
1242
+ durationMs: Math.max(0, endTime.getTime() - startTime.getTime()),
1229
1243
  llm: {
1230
1244
  provider: llmProvider,
1231
1245
  model: llmModel,
@@ -1283,7 +1297,7 @@ fs.writeFileSync(path.join(outputDir, `${runId}.json`), JSON.stringify(report, n
1283
1297
  const htmlReportPath = path.join(outputDir, `${runId}.html`);
1284
1298
  generateHtmlReport(report, htmlReportPath);
1285
1299
  if (jsonOutput) writeOutput(report);
1286
- else terminalUi.summary({ ...report.summary, reportPath: path.relative(process.cwd(), htmlReportPath) });
1300
+ else terminalUi.summary({ ...report.summary, durationMs: report.durationMs, reportPath: path.relative(process.cwd(), htmlReportPath) });
1287
1301
 
1288
1302
  try {
1289
1303
  if (!process.env.TN_KEEP_TMP && fs.existsSync(TMP_DIR)) {
package/bin/terminalUi.js CHANGED
@@ -9,6 +9,12 @@ const ANSI = {
9
9
  cream: '\u001b[38;5;230m',
10
10
  };
11
11
 
12
+ export function formatDuration(durationMs) {
13
+ if (!Number.isFinite(durationMs)) return null;
14
+ const seconds = Math.max(0, durationMs) / 1000;
15
+ return seconds < 60 ? `${seconds.toFixed(2)}s` : `${Math.floor(seconds / 60)}m ${(seconds % 60).toFixed(1)}s`;
16
+ }
17
+
12
18
  export function shouldUseColor(stream = process.stdout, env = process.env) {
13
19
  return Boolean(stream?.isTTY) && !('NO_COLOR' in env) && env.TERM !== 'dumb';
14
20
  }
@@ -38,13 +44,15 @@ export function createTerminalUi({
38
44
  const tagText = tags.length ? paint(ANSI.dim, ` ${tags.map(tag => `#${tag}`).join(' ')}`) : '';
39
45
  write(` ${marker} ${file}${tagText}`);
40
46
  },
41
- summary({ totalMissions = 0, passed = 0, failed = 0, reportPath }) {
47
+ summary({ totalMissions = 0, passed = 0, failed = 0, durationMs, reportPath }) {
42
48
  const status = failed === 0
43
49
  ? paint(`${ANSI.bold}${ANSI.teal}`, 'MISSION COMPLETE')
44
50
  : paint(`${ANSI.bold}${ANSI.red}`, 'MISSION NEEDS REVIEW');
45
51
  write('');
46
52
  write(`${label('COMPLETE')}${status}`);
47
53
  write(` ${paint(ANSI.teal, `${passed} passed`)} ${paint(failed ? ANSI.red : ANSI.dim, `${failed} failed`)} ${paint(ANSI.dim, `${totalMissions} total`)}`);
54
+ const duration = formatDuration(durationMs);
55
+ if (duration) write(paint(ANSI.dim, ` Duration: ${duration}`));
48
56
  if (reportPath) write(paint(ANSI.dim, ` Report: ${reportPath}`));
49
57
  },
50
58
  };
package/core/agent.js CHANGED
@@ -59,6 +59,7 @@ export async function runAgent(goals, missionName, maxTurns = 20, retryLimit, op
59
59
  const groundControl = createEmptyGroundControl();
60
60
 
61
61
  for (const goal of goals) {
62
+ const goalStartTime = Date.now();
62
63
  const telemetryStart = groundControl.telemetry.length;
63
64
  const steps = [];
64
65
  const stepsArchive = [];
@@ -114,6 +115,7 @@ export async function runAgent(goals, missionName, maxTurns = 20, retryLimit, op
114
115
  stepFile,
115
116
  startTime: now,
116
117
  endTime: now,
118
+ durationMs: 0,
117
119
  telemetry: [],
118
120
  launchProtocol: { id: protocol.id, status: 'reused', probe: 'passed' },
119
121
  });
@@ -289,6 +291,7 @@ Email invitation and magic links:
289
291
  }
290
292
 
291
293
  const compact = dedupeSteps(stepsArchive.length ? stepsArchive : steps);
294
+ const goalEndTime = Date.now();
292
295
 
293
296
  // Snapshot steps (avoid retaining references to mutable arrays)
294
297
  missionResults.push({
@@ -298,8 +301,9 @@ Email invitation and magic links:
298
301
  status: result?.success ? 'passed' : 'failed',
299
302
  steps: JSON.parse(JSON.stringify(compact)), // last 20 (by memory design)
300
303
  stepFile, // full history is in this JSONL (one step per line)
301
- startTime: Date.now() - 1, // TODO: wire actual timings if needed
302
- endTime: Date.now(),
304
+ startTime: goalStartTime,
305
+ endTime: goalEndTime,
306
+ durationMs: Math.max(0, goalEndTime - goalStartTime),
303
307
  telemetry: JSON.parse(JSON.stringify(groundControl.telemetry.slice(telemetryStart))),
304
308
  ...(protocolResult ? { launchProtocol: protocolResult } : {}),
305
309
  });
@@ -6,6 +6,54 @@ import { loadConfig } from './config.js';
6
6
  const DEFAULT_ROOT = 'missions';
7
7
  const DEFAULT_INCLUDE = ['**/*.mission.js', '**/*.mission.ts'];
8
8
 
9
+ function orderMissionFiles(files, order, root) {
10
+ const alphabetical = [...files].sort();
11
+ if (!Array.isArray(order) || order.length === 0) {
12
+ return { files: alphabetical, warnings: [] };
13
+ }
14
+
15
+ const remaining = new Set(alphabetical);
16
+ const ordered = [];
17
+ const warnings = [];
18
+
19
+ for (let index = 0; index < order.length; index += 1) {
20
+ const rawPattern = order[index];
21
+ const pattern = String(rawPattern ?? '').trim();
22
+ if (!pattern) continue;
23
+
24
+ if (pattern === '*') {
25
+ const reserved = new Set(order.slice(index + 1).flatMap(laterPattern => {
26
+ const later = String(laterPattern ?? '').trim();
27
+ if (!later || later === '*') return [];
28
+ return fg.sync(later, { cwd: root, onlyFiles: true });
29
+ }));
30
+ for (const file of alphabetical) {
31
+ if (!reserved.has(file) && remaining.delete(file)) ordered.push(file);
32
+ }
33
+ continue;
34
+ }
35
+
36
+ const matches = fg.sync(pattern, { cwd: root, onlyFiles: true })
37
+ .filter(file => remaining.has(file))
38
+ .sort();
39
+ for (const file of matches) {
40
+ remaining.delete(file);
41
+ ordered.push(file);
42
+ }
43
+
44
+ if (!fg.isDynamicPattern(pattern) && !alphabetical.includes(pattern)) {
45
+ warnings.push(`Configured mission order entry was not discovered: ${pattern}`);
46
+ }
47
+ }
48
+
49
+ // A partial order only moves the named groups; all other missions retain
50
+ // deterministic legacy ordering after the configured entries.
51
+ for (const file of alphabetical) {
52
+ if (remaining.delete(file)) ordered.push(file);
53
+ }
54
+ return { files: ordered, warnings };
55
+ }
56
+
9
57
  /**
10
58
  * Discover mission files based on testronaut-config.json.
11
59
  * - When no missions block exists, preserve legacy behavior:
@@ -14,7 +62,7 @@ const DEFAULT_INCLUDE = ['**/*.mission.js', '**/*.mission.ts'];
14
62
  *
15
63
  * @param {object} opts
16
64
  * @param {string} [opts.cwd=process.cwd()] - project root for config + resolution
17
- * @returns {Promise<{ root:string, files:string[] }>} root is absolute; files are relative to root
65
+ * @returns {Promise<{ root:string, files:string[], warnings:string[] }>} root is absolute; files are relative to root
18
66
  */
19
67
  export async function discoverMissionFiles({ cwd = process.cwd() } = {}) {
20
68
  const defaultRoot = path.resolve(cwd, DEFAULT_ROOT);
@@ -24,17 +72,17 @@ export async function discoverMissionFiles({ cwd = process.cwd() } = {}) {
24
72
  // Legacy behavior remains non-recursive, now with first-class TypeScript support.
25
73
  if (!missionsCfg) {
26
74
  if (!fs.existsSync(defaultRoot)) {
27
- return { root: defaultRoot, files: [] };
75
+ return { root: defaultRoot, files: [], warnings: [] };
28
76
  }
29
77
  const files = fs.readdirSync(defaultRoot)
30
78
  .filter(f => f.endsWith('.mission.js') || f.endsWith('.mission.ts'))
31
79
  .sort();
32
- return { root: defaultRoot, files };
80
+ return { root: defaultRoot, files, warnings: [] };
33
81
  }
34
82
 
35
83
  const root = path.resolve(cwd, missionsCfg.root || DEFAULT_ROOT);
36
84
  if (!fs.existsSync(root)) {
37
- return { root, files: [] };
85
+ return { root, files: [], warnings: [] };
38
86
  }
39
87
 
40
88
  const include = Array.isArray(missionsCfg.include) && missionsCfg.include.length
@@ -49,7 +97,9 @@ export async function discoverMissionFiles({ cwd = process.cwd() } = {}) {
49
97
  });
50
98
 
51
99
  const unique = Array.from(new Set(matches));
52
- unique.sort();
100
+ const ordered = orderMissionFiles(unique, missionsCfg.order, root);
53
101
 
54
- return { root, files: unique };
102
+ return { root, ...ordered };
55
103
  }
104
+
105
+ export const __test__ = { orderMissionFiles };
package/core/turnLoop.js CHANGED
@@ -409,6 +409,8 @@ export const turnLoop = async (
409
409
  // Idempotent recorder โ€“ prevents accidental double-push
410
410
  const recordStep = (step) => {
411
411
  if (!step || step.__recorded) return;
412
+ step.endTime = Date.now();
413
+ step.durationMs = Math.max(0, step.endTime - (step.startTime ?? step.endTime));
412
414
  step._seq = stepSeq++;
413
415
  step.__recorded = true;
414
416
  try { steps.push(step); } catch {}
@@ -427,6 +429,7 @@ export const turnLoop = async (
427
429
  const attempt = turnRetries + 1;
428
430
  const step = {
429
431
  turn,
432
+ startTime: Date.now(),
430
433
  retryAttempt: attempt,
431
434
  retryLimit: retryLimitClamped, // number of retries allowed (excludes initial)
432
435
  events: [],
@@ -1061,7 +1064,61 @@ export const turnLoop = async (
1061
1064
  turnRetries = 0;
1062
1065
  }
1063
1066
 
1064
- return { success: false, steps: stepsArchive };
1067
+ // The configured budget covers working turns. Once those are consumed, make
1068
+ // one final tool-free request so every mission ends with an explicit verdict.
1069
+ const closureStep = {
1070
+ turn: maxTurns,
1071
+ startTime: Date.now(),
1072
+ isClosureTurn: true,
1073
+ retryAttempt: 1,
1074
+ retryLimit: 0,
1075
+ events: ['โฑ๏ธ Working-turn budget exhausted; requesting final mission verdict.'],
1076
+ result: '๐ŸŸก Awaiting Closure',
1077
+ missionName,
1078
+ provider: PROVIDER_ID,
1079
+ model: MODEL_ID,
1080
+ };
1081
+ console.log(`\n๐Ÿ Turn budget exhausted after ${maxTurns} working turn(s). Requesting closure.`);
1082
+ messages.push({
1083
+ role: 'user',
1084
+ content: 'The working-turn budget is exhausted. Do not call tools or continue the task. Based only on the evidence already collected, respond with exactly SUCCESS: followed by a short reason if the goal was achieved, otherwise FAILURE: followed by a short reason.',
1085
+ });
1086
+
1087
+ try {
1088
+ const closureResponse = await llm.chat({ model: MODEL_ID, messages, tools: [] });
1089
+ const msg = closureResponse?.message ?? { role: 'assistant', content: '' };
1090
+ const usage = closureResponse?.usage;
1091
+ if (usage) {
1092
+ closureStep.tokensUsed = usage.total_tokens || 0;
1093
+ closureStep.inputTokens = usage.input_tokens ?? usage.prompt_tokens ?? usage.providerRaw?.input_tokens ?? usage.providerRaw?.prompt_tokens;
1094
+ closureStep.outputTokens = usage.output_tokens ?? usage.completion_tokens ?? usage.providerRaw?.output_tokens ?? usage.providerRaw?.completion_tokens;
1095
+ }
1096
+ closureStep.summary = summarizeTurnIntentFromMessage(msg, { emoji: true });
1097
+ const finalResponse = finalResponseHandler(msg);
1098
+ if (finalResponse) {
1099
+ closureStep.events.push(finalResponse.finalMessage);
1100
+ closureStep.result = finalResponse.success ? 'โœ… Mission Success' : 'โŒ Mission Failure';
1101
+ recordStep(closureStep);
1102
+ return {
1103
+ success: finalResponse.success,
1104
+ finalMessage: finalResponse.finalMessage,
1105
+ steps: stepsArchive,
1106
+ completedBy: 'budget_closure',
1107
+ };
1108
+ }
1109
+ closureStep.events.push('FAILURE: The closure response did not provide an explicit SUCCESS or FAILURE verdict.');
1110
+ } catch (error) {
1111
+ closureStep.events.push(`FAILURE: Unable to obtain the closure verdict: ${error?.message || error}`);
1112
+ }
1113
+
1114
+ closureStep.result = 'โŒ Mission Failure';
1115
+ recordStep(closureStep);
1116
+ return {
1117
+ success: false,
1118
+ finalMessage: closureStep.events.at(-1),
1119
+ steps: stepsArchive,
1120
+ completedBy: 'budget_closure',
1121
+ };
1065
1122
  };
1066
1123
 
1067
1124
  // Expose small helper bundle for unit tests (no production use).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "1.12.0",
3
+ "version": "1.12.2",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "bin/cli.js"
@@ -98,7 +98,10 @@ describe('CLI inspection commands', () => {
98
98
  const result = run(cwd, ['--tags', 'authentication', '--json', '--quiet']);
99
99
 
100
100
  expect(result.status).toBe(0);
101
- expect(JSON.parse(result.stdout).missions[0].missionName).toBe('https://example.test');
101
+ const report = JSON.parse(result.stdout);
102
+ expect(report.missions[0].missionName).toBe('https://example.test');
103
+ expect(report.durationMs).toEqual(expect.any(Number));
104
+ expect(report.missions[0].durationMs).toEqual(expect.any(Number));
102
105
  });
103
106
 
104
107
  it('records shared-auth session mode in the report', () => {
@@ -17,12 +17,13 @@ describe('terminal UI', () => {
17
17
  ui.banner({ version: '1.2.3', provider: 'openai', model: 'gpt-test', session: 'isolated' });
18
18
  ui.phase('launch', 'login.mission.js');
19
19
  ui.mission(true, 'login.mission.js', ['smoke']);
20
- ui.summary({ totalMissions: 1, passed: 1, failed: 0, reportPath: 'reports/run.html' });
20
+ ui.summary({ totalMissions: 1, passed: 1, failed: 0, durationMs: 1234, reportPath: 'reports/run.html' });
21
21
 
22
22
  expect(target.read()).toContain('๐Ÿง‘โ€๐Ÿš€ TESTRONAUT / MISSION RUNNER');
23
23
  expect(target.read()).toContain('LAUNCH login.mission.js');
24
24
  expect(target.read()).toContain('#smoke');
25
25
  expect(target.read()).toContain('MISSION COMPLETE');
26
+ expect(target.read()).toContain('Duration: 1.23s');
26
27
  expect(target.read()).not.toContain('\u001b[');
27
28
  });
28
29
 
@@ -61,6 +61,8 @@ describe('core/agent.runAgent', () => {
61
61
  expect(res).toHaveLength(2);
62
62
  expect(res[0].status).toBe('passed');
63
63
  expect(res[1].status).toBe('passed');
64
+ expect(res[0].durationMs).toEqual(expect.any(Number));
65
+ expect(res[0].endTime).toBeGreaterThanOrEqual(res[0].startTime);
64
66
  });
65
67
 
66
68
  it('stops early on first failure and returns partial results', async () => {
@@ -78,4 +78,48 @@ describe('discoverMissionFiles', () => {
78
78
  const res = await discoverMissionFiles({ cwd: tmp });
79
79
  expect(res.files).toEqual(['keep.mission.js']);
80
80
  });
81
+
82
+ it('orders configured patterns and places remaining missions at the wildcard', async () => {
83
+ const tmp = makeTempDir();
84
+ temps.push(tmp);
85
+ const missionsDir = path.join(tmp, 'missions');
86
+ fs.mkdirSync(path.join(missionsDir, 'account'), { recursive: true });
87
+ for (const file of ['login.mission.js', 'logout.mission.js', 'search.mission.js']) {
88
+ fs.writeFileSync(path.join(missionsDir, file), '// ok');
89
+ }
90
+ fs.writeFileSync(path.join(missionsDir, 'account', 'profile.mission.js'), '// ok');
91
+
92
+ writeJson(path.join(tmp, 'testronaut-config.json'), {
93
+ missions: {
94
+ include: ['**/*.mission.js'],
95
+ order: ['login.mission.js', 'account/**/*.mission.js', '*', 'logout.mission.js'],
96
+ },
97
+ });
98
+
99
+ const res = await discoverMissionFiles({ cwd: tmp });
100
+ expect(res.files).toEqual([
101
+ 'login.mission.js',
102
+ 'account/profile.mission.js',
103
+ 'search.mission.js',
104
+ 'logout.mission.js',
105
+ ]);
106
+ });
107
+
108
+ it('warns about missing exact order entries while ignoring unmatched globs', async () => {
109
+ const tmp = makeTempDir();
110
+ temps.push(tmp);
111
+ fs.mkdirSync(path.join(tmp, 'missions'));
112
+ fs.writeFileSync(path.join(tmp, 'missions', 'actual.mission.js'), '// ok');
113
+ writeJson(path.join(tmp, 'testronaut-config.json'), {
114
+ missions: {
115
+ order: ['missing.mission.js', 'optional/**/*.mission.js', '*'],
116
+ },
117
+ });
118
+
119
+ const res = await discoverMissionFiles({ cwd: tmp });
120
+ expect(res.files).toEqual(['actual.mission.js']);
121
+ expect(res.warnings).toEqual([
122
+ 'Configured mission order entry was not discovered: missing.mission.js',
123
+ ]);
124
+ });
81
125
  });
@@ -96,7 +96,7 @@ vi.mock('../../tools/turnLoopUtils.js', async () => {
96
96
  finalResponseHandler: (msg) => {
97
97
  const content = (msg?.content || '').toString();
98
98
  if (content.includes('FINAL')) {
99
- return { finalMessage: 'Mission complete', success: true };
99
+ return { finalMessage: content, success: !content.includes('FAILURE') };
100
100
  }
101
101
  return null;
102
102
  },
@@ -182,6 +182,52 @@ describe('turnLoop', () => {
182
182
  expect(shared.chromeToolSpies.get_dom).not.toHaveBeenCalled();
183
183
  });
184
184
 
185
+ it('uses one tool-free closure turn after the working-turn budget is exhausted', async () => {
186
+ shared.chatMock
187
+ .mockResolvedValueOnce({
188
+ message: { role: 'assistant', content: 'Still investigating' },
189
+ usage: { total_tokens: 10 },
190
+ headers: {},
191
+ })
192
+ .mockResolvedValueOnce({
193
+ message: { role: 'assistant', content: 'FINAL: FAILURE: budget exhausted before completion' },
194
+ usage: { total_tokens: 4 },
195
+ headers: {},
196
+ });
197
+
198
+ const result = await turnLoop(browser, baseMessages(), 1, 0, 0, {}, {
199
+ steps: [], missionName: 'demo',
200
+ });
201
+
202
+ expect(result).toMatchObject({ success: false, completedBy: 'budget_closure' });
203
+ expect(shared.chatMock).toHaveBeenCalledTimes(2);
204
+ expect(shared.chatMock.mock.calls[1][0].tools).toEqual([]);
205
+ expect(shared.chatMock.mock.calls[1][0].messages.at(-1).content).toContain('working-turn budget is exhausted');
206
+ expect(result.steps.at(-1)).toMatchObject({
207
+ isClosureTurn: true,
208
+ result: 'โŒ Mission Failure',
209
+ tokensUsed: 4,
210
+ });
211
+ });
212
+
213
+ it('records elapsed milliseconds for normal and closure turns', async () => {
214
+ shared.chatMock
215
+ .mockResolvedValueOnce({ message: { role: 'assistant', content: 'Continue' }, usage: {} })
216
+ .mockResolvedValueOnce({ message: { role: 'assistant', content: 'FINAL: FAILURE: incomplete' }, usage: {} });
217
+
218
+ const result = await turnLoop(browser, baseMessages(), 1, 0, 0, {}, {
219
+ steps: [], missionName: 'timed',
220
+ });
221
+
222
+ expect(result.steps).toHaveLength(2);
223
+ for (const step of result.steps) {
224
+ expect(step.startTime).toEqual(expect.any(Number));
225
+ expect(step.endTime).toEqual(expect.any(Number));
226
+ expect(step.durationMs).toEqual(expect.any(Number));
227
+ expect(step.durationMs).toBeGreaterThanOrEqual(0);
228
+ }
229
+ });
230
+
185
231
  it('records Jev shadow telemetry without changing the final result', async () => {
186
232
  process.env.TESTRONAUT_JEV_SHADOW = '1';
187
233
  process.env.TESTRONAUT_JEV_STRATEGIES = '1';
@@ -18,6 +18,43 @@ afterEach(() => {
18
18
  });
19
19
 
20
20
  describe('generateHtmlReport', () => {
21
+ it('points local reports to hosted analytics for the same run', () => {
22
+ const report = { runId: 'run_analytics_123', missions: [] };
23
+ const outPath = path.join(tmpDir, 'analytics-cta.html');
24
+ generateHtmlReport(report, outPath);
25
+ const html = fs.readFileSync(outPath, 'utf8');
26
+ expect(html).toContain('Need deeper analytics?');
27
+ expect(html).toContain('filtered and cumulative token, estimated-cost, and duration charts');
28
+ expect(html).toContain('testronaut upload run_analytics_123');
29
+ expect(html).toContain('aria-label="Copy upload command"');
30
+ expect(html).toContain("navigator.clipboard.writeText(command)");
31
+ expect(html).toContain('id="analytics-toggle" aria-expanded="true"');
32
+ expect(html).toContain("card.classList.toggle('collapsed', expanded)");
33
+ expect(html).toContain('href="https://mission.testronaut.app/"');
34
+ expect(html).toContain('href="https://docs.testronaut.app/docs/category/reports-and-mission-control"');
35
+ expect(html.indexOf('<aside class="analytics-cta">')).toBeLessThan(html.indexOf('<div class="summary">'));
36
+ });
37
+
38
+ it('renders explicit suite, mission, and turn durations', () => {
39
+ const report = {
40
+ runId: 'timed_run',
41
+ durationMs: 6500,
42
+ missions: [{
43
+ missionName: 'Timed Mission',
44
+ submissionType: 'mission',
45
+ status: 'passed',
46
+ durationMs: 2500,
47
+ steps: [{ turn: 0, result: 'โœ… Mission Success', durationMs: 1250 }],
48
+ }],
49
+ };
50
+ const outPath = path.join(tmpDir, 'timed.html');
51
+ generateHtmlReport(report, outPath);
52
+ const html = fs.readFileSync(outPath, 'utf8');
53
+ expect(html).toContain('<strong>Duration:</strong> 6.50s');
54
+ expect(html).toContain('duration: 2.50s');
55
+ expect(html).toContain('Turn 1 โ€ข 1.25s');
56
+ });
57
+
21
58
  it('writes an HTML report containing mission and retry metadata', () => {
22
59
  const report = {
23
60
  runId: 'run_test',
@@ -30,8 +30,9 @@ import { summarizeJevUsage } from '../core/jevUsage.js';
30
30
  export function generateHtmlReport(report, outputPath) {
31
31
  const { runId, startTime, endTime, missions = [], summary = {}, llm = {}, cli = {}, optimization } = report;
32
32
  const reportTags = normalizeTags(report.tags ?? missions.flatMap(m => m.submissionType === 'mission' ? (m.tags ?? []) : []));
33
- const durationSec =
34
- (startTime && endTime)
33
+ const durationSec = Number.isFinite(report.durationMs)
34
+ ? (report.durationMs / 1000).toFixed(2)
35
+ : (startTime && endTime)
35
36
  ? ((new Date(endTime) - new Date(startTime)) / 1000).toFixed(2)
36
37
  : 'โ€”';
37
38
 
@@ -91,8 +92,9 @@ export function generateHtmlReport(report, outputPath) {
91
92
  const jevLogo = '<svg viewBox="0 0 24 24" role="img" aria-hidden="true"><rect width="24" height="24" rx="4" fill="#e547b5"/><g fill="none" stroke="#181a1b" stroke-width="2.25" stroke-linecap="square" stroke-linejoin="round"><path d="M12 2.5 6.1 6v10.1l3.2 1.9 2.7-1.6V9.6l3-1.8V4.3z"/><path d="m9.3 9.6 2.7 1.6 3-1.8 3 1.8v7L12 21.5l-3-1.8V13z"/><path d="m12 11.2 3 1.8 3-1.8M15 13v5.2l-6 3.3M9.3 9.6 12 8l3 1.8"/></g></svg>';
92
93
 
93
94
  const submissionBlock = (m) => {
94
- const mDurationSec =
95
- m.endTime && m.startTime
95
+ const mDurationSec = Number.isFinite(m.durationMs)
96
+ ? (m.durationMs / 1000).toFixed(2)
97
+ : m.endTime && m.startTime
96
98
  ? ((m.endTime - m.startTime) / 1000).toFixed(2)
97
99
  : (typeof m.durationSeconds === 'number' ? m.durationSeconds.toFixed(2) : 'โ€”');
98
100
 
@@ -129,6 +131,9 @@ export function generateHtmlReport(report, outputPath) {
129
131
  : step.modelRouting?.applied
130
132
  ? ' โ€ข routed by Jev'
131
133
  : '';
134
+ const stepDuration = Number.isFinite(step.durationMs)
135
+ ? ` โ€ข ${(step.durationMs / 1000).toFixed(2)}s`
136
+ : '';
132
137
 
133
138
  const plan = (typeof step.summary === 'string' && step.summary.trim())
134
139
  ? step.summary.trim()
@@ -140,7 +145,7 @@ export function generateHtmlReport(report, outputPath) {
140
145
  return `
141
146
  <details class="step" ${ok ? '' : 'open'}>
142
147
  <summary>
143
- <span class="turn">Turn ${esc((step.turn ?? idx) + 1)}${esc(attemptLabel)}</span>
148
+ <span class="turn">${step.isClosureTurn ? 'Closure' : `Turn ${esc((step.turn ?? idx) + 1)}`}${esc(attemptLabel)}${stepDuration}</span>
144
149
  ${humanInput}
145
150
  ${planSpan}
146
151
  <span class="step-result ${ok ? 'ok' : 'bad'}" ${resultTooltip ? `title="${esc(resultTooltip)}"` : ''}>${esc(resultRaw)}</span>
@@ -303,6 +308,7 @@ export function generateHtmlReport(report, outputPath) {
303
308
  /* ===== Header ===== */
304
309
  .header{ max-width:960px; margin:0 auto 16px; padding:22px 24px; position:relative; overflow:hidden; }
305
310
  .header::after{content:"";position:absolute;right:0;top:0;width:92px;height:4px;background:linear-gradient(90deg,var(--orange),var(--amber));}
311
+ .header-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,340px);gap:22px;align-items:start;}
306
312
  .eyebrow{margin-bottom:7px;color:var(--cyan);font-size:11px;font-weight:800;letter-spacing:.18em;text-transform:uppercase;}
307
313
  h1{ margin:0 0 12px; font-size:27px; font-weight:850; color:#fff; letter-spacing:-.02em; }
308
314
  .run-meta{ color:var(--text-muted); display:grid; gap:4px; }
@@ -336,7 +342,7 @@ export function generateHtmlReport(report, outputPath) {
336
342
  .filter-empty{max-width:928px;margin:12px auto;padding:28px 16px;text-align:center;border:1px dashed var(--hairline-strong);border-radius:16px;color:var(--text-muted);}
337
343
  .filter-empty strong{display:block;color:var(--text);font-size:18px;margin-bottom:6px;}
338
344
  .mission-group.nonmatching{opacity:.45;}
339
- @media(max-width:640px){.match-count{width:100%;margin-left:0}.tag-filter{padding:14px 12px}.meta{display:none}}
345
+ @media(max-width:760px){.header-layout{grid-template-columns:1fr}.match-count{width:100%;margin-left:0}.tag-filter{padding:14px 12px}.meta{display:none}}
340
346
 
341
347
  /* ===== Disclosure blocks ===== */
342
348
  details{ background: var(--panel); border:1px solid var(--hairline); border-radius:16px; margin:10px 0; overflow:hidden; }
@@ -415,19 +421,76 @@ export function generateHtmlReport(report, outputPath) {
415
421
  /* container width */
416
422
  .container{ max-width:960px; margin:0 auto; }
417
423
  .empty{ color: var(--text-muted); padding:12px; }
424
+ .analytics-cta{
425
+ padding:16px; background:rgba(2,6,23,.34); border:1px solid var(--hairline-strong);
426
+ border-radius:13px;
427
+ color:var(--text-muted); font-size:13px;
428
+ }
429
+ .analytics-toggle{
430
+ width:100%;padding:0;display:flex;align-items:center;justify-content:space-between;gap:12px;
431
+ color:var(--text);background:transparent;border:0;cursor:pointer;text-align:left;font:inherit;font-weight:800;
432
+ }
433
+ .analytics-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;transition:transform .18s ease;}
434
+ .analytics-cta.collapsed .analytics-toggle svg{transform:rotate(-90deg);}
435
+ .analytics-body{display:grid;gap:11px;margin-top:11px;}
436
+ .analytics-cta.collapsed .analytics-body{display:none;}
437
+ .analytics-cta code{
438
+ color:#bfdbfe; background:rgba(59,130,246,.12); border:1px solid rgba(96,165,250,.25);
439
+ border-radius:8px; padding:6px 9px; white-space:nowrap;
440
+ }
441
+ .upload-command{display:flex;align-items:stretch;min-width:0;}
442
+ .upload-command code{flex:1;min-width:0;overflow:auto;border-radius:8px 0 0 8px;}
443
+ .copy-command{
444
+ display:grid;place-items:center;width:36px;flex:none;cursor:pointer;color:#bfdbfe;
445
+ background:rgba(59,130,246,.18);border:1px solid rgba(96,165,250,.32);border-left:0;
446
+ border-radius:0 8px 8px 0;
447
+ }
448
+ .copy-command:hover,.copy-command.copied{color:#fff;background:rgba(59,130,246,.3);}
449
+ .copy-command svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.8;}
450
+ .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;}
451
+ .analytics-links{display:flex;gap:8px;flex-wrap:wrap;}
452
+ .analytics-links a{
453
+ color:#bfdbfe;text-decoration:none;border:1px solid rgba(96,165,250,.32);
454
+ background:rgba(59,130,246,.12);border-radius:999px;padding:6px 10px;font-size:12px;font-weight:750;
455
+ }
456
+ .analytics-links a:hover{background:rgba(59,130,246,.22);}
418
457
  </style>
419
458
  </head>
420
459
  <body>
421
460
  <div class="header glass">
422
- <div class="eyebrow">Mission Control ยท Flight Report</div>
423
- <h1>๐Ÿง‘โ€๐Ÿš€ <span class="gradient-text">Testronaut Report</span></h1>
424
- <div class="run-meta">
425
- <div class="provider-meta"><span class="provider-logo" title="${esc(provider.name)}" aria-label="${esc(provider.name)}">${provider.logo}</span><strong>${esc((reportModels.length ? reportModels : [llm.model ?? 'โ€”']).join(' + '))}</strong>${reportJevUsage.evaluations ? ` <span aria-hidden="true">+</span> <span class="provider-logo" title="Jev" aria-label="Jev">${jevLogo}</span><strong>Jev</strong>` : ''}</div>
426
- <div><strong>Run ID:</strong> ${esc(runId ?? 'โ€”')}</div>
427
- <div><strong>Start:</strong> ${esc(startTime ?? 'โ€”')}</div>
428
- <div><strong>End:</strong> ${esc(endTime ?? 'โ€”')}</div>
429
- <div><strong>Duration:</strong> ${durationSec}s</div>
430
- <div><strong>CLI version:</strong> ${esc(cli.version ?? 'โ€”')}</div>
461
+ <div class="header-layout">
462
+ <div>
463
+ <div class="eyebrow">Mission Control ยท Flight Report</div>
464
+ <h1>๐Ÿง‘โ€๐Ÿš€ <span class="gradient-text">Testronaut Report</span></h1>
465
+ <div class="run-meta">
466
+ <div class="provider-meta"><span class="provider-logo" title="${esc(provider.name)}" aria-label="${esc(provider.name)}">${provider.logo}</span><strong>${esc((reportModels.length ? reportModels : [llm.model ?? 'โ€”']).join(' + '))}</strong>${reportJevUsage.evaluations ? ` <span aria-hidden="true">+</span> <span class="provider-logo" title="Jev" aria-label="Jev">${jevLogo}</span><strong>Jev</strong>` : ''}</div>
467
+ <div><strong>Run ID:</strong> ${esc(runId ?? 'โ€”')}</div>
468
+ <div><strong>Start:</strong> ${esc(startTime ?? 'โ€”')}</div>
469
+ <div><strong>End:</strong> ${esc(endTime ?? 'โ€”')}</div>
470
+ <div><strong>Duration:</strong> ${durationSec}s</div>
471
+ <div><strong>CLI version:</strong> ${esc(cli.version ?? 'โ€”')}</div>
472
+ </div>
473
+ </div>
474
+ <aside class="analytics-cta">
475
+ <button type="button" class="analytics-toggle" id="analytics-toggle" aria-expanded="true" aria-controls="analytics-body">
476
+ <span>Need deeper analytics?</span>
477
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
478
+ </button>
479
+ <div class="analytics-body" id="analytics-body">
480
+ <span>Upload this run for filtered and cumulative token, estimated-cost, and duration charts.</span>
481
+ <div class="upload-command">
482
+ <code id="analytics-upload-command">testronaut upload ${esc(runId ?? '')}</code>
483
+ <button type="button" class="copy-command" id="copy-upload-command" aria-label="Copy upload command" title="Copy upload command">
484
+ <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M15 9V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h3"/></svg>
485
+ </button>
486
+ <span class="sr-only" id="copy-upload-status" aria-live="polite"></span>
487
+ </div>
488
+ <div class="analytics-links">
489
+ <a href="https://mission.testronaut.app/">Open Mission Control</a>
490
+ <a href="https://docs.testronaut.app/docs/category/reports-and-mission-control">Upload guide</a>
491
+ </div>
492
+ </div>
493
+ </aside>
431
494
  </div>
432
495
  </div>
433
496
 
@@ -470,6 +533,50 @@ export function generateHtmlReport(report, outputPath) {
470
533
  </div>
471
534
  <script>
472
535
  (function () {
536
+ var analyticsToggle = document.getElementById('analytics-toggle');
537
+ var analyticsBody = document.getElementById('analytics-body');
538
+ if (analyticsToggle && analyticsBody) {
539
+ analyticsToggle.addEventListener('click', function () {
540
+ var card = analyticsToggle.closest('.analytics-cta');
541
+ var expanded = analyticsToggle.getAttribute('aria-expanded') === 'true';
542
+ analyticsToggle.setAttribute('aria-expanded', expanded ? 'false' : 'true');
543
+ analyticsBody.hidden = expanded;
544
+ if (card) card.classList.toggle('collapsed', expanded);
545
+ });
546
+ }
547
+ var copyUploadButton = document.getElementById('copy-upload-command');
548
+ var uploadCommand = document.getElementById('analytics-upload-command');
549
+ var copyUploadStatus = document.getElementById('copy-upload-status');
550
+ if (copyUploadButton && uploadCommand) {
551
+ copyUploadButton.addEventListener('click', function () {
552
+ var command = uploadCommand.textContent || '';
553
+ var copy = navigator.clipboard && window.isSecureContext
554
+ ? navigator.clipboard.writeText(command)
555
+ : new Promise(function (resolve, reject) {
556
+ var input = document.createElement('textarea');
557
+ input.value = command;
558
+ input.setAttribute('readonly', '');
559
+ input.style.position = 'fixed';
560
+ input.style.opacity = '0';
561
+ document.body.appendChild(input);
562
+ input.select();
563
+ try { document.execCommand('copy') ? resolve() : reject(new Error('Copy failed')); }
564
+ catch (error) { reject(error); }
565
+ finally { document.body.removeChild(input); }
566
+ });
567
+ copy.then(function () {
568
+ copyUploadButton.classList.add('copied');
569
+ copyUploadButton.title = 'Copied!';
570
+ if (copyUploadStatus) copyUploadStatus.textContent = 'Upload command copied';
571
+ window.setTimeout(function () {
572
+ copyUploadButton.classList.remove('copied');
573
+ copyUploadButton.title = 'Copy upload command';
574
+ }, 1600);
575
+ }).catch(function () {
576
+ if (copyUploadStatus) copyUploadStatus.textContent = 'Could not copy upload command';
577
+ });
578
+ });
579
+ }
473
580
  var selectedTags = [];
474
581
  function applyTagFilter() {
475
582
  var match = document.getElementById('tag-match');