testronaut 1.3.0 β†’ 1.3.1

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
@@ -29,7 +29,7 @@
29
29
  * Test strategy:
30
30
  * To test pure helpers without running the whole CLI, we export a tiny test bundle:
31
31
  * export const __test__ = { guessMimeType, safeJoin, findLatestReportPair, pkgManagerForCwd }
32
- * See tests in tests/cliTests/cli.helpers.test.js
32
+ * See tests in tests/toolsTests/cli.helpers.test.js
33
33
  */
34
34
 
35
35
  import fs from 'fs';
@@ -60,6 +60,11 @@ const apiBase = 'http://api.testronaut.app'; // Replace with your actual API bas
60
60
 
61
61
  const args = process.argv.slice(2);
62
62
 
63
+ /**
64
+ * Read a JSONL steps file into an array of objects.
65
+ * @param {string} p
66
+ * @returns {object[]|null}
67
+ */
63
68
  function readJsonlSteps(p) {
64
69
  if (!p || !fs.existsSync(p)) return null;
65
70
  const lines = fs.readFileSync(p, 'utf8').split(/\r?\n/).filter(Boolean);
@@ -73,21 +78,27 @@ function readJsonlSteps(p) {
73
78
  /**
74
79
  * Merge duplicate turns without losing information.
75
80
  * Strategy:
76
- * - Keep the latest **non-empty** version for a given turn if both exist
81
+ * - Keep the latest **non-empty** version for a given (turn,retry) pair
77
82
  * - If both are non-empty, keep the later one (last write wins)
78
83
  * - Preserve original order by line index as a tiebreaker
84
+ *
85
+ * @param {Array} steps
86
+ * @returns {Array}
79
87
  */
80
88
  function mergeDuplicateTurns(steps) {
81
89
  const out = [];
82
- const byTurn = new Map(); // turn -> index in out
90
+ const byKey = new Map(); // composite key -> index in out
83
91
  for (const s of steps) {
84
92
  const turn = Number.isFinite(s.turn) ? s.turn : out.length;
93
+ const attempt = Number.isFinite(s.retryAttempt) ? s.retryAttempt : 1;
85
94
  const hasEvents = Array.isArray(s.events) && s.events.length > 0;
86
- if (!byTurn.has(turn)) {
95
+ const key = `${turn}::${attempt}`;
96
+
97
+ if (!byKey.has(key)) {
87
98
  out.push(s);
88
- byTurn.set(turn, out.length - 1);
99
+ byKey.set(key, out.length - 1);
89
100
  } else {
90
- const idx = byTurn.get(turn);
101
+ const idx = byKey.get(key);
91
102
  const existing = out[idx];
92
103
  const existingHasEvents = Array.isArray(existing.events) && existing.events.length > 0;
93
104
  // Prefer the one with events; if both have events, prefer the newer (s)
@@ -101,11 +112,28 @@ function mergeDuplicateTurns(steps) {
101
112
  }
102
113
  }
103
114
  }
104
- // stable sort by `turn` ascending, then by original order via array index already preserved
105
- out.sort((a, b) => (a.turn ?? 0) - (b.turn ?? 0));
115
+ // stable sort by `turn` then `retryAttempt`, then by original order
116
+ out.sort((a, b) => {
117
+ const ta = a.turn ?? 0;
118
+ const tb = b.turn ?? 0;
119
+ if (ta !== tb) return ta - tb;
120
+ const ra = Number.isFinite(a.retryAttempt) ? a.retryAttempt : 1;
121
+ const rb = Number.isFinite(b.retryAttempt) ? b.retryAttempt : 1;
122
+ if (ra !== rb) return ra - rb;
123
+ return 0;
124
+ });
106
125
  return out;
107
126
  }
108
127
 
128
+ // Expose a small bundle for unit tests (helper-only; not the CLI flow)
129
+ export const __test__ = {
130
+ guessMimeType,
131
+ safeJoin,
132
+ findLatestReportPair,
133
+ mergeDuplicateTurns,
134
+ readJsonlSteps,
135
+ };
136
+
109
137
  // Look for --model=<id> or --model <id>
110
138
  let modelOverride;
111
139
  const modelFlagIndex = args.findIndex(a => a === '--model' || a.startsWith('--model='));
@@ -149,6 +177,37 @@ if (turnsFlagIndex >= 0) {
149
177
  args.splice(turnsFlagIndex, turnsOverride ? 2 : 1);
150
178
  }
151
179
 
180
+ // Look for --retry_limit / --retry-limit
181
+ let retryOverride;
182
+ const retryFlagIndex = args.findIndex(a =>
183
+ a === '--retry_limit' ||
184
+ a.startsWith('--retry_limit=') ||
185
+ a === '--retry-limit' ||
186
+ a.startsWith('--retry-limit=')
187
+ );
188
+ if (retryFlagIndex >= 0) {
189
+ const rawArg = args[retryFlagIndex];
190
+ if (rawArg.includes('=')) {
191
+ retryOverride = rawArg.split('=')[1];
192
+ } else if (args[retryFlagIndex + 1]) {
193
+ retryOverride = args[retryFlagIndex + 1];
194
+ }
195
+
196
+ if (retryOverride) {
197
+ const n = Number(retryOverride.trim());
198
+ if (Number.isFinite(n)) {
199
+ const clamped = Math.min(10, Math.max(1, n));
200
+ process.env.TESTRONAUT_RETRY_LIMIT = String(clamped);
201
+ console.log(`πŸ” Retry limit override: ${process.env.TESTRONAUT_RETRY_LIMIT} (allowed 1-10)`);
202
+ } else {
203
+ console.warn(`⚠️ Invalid --retry_limit value "${retryOverride}". Ignoring.`);
204
+ }
205
+ }
206
+
207
+ // Remove flag & value so they aren’t treated as filenames
208
+ args.splice(retryFlagIndex, retryOverride ? 2 : 1);
209
+ }
210
+
152
211
  const missionsDir = path.resolve(process.cwd(), 'missions');
153
212
 
154
213
  const allResults = [];
@@ -170,6 +229,7 @@ Options:
170
229
  --init Scaffold project folders and a welcome mission
171
230
  --turns=<n> Override max turns for this run (e.g., --turns=30)
172
231
  --help Show this help message
232
+ --retry_limit=<n> Override agent turn retry limits (minimum 1, maximum 10)
173
233
 
174
234
  Examples:
175
235
  npx testronaut
@@ -720,6 +780,11 @@ function safeJoin(root, relUrlPath) {
720
780
  return resolved;
721
781
  }
722
782
 
783
+ /**
784
+ * Best-effort cross-platform URL opener.
785
+ * @param {string} targetUrl
786
+ * @returns {Promise<void>}
787
+ */
723
788
  async function openInBrowser(targetUrl) {
724
789
  const platform = process.platform;
725
790
  const quoted = `"${targetUrl}"`;
@@ -848,4 +913,4 @@ try {
848
913
  }
849
914
  } catch (err) {
850
915
  console.warn(`⚠️ Could not remove tmp folder: ${err.message}`);
851
- }
916
+ }
package/core/agent.js CHANGED
@@ -37,7 +37,7 @@ import path from 'path';
37
37
  * @param {number} [maxTurns=20] - upper bound for turnLoop per goal
38
38
  * @returns {Promise<Array<{missionName:string, submissionType:string, submissionName:string|null, status:'passed'|'failed', steps:any[], startTime:number, endTime:number}>>}
39
39
  */
40
- export async function runAgent(goals, missionName, maxTurns = 20) {
40
+ export async function runAgent(goals, missionName, maxTurns = 20, retryLimit) {
41
41
  const browser = new ChromeBrowser();
42
42
  await browser.start();
43
43
  let result;
@@ -52,6 +52,7 @@ export async function runAgent(goals, missionName, maxTurns = 20) {
52
52
 
53
53
  for (const goal of goals) {
54
54
  const steps = [];
55
+ const stepsArchive = [];
55
56
  // Unique-ish JSONL file for this mission’s steps
56
57
  const stepFile = path.join(
57
58
  tmpDir,
@@ -152,7 +153,9 @@ Use Ground Control as your persistent mission memory about:
152
153
  null, // currentStep
153
154
  {
154
155
  steps,
156
+ stepsArchive,
155
157
  missionName,
158
+ retryLimit,
156
159
  groundControl,
157
160
  onStep: (s) => {
158
161
  // Append each step as a JSON line
@@ -160,26 +163,24 @@ Use Ground Control as your persistent mission memory about:
160
163
  fs.appendFileSync(stepFile, JSON.stringify(s) + '\n');
161
164
  // Keep memory in check: retain only the last ~20 steps in RAM
162
165
  if (steps.length > 20) steps.splice(0, steps.length - 20);
166
+ stepsArchive.push(s);
163
167
  } catch {} // best-effort; don’t crash the agent
164
168
  }
165
169
  }
166
170
  );
167
171
 
168
172
  function dedupeSteps(steps) {
169
- const seen = new Set();
170
- const out = [];
171
- for (const s of steps) {
172
- const first = s?.events?.[0] || '';
173
- const key = `${s.turn}::${s.summary}::${first}`;
174
- if (!seen.has(key)) {
175
- seen.add(key);
176
- out.push(s);
173
+ const copy = [...steps];
174
+ copy.sort((a, b) => {
175
+ if (Number.isFinite(a?._seq) && Number.isFinite(b?._seq)) {
176
+ return a._seq - b._seq;
177
177
  }
178
- }
179
- return out;
178
+ return (a?.turn ?? 0) - (b?.turn ?? 0);
179
+ });
180
+ return copy;
180
181
  }
181
182
 
182
- const compact = dedupeSteps(steps);
183
+ const compact = dedupeSteps(stepsArchive.length ? stepsArchive : steps);
183
184
 
184
185
  // Snapshot steps (avoid retaining references to mutable arrays)
185
186
  missionResults.push({
package/core/config.js CHANGED
@@ -52,6 +52,36 @@ export function getMaxTurns(cfg, fallback = 20) {
52
52
  return n;
53
53
  }
54
54
 
55
+ /**
56
+ * Resolve retry limit per turn (attempts - 1) with clamping.
57
+ * Priority: env TESTRONAUT_RETRY_LIMIT β†’ config.retryLimit β†’ fallback
58
+ *
59
+ * @param {object} cfg
60
+ * @param {number} [fallback=2]
61
+ * @returns {{ value:number, source:'env'|'config'|'default', clamped:boolean }}
62
+ */
63
+ export function getRetryLimit(cfg, fallback = 2) {
64
+ const clampRetry = (n) => {
65
+ const clamped = Math.min(10, Math.max(1, n));
66
+ return { value: clamped, clamped: clamped !== n };
67
+ };
68
+
69
+ const envVal = Number(process.env.TESTRONAUT_RETRY_LIMIT);
70
+ if (Number.isFinite(envVal)) {
71
+ const { value, clamped } = clampRetry(envVal);
72
+ return { value, source: 'env', clamped };
73
+ }
74
+
75
+ const cfgVal = Number(cfg?.retryLimit);
76
+ if (Number.isFinite(cfgVal)) {
77
+ const { value, clamped } = clampRetry(cfgVal);
78
+ return { value, source: 'config', clamped };
79
+ }
80
+
81
+ const { value, clamped } = clampRetry(fallback);
82
+ return { value, source: 'default', clamped };
83
+ }
84
+
55
85
  /**
56
86
  * Baseline limits for mission runs. Adjust here for global defaults.
57
87
  *
package/core/turnLoop.js CHANGED
@@ -67,6 +67,8 @@ const llm = getLLM(PROVIDER_ID);
67
67
  let totalTokensUsed = 0;
68
68
  let turnTimestamps = [];
69
69
  let shouldBackoff;
70
+ const DEFAULT_TURN_RETRY_LIMIT = 2; // number of retries (not counting initial attempt)
71
+ const TURN_RETRY_BASE_DELAY_MS = 500;
70
72
 
71
73
  // Certain tools mutate the browser or produce side effects that are
72
74
  // *useful for humans* (reports/screenshots), but do not carry semantic
@@ -171,16 +173,25 @@ export const turnLoop = async (
171
173
  currentStep = {},
172
174
  ctx = {} // { steps, missionName, groundControl }
173
175
  ) => {
174
- const { steps = [], missionName, groundControl = createEmptyGroundControl() } = ctx;
176
+ const { steps = [], missionName, groundControl = createEmptyGroundControl(), retryLimit: retryLimitRaw } = ctx;
177
+ const stepsArchive = ctx.stepsArchive || steps;
178
+ const retryLimitClamped = Math.min(10, Math.max(1, Number.isFinite(retryLimitRaw) ? retryLimitRaw : DEFAULT_TURN_RETRY_LIMIT)); // retries allowed (excludes initial)
179
+ const maxAttempts = retryLimitClamped + 1; // includes initial attempt
175
180
  ctx.groundControl = groundControl;
176
181
  let agentMemory = { lastMenuExpanded: false };
182
+ let turnRetries = 0;
183
+ let stepSeq = ctx._stepSeq || 0;
177
184
 
178
185
  // Centralized recorder: push to in-memory buffer AND fire optional callback for streaming
179
186
  // Idempotent recorder – prevents accidental double-push
180
187
  const recordStep = (step) => {
181
188
  if (!step || step.__recorded) return;
189
+ step._seq = stepSeq++;
182
190
  step.__recorded = true;
183
191
  try { steps.push(step); } catch {}
192
+ if (stepsArchive && stepsArchive !== steps) {
193
+ try { stepsArchive.push(step); } catch {}
194
+ }
184
195
  try { ctx?.onStep?.(step); } catch {}
185
196
  };
186
197
 
@@ -190,7 +201,19 @@ export const turnLoop = async (
190
201
  for (let turn = currentTurn; turn < maxTurns; turn++) {
191
202
  console.log(`\nπŸ”„ Turn ${turn + 1}/${maxTurns}`);
192
203
  let response;
193
- const step = { turn, events: [], result: '🟑 In Progress', missionName };
204
+ const attempt = turnRetries + 1;
205
+ const step = {
206
+ turn,
207
+ retryAttempt: attempt,
208
+ retryLimit: retryLimitClamped, // number of retries allowed (excludes initial)
209
+ events: [],
210
+ result: '🟑 In Progress',
211
+ missionName,
212
+ };
213
+ if (attempt > 1) {
214
+ const retryNumber = attempt - 1;
215
+ step.events.push(`πŸ” Re-attempt ${retryNumber}/${retryLimitClamped} for turn`);
216
+ }
194
217
 
195
218
  try {
196
219
  // Refresh token usage window (rolling 60 seconds)
@@ -211,7 +234,7 @@ export const turnLoop = async (
211
234
  step.events.push('❌ Tool call structure invalid and no placeholders inserted.');
212
235
  step.result = '❌ Failure';
213
236
  recordStep(step);
214
- return steps;
237
+ return { success: false, steps: stepsArchive };
215
238
  }
216
239
 
217
240
  // Detect orphaned assistant tool calls with no matching tool responses
@@ -231,7 +254,7 @@ export const turnLoop = async (
231
254
  step.events.push('πŸ›‘ Detected assistant tool calls without matching tool responses');
232
255
  step.result = '❌ Failure';
233
256
  recordStep(step);
234
- return steps;
257
+ return { success: false, steps: stepsArchive };
235
258
  }
236
259
 
237
260
  // Before calling the model, trim/sanitize the conversation context.
@@ -273,7 +296,7 @@ export const turnLoop = async (
273
296
  step.events.push('❌ Too many retries. Exiting.');
274
297
  step.result = '❌ Failure';
275
298
  recordStep(step);
276
- return steps;
299
+ return { success: false, steps: stepsArchive };
277
300
  }
278
301
  retryCount += 1;
279
302
  turn -= 1;
@@ -283,7 +306,7 @@ export const turnLoop = async (
283
306
  step.events.push(`❌ Bad request: ${err.message}`);
284
307
  step.result = '❌ Failure';
285
308
  recordStep(step);
286
- return steps;
309
+ return { success: false, steps: stepsArchive };
287
310
  } else {
288
311
  throw err;
289
312
  }
@@ -319,6 +342,7 @@ export const turnLoop = async (
319
342
  if (msg.tool_calls?.length) {
320
343
  console.log('Processing tool calls...');
321
344
  const toolResponses = [];
345
+ let hadToolIssues = false;
322
346
 
323
347
  for (const call of msg.tool_calls) {
324
348
  const fnName = call.function.name;
@@ -335,6 +359,7 @@ export const turnLoop = async (
335
359
  } catch (e) {
336
360
  errorMessage = `ERROR: ${e.message}`;
337
361
  result = errorMessage;
362
+ hadToolIssues = true;
338
363
  }
339
364
 
340
365
  // Capture and log any file upload/download events (for reports)
@@ -416,7 +441,21 @@ export const turnLoop = async (
416
441
 
417
442
  // Merge new assistant + tool responses back into conversation
418
443
  messages.push(msg, ...toolResponses);
419
- step.result = 'βœ… Passed';
444
+ if (hadToolIssues && turnRetries < retryLimitClamped) {
445
+ step.result = '⏳ Retrying turn';
446
+ const retryNumber = attempt - 1;
447
+ step.events.push(`πŸ” Re-attempt ${retryNumber}/${retryLimitClamped} after tool issues`);
448
+ recordStep(step);
449
+ turnRetries += 1;
450
+ const delay = Math.min(TURN_RETRY_BASE_DELAY_MS * 2 ** (turnRetries - 1), 2000);
451
+ await wait(delay);
452
+ turn -= 1; // re-use the same turn index
453
+ continue;
454
+ }
455
+
456
+ // reset retries after a clean turn or after exhausting retries
457
+ turnRetries = 0;
458
+ step.result = hadToolIssues ? '⚠️ Turn Issues' : 'βœ… Passed';
420
459
  recordStep(step);
421
460
  continue;
422
461
  }
@@ -429,7 +468,8 @@ export const turnLoop = async (
429
468
  step.events.push(finalResponse.finalMessage);
430
469
  step.result = finalResponse.success ? 'βœ… Mission Success' : '❌ Mission Failure';
431
470
  recordStep(step);
432
- return { success: finalResponse.finalMessage, steps };
471
+ turnRetries = 0;
472
+ return { success: finalResponse.success, finalMessage: finalResponse.finalMessage, steps: stepsArchive };
433
473
  }
434
474
 
435
475
  // ─────────────────────────────────────────────
@@ -443,5 +483,8 @@ export const turnLoop = async (
443
483
  // This is a meaningful turn even without tool calls β€” record it.
444
484
  step.result = step.result || '🟑 In Progress';
445
485
  recordStep(step);
486
+ turnRetries = 0;
446
487
  }
488
+
489
+ return { success: false, steps: stepsArchive };
447
490
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "./bin/cli.js"
@@ -25,7 +25,7 @@
25
25
 
26
26
  import { runAgent } from '../core/agent.js';
27
27
  import { redactPasswordInText } from '../core/redaction.js';
28
- import { loadConfig, enforceTurnBudget } from '../core/config.js';
28
+ import { loadConfig, enforceTurnBudget, getRetryLimit } from '../core/config.js';
29
29
 
30
30
  /**
31
31
  * Run a mission flow.
@@ -38,9 +38,14 @@ export async function runMissions({ preMission, mission, postMission }, missionN
38
38
  const cfg = await loadConfig();
39
39
  const { effectiveMax, limits, notes } = enforceTurnBudget(cfg);
40
40
  const maxTurns = effectiveMax;
41
+ const retryInfo = getRetryLimit(cfg);
42
+ const retryLimit = retryInfo.value;
41
43
  if (notes.length) {
42
44
  console.warn(notes.join('\n'));
43
45
  }
46
+ if (retryInfo.clamped) {
47
+ console.warn(`⚠️ Retry limit clamped to ${retryLimit} (allowed range 1-10).`);
48
+ }
44
49
 
45
50
  // 2) Normalize submissions
46
51
  const normalizeToArray = (x) => (Array.isArray(x) ? x : x ? [x] : []);
@@ -123,7 +128,7 @@ export async function runMissions({ preMission, mission, postMission }, missionN
123
128
  );
124
129
 
125
130
  // 3) Execute
126
- const success = await runAgent(goals, missionName, maxTurns);
131
+ const success = await runAgent(goals, missionName, maxTurns, retryLimit);
127
132
  if (!success) {
128
133
  console.log(`❌ Aborting after failed goal.`);
129
134
  return;
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+
5
+ // Import helper exports from cli.js
6
+ import { __test__ } from '../../bin/cli.js';
7
+
8
+ describe('cli helpers', () => {
9
+ it('guessMimeType returns expected types', () => {
10
+ const { guessMimeType } = __test__;
11
+ expect(guessMimeType('file.html')).toContain('text/html');
12
+ expect(guessMimeType('file.json')).toContain('application/json');
13
+ expect(guessMimeType('file.unknown')).toBe('application/octet-stream');
14
+ });
15
+
16
+ it('safeJoin prevents traversal', () => {
17
+ const { safeJoin } = __test__;
18
+ const root = '/tmp/root';
19
+ expect(safeJoin(root, '/sub/ok.txt')).toBe(path.resolve('/tmp/root/sub/ok.txt'));
20
+ expect(safeJoin(root, '/../etc/passwd')).toBeNull();
21
+ });
22
+
23
+ it('findLatestReportPair finds latest html/json', () => {
24
+ const { findLatestReportPair } = __test__;
25
+ const tmp = fs.mkdtempSync(path.join(process.cwd(), 'testronaut-report-'));
26
+ const r1 = path.join(tmp, 'run_100.html');
27
+ const r2 = path.join(tmp, 'run_200.html');
28
+ fs.writeFileSync(r1, 'a');
29
+ fs.writeFileSync(r2, 'b');
30
+ fs.writeFileSync(path.join(tmp, 'run_200.json'), '{}');
31
+
32
+ const latest = findLatestReportPair(tmp);
33
+ expect(latest?.htmlFile).toBe('run_200.html');
34
+ expect(latest?.jsonFile).toBe('run_200.json');
35
+ });
36
+ });
@@ -8,6 +8,7 @@ import { readFile } from 'node:fs/promises';
8
8
  import {
9
9
  loadConfig,
10
10
  getMaxTurns,
11
+ getRetryLimit,
11
12
  resolveTurnLimits,
12
13
  enforceTurnBudget,
13
14
  } from '../../core/config.js';
@@ -64,6 +65,46 @@ describe('core/config', () => {
64
65
  });
65
66
  });
66
67
 
68
+ describe('getRetryLimit', () => {
69
+ const OLD_ENV = { ...process.env };
70
+ beforeEach(() => {
71
+ process.env = { ...OLD_ENV };
72
+ delete process.env.TESTRONAUT_RETRY_LIMIT;
73
+ });
74
+ afterEach(() => {
75
+ process.env = { ...OLD_ENV };
76
+ });
77
+
78
+ it('returns default when missing', () => {
79
+ const res = getRetryLimit({});
80
+ expect(res.value).toBe(2);
81
+ expect(res.source).toBe('default');
82
+ expect(res.clamped).toBe(false);
83
+ });
84
+
85
+ it('uses config value and clamps to range', () => {
86
+ expect(getRetryLimit({ retryLimit: 5 }).value).toBe(5);
87
+ const high = getRetryLimit({ retryLimit: 99 });
88
+ expect(high.value).toBe(10);
89
+ expect(high.clamped).toBe(true);
90
+ const low = getRetryLimit({ retryLimit: 0 });
91
+ expect(low.value).toBe(1);
92
+ expect(low.clamped).toBe(true);
93
+ });
94
+
95
+ it('prefers env over config and clamps', () => {
96
+ process.env.TESTRONAUT_RETRY_LIMIT = '8';
97
+ const res = getRetryLimit({ retryLimit: 3 });
98
+ expect(res.value).toBe(8);
99
+ expect(res.source).toBe('env');
100
+
101
+ process.env.TESTRONAUT_RETRY_LIMIT = '0';
102
+ const clampedLow = getRetryLimit({ retryLimit: 3 });
103
+ expect(clampedLow.value).toBe(1);
104
+ expect(clampedLow.clamped).toBe(true);
105
+ });
106
+ });
107
+
67
108
  describe('enforceTurnBudget (lenient)', () => {
68
109
  it('clamps > hardMaxTurns', () => {
69
110
  const cfg = { maxTurns: 1000 };
@@ -128,7 +128,7 @@ describe('turnLoop', () => {
128
128
  const messages = baseMessages();
129
129
  const result = await turnLoop(browser, messages, 1, 0, 0, {}, { steps: [], missionName: 'demo' });
130
130
 
131
- expect(result).toBeTruthy();
131
+ expect(result.success).toBe(true);
132
132
  expect(result.steps?.length).toBe(1);
133
133
  const step = result.steps[0];
134
134
  expect(step.tokensUsed).toBe(42);
@@ -163,7 +163,7 @@ describe('turnLoop', () => {
163
163
  const messages = baseMessages();
164
164
  const res = await turnLoop(browser, messages, 2, 0, 0, {}, { steps: [], missionName: 'demo' });
165
165
 
166
- expect(res).toBeTruthy();
166
+ expect(res.success).toBe(true);
167
167
  expect(res.steps.length).toBe(2);
168
168
 
169
169
  const step1 = res.steps[0];
@@ -174,4 +174,56 @@ describe('turnLoop', () => {
174
174
  const step2 = res.steps[1];
175
175
  expect(step2.result).toMatch(/Success/);
176
176
  });
177
+
178
+ it('retries a turn on tool error before marking issues', async () => {
179
+ // First turn: tool call β†’ click_text will throw
180
+ shared.chatMock.mockResolvedValueOnce({
181
+ message: {
182
+ role: 'assistant',
183
+ content: '',
184
+ tool_calls: [
185
+ {
186
+ id: 'tool_retry',
187
+ type: 'function',
188
+ function: { name: 'click_text', arguments: JSON.stringify({ text: 'Hello' }) },
189
+ },
190
+ ],
191
+ },
192
+ usage: { total_tokens: 7 },
193
+ });
194
+ // Retry turn: same tool call, click_text succeeds
195
+ shared.chatMock.mockResolvedValueOnce({
196
+ message: {
197
+ role: 'assistant',
198
+ content: '',
199
+ tool_calls: [
200
+ {
201
+ id: 'tool_retry_2',
202
+ type: 'function',
203
+ function: { name: 'click_text', arguments: JSON.stringify({ text: 'Hello' }) },
204
+ },
205
+ ],
206
+ },
207
+ usage: { total_tokens: 6 },
208
+ });
209
+ // Final turn: mission completes
210
+ shared.chatMock.mockResolvedValueOnce({
211
+ message: { role: 'assistant', content: 'FINAL: done' },
212
+ usage: { total_tokens: 5 },
213
+ });
214
+
215
+ // click_text fails once, then succeeds
216
+ shared.chromeToolSpies.click_text
217
+ .mockImplementationOnce(async () => { throw new Error('no locator'); })
218
+ .mockImplementationOnce(async () => JSON.stringify({ ok: true, clicked: 'Hello' }));
219
+
220
+ const messages = baseMessages();
221
+ const res = await turnLoop(browser, messages, 3, 0, 0, {}, { steps: [], missionName: 'demo' });
222
+
223
+ expect(res.success).toBe(true);
224
+ expect(res.steps.length).toBe(3);
225
+ expect(res.steps[0].result).toBe('⏳ Retrying turn');
226
+ expect(res.steps[1].result).toBe('βœ… Passed');
227
+ expect(res.steps[2].result).toMatch(/Success/);
228
+ });
177
229
  });
@@ -9,10 +9,11 @@ vi.mock('../../core/redaction.js', () => ({
9
9
  vi.mock('../../core/config.js', () => ({
10
10
  loadConfig: vi.fn(),
11
11
  enforceTurnBudget: vi.fn(),
12
+ getRetryLimit: vi.fn(),
12
13
  }));
13
14
 
14
15
  import { runAgent } from '../../core/agent.js';
15
- import { loadConfig, enforceTurnBudget } from '../../core/config.js';
16
+ import { loadConfig, enforceTurnBudget, getRetryLimit } from '../../core/config.js';
16
17
 
17
18
  // Adjust the import path if your file lives elsewhere
18
19
  import { runMissions } from '../../runner/testronaut.js';
@@ -20,6 +21,8 @@ import { runMissions } from '../../runner/testronaut.js';
20
21
  describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
21
22
  beforeEach(() => {
22
23
  vi.clearAllMocks();
24
+ // Default retry limit unless overridden in a test
25
+ getRetryLimit.mockReturnValue({ value: 2, source: 'default', clamped: false });
23
26
  });
24
27
 
25
28
  it('passes effectiveMax to runAgent and logs any notes', async () => {
@@ -30,6 +33,7 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
30
33
  notes: ['⚠️ maxTurns (999) exceeds hardMaxTurns (200). Clamping to 200.'],
31
34
  strict: false,
32
35
  });
36
+ getRetryLimit.mockReturnValue({ value: 2, source: 'default', clamped: false });
33
37
 
34
38
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
35
39
  const log = vi.spyOn(console, 'log').mockImplementation(() => {});
@@ -46,7 +50,7 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
46
50
  expect.stringContaining('Clamping to 200')
47
51
  );
48
52
  expect(runAgent).toHaveBeenCalledWith(
49
- expect.any(Array), 'Budgeted Run', 200
53
+ expect.any(Array), 'Budgeted Run', 200, 2
50
54
  );
51
55
 
52
56
  warn.mockRestore();
@@ -61,6 +65,7 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
61
65
  notes: [],
62
66
  strict: false,
63
67
  });
68
+ getRetryLimit.mockReturnValue({ value: 3, source: 'default', clamped: false });
64
69
 
65
70
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
66
71
  const log = vi.spyOn(console, 'log').mockImplementation(() => {});
@@ -72,7 +77,7 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
72
77
  await runMissions({ mission: 'No warnings' }, 'Clean');
73
78
 
74
79
  expect(warn).not.toHaveBeenCalled();
75
- expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'Clean', 20);
80
+ expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'Clean', 20, 3);
76
81
 
77
82
  warn.mockRestore();
78
83
  log.mockRestore();
@@ -138,8 +143,8 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
138
143
  // Mission name is the passed-in missionName for main
139
144
  expect(goals[1].submissionName).toMatch(/^My Mission/);
140
145
 
141
- // Effective max turns passed through
142
- expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'My Mission', 15);
146
+ // Effective max turns and retry limit passed through
147
+ expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'My Mission', 15, 2);
143
148
 
144
149
  log.mockRestore();
145
150
  });
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+
6
+ import { generateHtmlReport } from '../../tools/generateHtmlReport.js';
7
+
8
+ let tmpDir;
9
+
10
+ beforeEach(() => {
11
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'testronaut-report-'));
12
+ });
13
+
14
+ afterEach(() => {
15
+ try {
16
+ fs.rmSync(tmpDir, { recursive: true, force: true });
17
+ } catch {}
18
+ });
19
+
20
+ describe('generateHtmlReport', () => {
21
+ it('writes an HTML report containing mission and retry metadata', () => {
22
+ const report = {
23
+ runId: 'run_test',
24
+ missions: [
25
+ {
26
+ missionName: 'Mission A',
27
+ submissionType: 'mission',
28
+ submissionName: 'Main',
29
+ status: 'passed',
30
+ steps: [
31
+ {
32
+ turn: 0,
33
+ retryAttempt: 2,
34
+ retryLimit: 5,
35
+ events: ['did something'],
36
+ result: '⚠️ Turn Issues',
37
+ summary: 'Do thing',
38
+ tokensUsed: 10,
39
+ totalTokensUsed: 20,
40
+ },
41
+ ],
42
+ },
43
+ ],
44
+ };
45
+
46
+ const outPath = path.join(tmpDir, 'report.html');
47
+ const written = generateHtmlReport(report, outPath);
48
+
49
+ expect(written).toBe(outPath);
50
+ expect(fs.existsSync(outPath)).toBe(true);
51
+ const html = fs.readFileSync(outPath, 'utf8');
52
+
53
+ expect(html).toContain('Testronaut Report');
54
+ expect(html).toContain('Mission A');
55
+ expect(html).toContain('Turn 1 (re-attempt 1/5)');
56
+ expect(html).toContain('⚠️ Turn Issues');
57
+ });
58
+
59
+ it('escapes unsafe text in output', () => {
60
+ const report = {
61
+ runId: 'run<&>',
62
+ missions: [
63
+ {
64
+ missionName: 'Mission <X>',
65
+ submissionType: 'mission',
66
+ submissionName: 'Main',
67
+ status: 'passed',
68
+ steps: [
69
+ {
70
+ turn: 0,
71
+ retryAttempt: 1,
72
+ events: ['<script>alert(1)</script>'],
73
+ result: 'βœ… Passed',
74
+ summary: '<b>bold</b>',
75
+ },
76
+ ],
77
+ },
78
+ ],
79
+ };
80
+
81
+ const outPath = path.join(tmpDir, 'safe.html');
82
+ generateHtmlReport(report, outPath);
83
+ const html = fs.readFileSync(outPath, 'utf8');
84
+
85
+ expect(html).toContain('run&lt;&amp;&gt;');
86
+ expect(html).toContain('Mission &lt;X&gt;');
87
+ expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
88
+ expect(html).toContain('&lt;b&gt;bold&lt;/b&gt;');
89
+ });
90
+ });
@@ -1,7 +1,30 @@
1
- // generateHtmlReport.js
1
+ /**
2
+ * generateHtmlReport.js
3
+ * ---------------------
4
+ * Purpose:
5
+ * Render a Testronaut run summary into a single, self-contained HTML file.
6
+ *
7
+ * Responsibilities:
8
+ * - Escape and format mission/step data into a readable report.
9
+ * - Group submissions by mission and surface per-step metadata (tokens, retries).
10
+ * - Write the HTML to disk at the provided output path (or a default location).
11
+ *
12
+ * Related tests:
13
+ * tests/toolsTests/generateHtmlReport.test.js
14
+ *
15
+ * Used by:
16
+ * - CLI report generation and upload flows.
17
+ */
2
18
  import fs from 'fs';
3
19
  import path from 'path';
4
20
 
21
+ /**
22
+ * Render and write a Testronaut run report to disk.
23
+ *
24
+ * @param {object} report - normalized run JSON (runId, missions, summary, llm, etc.)
25
+ * @param {string} [outputPath] - optional absolute/relative path for the HTML file
26
+ * @returns {string} absolute path to the written HTML file
27
+ */
5
28
  export function generateHtmlReport(report, outputPath) {
6
29
  const { runId, startTime, endTime, missions = [], summary = {}, llm = {} } = report;
7
30
  const durationSec =
@@ -9,7 +32,7 @@ export function generateHtmlReport(report, outputPath) {
9
32
  ? ((new Date(endTime) - new Date(startTime)) / 1000).toFixed(2)
10
33
  : 'β€”';
11
34
 
12
- // helpers
35
+ // Escaping helpers for text and HTML attributes
13
36
  const esc = (s) => String(s ?? '')
14
37
  .replace(/&/g, '&amp;').replace(/</g, '&lt;')
15
38
  .replace(/>/g, '&gt;');
@@ -28,6 +51,17 @@ export function generateHtmlReport(report, outputPath) {
28
51
  const stepItems = steps.map((step, idx) => {
29
52
  const events = Array.isArray(step.events) ? step.events : [];
30
53
  const ok = /βœ…|Passed|Mission Success/i.test(step.result || '');
54
+ const resultRaw = step.result || 'β€”';
55
+ const resultTooltip = resultRaw.includes('⚠️ Turn Issues')
56
+ ? 'Turn had tool/action issues (e.g., selector/timeouts). It may not indicate a product bug.'
57
+ : '';
58
+ const retryAttempt = step.retryAttempt || 1; // includes initial
59
+ const retryLimit = step.retryLimit; // number of retries allowed (excludes initial)
60
+ const retryNumber = retryAttempt - 1;
61
+ const retryTotal = Number.isFinite(retryLimit) ? retryLimit : Math.max(retryNumber, 0);
62
+ const attemptLabel = retryNumber > 0
63
+ ? ` (re-attempt ${retryNumber}/${retryTotal || retryNumber})`
64
+ : '';
31
65
  const imgTag = step.screenshotPath
32
66
  ? `<img src="${esc(step.screenshotPath)}" alt="screenshot turn ${esc(step.turn ?? idx)}">`
33
67
  : '';
@@ -42,9 +76,9 @@ export function generateHtmlReport(report, outputPath) {
42
76
  return `
43
77
  <details class="step" ${ok ? '' : 'open'}>
44
78
  <summary>
45
- <span class="turn">Turn ${esc((step.turn ?? idx) + 1)}</span>
79
+ <span class="turn">Turn ${esc((step.turn ?? idx) + 1)}${esc(attemptLabel)}</span>
46
80
  ${planSpan}
47
- <span class="step-result ${ok ? 'ok' : 'bad'}">${esc(step.result || 'β€”')}</span>
81
+ <span class="step-result ${ok ? 'ok' : 'bad'}" ${resultTooltip ? `title="${escAttr(resultTooltip)}"` : ''}>${esc(resultRaw)}</span>
48
82
  <span class="tokens">tokens: ${esc(step.tokensUsed ?? 'β€”')} / total: ${esc(step.totalTokensUsed ?? 'β€”')}</span>
49
83
  </summary>
50
84
  <pre class="events">${esc(events.join('\n')) || '(no events)'}</pre>