testronaut 1.3.0 → 1.3.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 +2 -0
- package/bin/cli.js +124 -16
- package/bin/init.js +2 -0
- package/bin/initHelpers.js +8 -0
- package/core/agent.js +21 -13
- package/core/config.js +121 -0
- package/core/missionDiscovery.js +55 -0
- package/core/turnLoop.js +169 -8
- package/package.json +3 -2
- package/runner/testronaut.js +71 -2
- package/tests/binTests/cli.helpers.test.js +45 -0
- package/tests/binTests/init.test.js +6 -0
- package/tests/binTests/initHelpersTests/defaultConfig.test.js +3 -0
- package/tests/coreTests/config.test.js +137 -0
- package/tests/coreTests/missionDiscovery.test.js +81 -0
- package/tests/coreTests/turnLoop.test.js +100 -3
- package/tests/runnerTests/testronaut.test.js +76 -6
- package/tests/toolsTests/domControl.test.js +35 -0
- package/tests/toolsTests/generateHtmlReport.test.js +90 -0
- package/tests/toolsTests/toolSchema.test.js +24 -0
- package/tools/chromeBrowser.js +211 -4
- package/tools/domControl.js +10 -3
- package/tools/generateHtmlReport.js +38 -4
- package/tools/toolSchema.js +43 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { discoverMissionFiles } from '../../core/missionDiscovery.js';
|
|
6
|
+
|
|
7
|
+
function makeTempDir() {
|
|
8
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'testronaut-missions-'));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function writeJson(p, obj) {
|
|
12
|
+
fs.writeFileSync(p, JSON.stringify(obj, null, 2));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const temps = [];
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
while (temps.length) {
|
|
19
|
+
const dir = temps.pop();
|
|
20
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('discoverMissionFiles', () => {
|
|
25
|
+
it('falls back to legacy discovery when missions block is missing', async () => {
|
|
26
|
+
const tmp = makeTempDir();
|
|
27
|
+
temps.push(tmp);
|
|
28
|
+
const missionsDir = path.join(tmp, 'missions');
|
|
29
|
+
fs.mkdirSync(missionsDir, { recursive: true });
|
|
30
|
+
fs.writeFileSync(path.join(missionsDir, 'alpha.mission.js'), '// ok');
|
|
31
|
+
fs.writeFileSync(path.join(missionsDir, 'beta.txt'), '// ignore');
|
|
32
|
+
fs.mkdirSync(path.join(missionsDir, 'nested'), { recursive: true });
|
|
33
|
+
fs.writeFileSync(path.join(missionsDir, 'nested', 'gamma.mission.js'), '// nested');
|
|
34
|
+
fs.writeFileSync(path.join(missionsDir, 'delta.mission.ts'), '// ts not included by default');
|
|
35
|
+
|
|
36
|
+
const res = await discoverMissionFiles({ cwd: tmp });
|
|
37
|
+
expect(res.root).toBe(path.resolve(tmp, 'missions'));
|
|
38
|
+
expect(res.files).toEqual(['alpha.mission.js']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('respects missions.root with include patterns', async () => {
|
|
42
|
+
const tmp = makeTempDir();
|
|
43
|
+
temps.push(tmp);
|
|
44
|
+
const missionsDir = path.join(tmp, 'custom');
|
|
45
|
+
fs.mkdirSync(path.join(missionsDir, 'nested'), { recursive: true });
|
|
46
|
+
fs.writeFileSync(path.join(missionsDir, 'root.mission.js'), '// root');
|
|
47
|
+
fs.writeFileSync(path.join(missionsDir, 'nested', 'deep.mission.ts'), '// nested ts');
|
|
48
|
+
|
|
49
|
+
writeJson(path.join(tmp, 'testronaut-config.json'), {
|
|
50
|
+
missions: {
|
|
51
|
+
root: './custom',
|
|
52
|
+
include: ['*.mission.js', 'nested/**/*.mission.ts'],
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const res = await discoverMissionFiles({ cwd: tmp });
|
|
57
|
+
expect(res.root).toBe(path.resolve(tmp, 'custom'));
|
|
58
|
+
expect(res.files).toEqual(['nested/deep.mission.ts', 'root.mission.js']);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('applies missions.exclude after includes', async () => {
|
|
62
|
+
const tmp = makeTempDir();
|
|
63
|
+
temps.push(tmp);
|
|
64
|
+
const missionsDir = path.join(tmp, 'custom');
|
|
65
|
+
fs.mkdirSync(path.join(missionsDir, 'ignore'), { recursive: true });
|
|
66
|
+
fs.writeFileSync(path.join(missionsDir, 'keep.mission.js'), '// keep');
|
|
67
|
+
fs.writeFileSync(path.join(missionsDir, 'ignore', 'skip.mission.js'), '// skip');
|
|
68
|
+
fs.writeFileSync(path.join(missionsDir, 'keep.wip.mission.js'), '// skip by pattern');
|
|
69
|
+
|
|
70
|
+
writeJson(path.join(tmp, 'testronaut-config.json'), {
|
|
71
|
+
missions: {
|
|
72
|
+
root: './custom',
|
|
73
|
+
include: ['**/*.mission.js'],
|
|
74
|
+
exclude: ['ignore/**', '**/*.wip.mission.js'],
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const res = await discoverMissionFiles({ cwd: tmp });
|
|
79
|
+
expect(res.files).toEqual(['keep.mission.js']);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -22,6 +22,7 @@ const { shared } = vi.hoisted(() => {
|
|
|
22
22
|
mode: 'save',
|
|
23
23
|
})
|
|
24
24
|
),
|
|
25
|
+
list_local_files: vi.fn(async () => JSON.stringify({ files: ['a.pdf', 'b.pdf'] })),
|
|
25
26
|
},
|
|
26
27
|
},
|
|
27
28
|
};
|
|
@@ -63,6 +64,7 @@ vi.mock('../../tools/toolSchema.js', () => ({
|
|
|
63
64
|
{ type: 'function', function: { name: 'click_text', description: 'click by text', parameters: {} } },
|
|
64
65
|
{ type: 'function', function: { name: 'get_dom', description: 'get DOM html', parameters: {} } },
|
|
65
66
|
{ type: 'function', function: { name: 'screenshot', description: 'take a screenshot', parameters: {} } },
|
|
67
|
+
{ type: 'function', function: { name: 'list_local_files', description: 'list files', parameters: {} } },
|
|
66
68
|
],
|
|
67
69
|
}));
|
|
68
70
|
|
|
@@ -96,7 +98,7 @@ vi.mock('../../core/redaction.js', () => ({
|
|
|
96
98
|
}));
|
|
97
99
|
|
|
98
100
|
// Import SUT after mocks
|
|
99
|
-
import { turnLoop } from '../../core/turnLoop.js';
|
|
101
|
+
import { turnLoop, __docProgressInternals } from '../../core/turnLoop.js';
|
|
100
102
|
|
|
101
103
|
// Helper
|
|
102
104
|
function baseMessages() {
|
|
@@ -128,7 +130,7 @@ describe('turnLoop', () => {
|
|
|
128
130
|
const messages = baseMessages();
|
|
129
131
|
const result = await turnLoop(browser, messages, 1, 0, 0, {}, { steps: [], missionName: 'demo' });
|
|
130
132
|
|
|
131
|
-
expect(result).
|
|
133
|
+
expect(result.success).toBe(true);
|
|
132
134
|
expect(result.steps?.length).toBe(1);
|
|
133
135
|
const step = result.steps[0];
|
|
134
136
|
expect(step.tokensUsed).toBe(42);
|
|
@@ -163,7 +165,7 @@ describe('turnLoop', () => {
|
|
|
163
165
|
const messages = baseMessages();
|
|
164
166
|
const res = await turnLoop(browser, messages, 2, 0, 0, {}, { steps: [], missionName: 'demo' });
|
|
165
167
|
|
|
166
|
-
expect(res).
|
|
168
|
+
expect(res.success).toBe(true);
|
|
167
169
|
expect(res.steps.length).toBe(2);
|
|
168
170
|
|
|
169
171
|
const step1 = res.steps[0];
|
|
@@ -174,4 +176,99 @@ describe('turnLoop', () => {
|
|
|
174
176
|
const step2 = res.steps[1];
|
|
175
177
|
expect(step2.result).toMatch(/Success/);
|
|
176
178
|
});
|
|
179
|
+
|
|
180
|
+
it('retries a turn on tool error before marking issues', async () => {
|
|
181
|
+
// First turn: tool call → click_text will throw
|
|
182
|
+
shared.chatMock.mockResolvedValueOnce({
|
|
183
|
+
message: {
|
|
184
|
+
role: 'assistant',
|
|
185
|
+
content: '',
|
|
186
|
+
tool_calls: [
|
|
187
|
+
{
|
|
188
|
+
id: 'tool_retry',
|
|
189
|
+
type: 'function',
|
|
190
|
+
function: { name: 'click_text', arguments: JSON.stringify({ text: 'Hello' }) },
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
usage: { total_tokens: 7 },
|
|
195
|
+
});
|
|
196
|
+
// Retry turn: same tool call, click_text succeeds
|
|
197
|
+
shared.chatMock.mockResolvedValueOnce({
|
|
198
|
+
message: {
|
|
199
|
+
role: 'assistant',
|
|
200
|
+
content: '',
|
|
201
|
+
tool_calls: [
|
|
202
|
+
{
|
|
203
|
+
id: 'tool_retry_2',
|
|
204
|
+
type: 'function',
|
|
205
|
+
function: { name: 'click_text', arguments: JSON.stringify({ text: 'Hello' }) },
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
usage: { total_tokens: 6 },
|
|
210
|
+
});
|
|
211
|
+
// Final turn: mission completes
|
|
212
|
+
shared.chatMock.mockResolvedValueOnce({
|
|
213
|
+
message: { role: 'assistant', content: 'FINAL: done' },
|
|
214
|
+
usage: { total_tokens: 5 },
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// click_text fails once, then succeeds
|
|
218
|
+
shared.chromeToolSpies.click_text
|
|
219
|
+
.mockImplementationOnce(async () => { throw new Error('no locator'); })
|
|
220
|
+
.mockImplementationOnce(async () => JSON.stringify({ ok: true, clicked: 'Hello' }));
|
|
221
|
+
|
|
222
|
+
const messages = baseMessages();
|
|
223
|
+
const res = await turnLoop(browser, messages, 3, 0, 0, {}, { steps: [], missionName: 'demo' });
|
|
224
|
+
|
|
225
|
+
expect(res.success).toBe(true);
|
|
226
|
+
expect(res.steps.length).toBe(3);
|
|
227
|
+
expect(res.steps[0].result).toBe('⏳ Retrying turn');
|
|
228
|
+
expect(res.steps[1].result).toBe('✅ Passed');
|
|
229
|
+
expect(res.steps[2].result).toMatch(/Success/);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('__docProgressInternals helpers', () => {
|
|
233
|
+
const { parseDocListFromDom, extractDocIdFromUrl, ensureDocProgress } = __docProgressInternals;
|
|
234
|
+
|
|
235
|
+
it('parses injected doc list summary', () => {
|
|
236
|
+
const html = `<pre data-testronaut-doc-list>
|
|
237
|
+
List
|
|
238
|
+
- [123] File A /document/123
|
|
239
|
+
- [456] File B /document/456
|
|
240
|
+
</pre>`;
|
|
241
|
+
const { items, scriptDocs } = parseDocListFromDom(html);
|
|
242
|
+
expect(items).toHaveLength(2);
|
|
243
|
+
expect(items[0].id).toBe('123');
|
|
244
|
+
expect(items[1].title.length).toBeGreaterThan(0);
|
|
245
|
+
expect(scriptDocs).toBe(2);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('extracts doc id from url and tracks progress', () => {
|
|
249
|
+
expect(extractDocIdFromUrl('https://x.com/document/999/foo')).toBe('999');
|
|
250
|
+
expect(extractDocIdFromUrl('nope')).toBe('');
|
|
251
|
+
|
|
252
|
+
const mem = {};
|
|
253
|
+
const cfg = { enabled: true, hrefIncludes: ['/doc'], dataTypes: ['doc'] };
|
|
254
|
+
const prog = ensureDocProgress(mem, cfg);
|
|
255
|
+
expect(prog.patterns).toBe(cfg);
|
|
256
|
+
prog.items = [{ id: '1', title: 'Doc 1' }];
|
|
257
|
+
prog.downloaded.add('1');
|
|
258
|
+
expect(prog.downloaded.has('1')).toBe(true);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('updates progress from list_local_files results', () => {
|
|
262
|
+
const mem = {};
|
|
263
|
+
const cfg = { enabled: true, hrefIncludes: ['/doc'], dataTypes: ['doc'] };
|
|
264
|
+
const prog = ensureDocProgress(mem, cfg);
|
|
265
|
+
prog.items = [];
|
|
266
|
+
// simulate list_local_files JSON result
|
|
267
|
+
const parsed = JSON.parse(JSON.stringify({ files: ['x.pdf', 'y.pdf'] }));
|
|
268
|
+
prog.items = parsed.files.map(f => ({ id: f, title: f, href: f }));
|
|
269
|
+
prog.downloaded.add('x.pdf');
|
|
270
|
+
expect(prog.items).toHaveLength(2);
|
|
271
|
+
expect(prog.downloaded.has('x.pdf')).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
177
274
|
});
|
|
@@ -9,17 +9,26 @@ 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(),
|
|
13
|
+
getDomListLimit: vi.fn(),
|
|
14
|
+
getResourceGuardConfig: vi.fn(),
|
|
12
15
|
}));
|
|
13
16
|
|
|
14
17
|
import { runAgent } from '../../core/agent.js';
|
|
15
|
-
import { loadConfig, enforceTurnBudget } from '../../core/config.js';
|
|
18
|
+
import { loadConfig, enforceTurnBudget, getRetryLimit, getDomListLimit, getResourceGuardConfig } from '../../core/config.js';
|
|
16
19
|
|
|
17
20
|
// Adjust the import path if your file lives elsewhere
|
|
18
|
-
import { runMissions } from '../../runner/testronaut.js';
|
|
21
|
+
import { runMissions, __test__ as testronautInternals } from '../../runner/testronaut.js';
|
|
19
22
|
|
|
20
23
|
describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
|
|
24
|
+
const OLD_ENV = { ...process.env };
|
|
21
25
|
beforeEach(() => {
|
|
26
|
+
process.env = { ...OLD_ENV };
|
|
22
27
|
vi.clearAllMocks();
|
|
28
|
+
// Default retry limit unless overridden in a test
|
|
29
|
+
getRetryLimit.mockReturnValue({ value: 2, source: 'default', clamped: false });
|
|
30
|
+
getDomListLimit.mockReturnValue({ value: 3, mode: 'number', source: 'default', clamped: false });
|
|
31
|
+
getResourceGuardConfig.mockReturnValue({ enabled: true, hrefIncludes: ['/document/'], dataTypes: ['document'] });
|
|
23
32
|
});
|
|
24
33
|
|
|
25
34
|
it('passes effectiveMax to runAgent and logs any notes', async () => {
|
|
@@ -30,6 +39,7 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
|
|
|
30
39
|
notes: ['⚠️ maxTurns (999) exceeds hardMaxTurns (200). Clamping to 200.'],
|
|
31
40
|
strict: false,
|
|
32
41
|
});
|
|
42
|
+
getRetryLimit.mockReturnValue({ value: 2, source: 'default', clamped: false });
|
|
33
43
|
|
|
34
44
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
35
45
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
@@ -46,7 +56,7 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
|
|
|
46
56
|
expect.stringContaining('Clamping to 200')
|
|
47
57
|
);
|
|
48
58
|
expect(runAgent).toHaveBeenCalledWith(
|
|
49
|
-
expect.any(Array), 'Budgeted Run', 200
|
|
59
|
+
expect.any(Array), 'Budgeted Run', 200, 2, { domListLimit: 3, debug: false, resourceGuard: { enabled: true, hrefIncludes: ['/document/'], dataTypes: ['document'] } }
|
|
50
60
|
);
|
|
51
61
|
|
|
52
62
|
warn.mockRestore();
|
|
@@ -61,6 +71,7 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
|
|
|
61
71
|
notes: [],
|
|
62
72
|
strict: false,
|
|
63
73
|
});
|
|
74
|
+
getRetryLimit.mockReturnValue({ value: 3, source: 'default', clamped: false });
|
|
64
75
|
|
|
65
76
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
66
77
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
@@ -72,7 +83,33 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
|
|
|
72
83
|
await runMissions({ mission: 'No warnings' }, 'Clean');
|
|
73
84
|
|
|
74
85
|
expect(warn).not.toHaveBeenCalled();
|
|
75
|
-
expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'Clean', 20);
|
|
86
|
+
expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'Clean', 20, 3, { domListLimit: 3, debug: false, resourceGuard: { enabled: true, hrefIncludes: ['/document/'], dataTypes: ['document'] } });
|
|
87
|
+
|
|
88
|
+
warn.mockRestore();
|
|
89
|
+
log.mockRestore();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('warns when the DOM list limit is clamped', async () => {
|
|
93
|
+
loadConfig.mockResolvedValue({});
|
|
94
|
+
enforceTurnBudget.mockReturnValue({
|
|
95
|
+
effectiveMax: 20,
|
|
96
|
+
limits: {},
|
|
97
|
+
notes: [],
|
|
98
|
+
strict: false,
|
|
99
|
+
});
|
|
100
|
+
getDomListLimit.mockReturnValue({ value: 100, mode: 'number', source: 'config', clamped: true });
|
|
101
|
+
|
|
102
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
103
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
104
|
+
runAgent.mockResolvedValue([
|
|
105
|
+
{ steps: [{ result: 'SUCCESS: ok' }], status: 'passed' },
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
await runMissions({ mission: 'Clamp lists' }, 'Clamp Mission');
|
|
109
|
+
|
|
110
|
+
expect(warn).toHaveBeenCalledWith(
|
|
111
|
+
expect.stringContaining('DOM list limit clamped to 100')
|
|
112
|
+
);
|
|
76
113
|
|
|
77
114
|
warn.mockRestore();
|
|
78
115
|
log.mockRestore();
|
|
@@ -138,8 +175,8 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
|
|
|
138
175
|
// Mission name is the passed-in missionName for main
|
|
139
176
|
expect(goals[1].submissionName).toMatch(/^My Mission/);
|
|
140
177
|
|
|
141
|
-
// Effective max turns passed through
|
|
142
|
-
expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'My Mission', 15);
|
|
178
|
+
// Effective max turns and retry limit passed through
|
|
179
|
+
expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'My Mission', 15, 2, { domListLimit: 3, debug: false, resourceGuard: { enabled: true, hrefIncludes: ['/document/'], dataTypes: ['document'] } });
|
|
143
180
|
|
|
144
181
|
log.mockRestore();
|
|
145
182
|
});
|
|
@@ -161,4 +198,37 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
|
|
|
161
198
|
|
|
162
199
|
log.mockRestore();
|
|
163
200
|
});
|
|
201
|
+
|
|
202
|
+
it('passes debug flag through when TESTRONAUT_DEBUG is set', async () => {
|
|
203
|
+
process.env.TESTRONAUT_DEBUG = 'true';
|
|
204
|
+
loadConfig.mockResolvedValue({});
|
|
205
|
+
enforceTurnBudget.mockReturnValue({
|
|
206
|
+
effectiveMax: 20,
|
|
207
|
+
limits: {},
|
|
208
|
+
notes: [],
|
|
209
|
+
strict: false,
|
|
210
|
+
});
|
|
211
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
212
|
+
runAgent.mockResolvedValue([
|
|
213
|
+
{ steps: [{ result: 'SUCCESS: ok' }], status: 'passed' },
|
|
214
|
+
]);
|
|
215
|
+
|
|
216
|
+
await runMissions({ mission: 'Debug' }, 'Debug Mission');
|
|
217
|
+
|
|
218
|
+
expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'Debug Mission', 20, 2, { domListLimit: 3, debug: true, resourceGuard: { enabled: true, hrefIncludes: ['/document/'], dataTypes: ['document'] } });
|
|
219
|
+
|
|
220
|
+
log.mockRestore();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('__test__ helpers', () => {
|
|
224
|
+
it('formats list limits and reads debug env', () => {
|
|
225
|
+
const { isDebugEnabled, formatListLimit } = testronautInternals;
|
|
226
|
+
process.env.TESTRONAUT_DEBUG = 'on';
|
|
227
|
+
expect(isDebugEnabled()).toBe(true);
|
|
228
|
+
process.env.TESTRONAUT_DEBUG = '0';
|
|
229
|
+
expect(isDebugEnabled()).toBe(false);
|
|
230
|
+
expect(formatListLimit(Infinity)).toBe('all');
|
|
231
|
+
expect(formatListLimit(5)).toBe(5);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
164
234
|
});
|
|
@@ -195,6 +195,41 @@ describe('domControl helpers', () => {
|
|
|
195
195
|
// Total children: 2 cards + 1 other + 1 marker
|
|
196
196
|
expect(rootChildren.length).toBe(4);
|
|
197
197
|
});
|
|
198
|
+
|
|
199
|
+
it('keeps all repeated divs when maxAllowed is Infinity', () => {
|
|
200
|
+
const html = `
|
|
201
|
+
<div id="root">
|
|
202
|
+
<div class="card">1</div>
|
|
203
|
+
<div class="card">2</div>
|
|
204
|
+
<div class="card">3</div>
|
|
205
|
+
<div class="card">4</div>
|
|
206
|
+
</div>
|
|
207
|
+
`;
|
|
208
|
+
const $ = cheerio.load(html);
|
|
209
|
+
|
|
210
|
+
reduceRepeatedDivs($, Infinity);
|
|
211
|
+
|
|
212
|
+
const cardDivs = $('#root').children('div.card');
|
|
213
|
+
expect(cardDivs.length).toBe(4);
|
|
214
|
+
expect($('#root').children('div[data-collapsed="true"]').length).toBe(0);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('removes all repeated divs when maxAllowed is 0', () => {
|
|
218
|
+
const html = `
|
|
219
|
+
<div id="root">
|
|
220
|
+
<div class="card">A</div>
|
|
221
|
+
<div class="card">B</div>
|
|
222
|
+
</div>
|
|
223
|
+
`;
|
|
224
|
+
const $ = cheerio.load(html);
|
|
225
|
+
|
|
226
|
+
reduceRepeatedDivs($, 0);
|
|
227
|
+
|
|
228
|
+
const cardDivs = $('#root').children('div.card');
|
|
229
|
+
expect(cardDivs.length).toBe(0);
|
|
230
|
+
const markers = $('#root').children('div[data-collapsed="true"]');
|
|
231
|
+
expect(markers.length).toBe(1);
|
|
232
|
+
});
|
|
198
233
|
});
|
|
199
234
|
|
|
200
235
|
describe('removeObfuscatedClassNames', () => {
|
|
@@ -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<&>');
|
|
86
|
+
expect(html).toContain('Mission <X>');
|
|
87
|
+
expect(html).toContain('<script>alert(1)</script>');
|
|
88
|
+
expect(html).toContain('<b>bold</b>');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import toolsSchema from '../../tools/toolSchema.js';
|
|
3
|
+
|
|
4
|
+
describe('tools/toolSchema', () => {
|
|
5
|
+
it('includes resource_progress tool with empty params', () => {
|
|
6
|
+
const entry = toolsSchema.find(t => t.function?.name === 'resource_progress');
|
|
7
|
+
expect(entry?.type).toBe('function');
|
|
8
|
+
expect(entry.function.parameters).toEqual({ type: 'object', properties: {} });
|
|
9
|
+
expect(entry.function.description).toMatch(/remaining/i);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('includes list_local_files tool', () => {
|
|
13
|
+
const entry = toolsSchema.find(t => t.function?.name === 'list_local_files');
|
|
14
|
+
expect(entry?.type).toBe('function');
|
|
15
|
+
expect(entry.function.parameters?.properties).toHaveProperty('dir');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('defines navigate and download_file with required fields', () => {
|
|
19
|
+
const nav = toolsSchema.find(t => t.function?.name === 'navigate');
|
|
20
|
+
const download = toolsSchema.find(t => t.function?.name === 'download_file');
|
|
21
|
+
expect(nav?.function?.parameters?.required).toContain('url');
|
|
22
|
+
expect(download?.function?.parameters?.properties).toHaveProperty('url');
|
|
23
|
+
});
|
|
24
|
+
});
|