testronaut 1.2.5 → 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.
@@ -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
  });
@@ -77,6 +77,35 @@ describe('GeminiProvider', () => {
77
77
  expect(shared.getModelOpts.tools[0].functionDeclarations.map(d => d.name)).toEqual(['foo', 'bar']);
78
78
  });
79
79
 
80
+ it('strips additionalProperties from tool schemas (unsupported by Gemini)', async () => {
81
+ const prov = new GeminiProvider({ apiKey: 'gk-123' });
82
+ const tools = [
83
+ {
84
+ type: 'function',
85
+ function: {
86
+ name: 'set_ground_control_state',
87
+ parameters: {
88
+ type: 'object',
89
+ properties: {
90
+ app: {
91
+ type: 'object',
92
+ additionalProperties: false,
93
+ properties: { baseUrl: { type: 'string' } },
94
+ },
95
+ },
96
+ additionalProperties: false,
97
+ },
98
+ },
99
+ },
100
+ ];
101
+
102
+ await prov.chat({ model: 'gemini-2.5-flash', messages: [{ role: 'user', content: 'go' }], tools });
103
+
104
+ const decl = shared.getModelOpts.tools[0].functionDeclarations[0];
105
+ expect(decl.parameters.additionalProperties).toBeUndefined();
106
+ expect(decl.parameters.properties.app.additionalProperties).toBeUndefined();
107
+ });
108
+
80
109
  it('converts OpenAI-like messages → Gemini contents (system prefix + tool calls + tool results)', async () => {
81
110
  const prov = new GeminiProvider({ apiKey: 'gk-xyz' });
82
111
 
@@ -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
  });