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.
- package/bin/cli.js +74 -9
- package/core/agent.js +88 -25
- package/core/config.js +30 -0
- package/core/turnLoop.js +119 -20
- package/llm/gemini/geminiProvider.js +14 -1
- package/package.json +1 -1
- package/runner/testronaut.js +7 -2
- package/tests/binTests/cli.helpers.test.js +36 -0
- package/tests/coreTests/config.test.js +41 -0
- package/tests/coreTests/turnLoop.test.js +54 -2
- package/tests/llmTests/geminiProvider.test.js +29 -0
- package/tests/runnerTests/testronaut.test.js +10 -5
- package/tests/toolsTests/contextControl.test.js +595 -0
- package/tests/toolsTests/domControl.test.js +329 -0
- package/tests/toolsTests/generateHtmlReport.test.js +90 -0
- package/tools/chromeBrowser.js +4 -0
- package/tools/contextControl.js +307 -0
- package/tools/domControl.js +129 -3
- package/tools/generateHtmlReport.js +38 -4
- package/tools/tokenControl.js +13 -2
- package/tools/toolSchema.js +101 -0
- package/tools/turnLoopUtils.js +0 -1
package/runner/testronaut.js
CHANGED
|
@@ -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).
|
|
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).
|
|
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
|
});
|