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
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// tests/toolsTests/domControl.test.js
|
|
2
|
+
/**
|
|
3
|
+
* domControl.test.js
|
|
4
|
+
* -------------------
|
|
5
|
+
* Purpose:
|
|
6
|
+
* Unit tests for DOM normalization helpers in domControl.js.
|
|
7
|
+
* Ensures we preserve semantic structure/text while stripping noise
|
|
8
|
+
* and collapsing long/repetitive content.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from 'vitest';
|
|
12
|
+
import * as cheerio from 'cheerio';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
removeNonContentTags,
|
|
16
|
+
collapseLongContent,
|
|
17
|
+
chunkBySection,
|
|
18
|
+
reduceRepeatedDivs,
|
|
19
|
+
removeObfuscatedClassNames,
|
|
20
|
+
removeAutoGeneratedClasses,
|
|
21
|
+
minifyDomWhitespace,
|
|
22
|
+
} from '../../tools/domControl.js'; // ← adjust path as needed
|
|
23
|
+
|
|
24
|
+
describe('domControl helpers', () => {
|
|
25
|
+
describe('removeNonContentTags', () => {
|
|
26
|
+
it('removes non-content tags and non-whitelisted attributes', async () => {
|
|
27
|
+
const html = `
|
|
28
|
+
<html>
|
|
29
|
+
<head>
|
|
30
|
+
<meta charset="utf-8">
|
|
31
|
+
<script>console.log('hi');</script>
|
|
32
|
+
<style>.foo { color: red; }</style>
|
|
33
|
+
<link rel="stylesheet" href="style.css" />
|
|
34
|
+
</head>
|
|
35
|
+
<body data-theme="dark">
|
|
36
|
+
<!-- comment -->
|
|
37
|
+
<div id="main" data-testid="main" onclick="alert('x')">
|
|
38
|
+
<iframe src="frame.html"></iframe>
|
|
39
|
+
<canvas></canvas>
|
|
40
|
+
<svg></svg>
|
|
41
|
+
<a href="/home" rel="noopener" target="_blank">Home</a>
|
|
42
|
+
<input id="email" name="email" type="email" data-tracking="abc" />
|
|
43
|
+
</div>
|
|
44
|
+
</body>
|
|
45
|
+
</html>
|
|
46
|
+
`;
|
|
47
|
+
const $ = cheerio.load(html);
|
|
48
|
+
|
|
49
|
+
await removeNonContentTags($);
|
|
50
|
+
const result = $.html();
|
|
51
|
+
|
|
52
|
+
// Non-content tags removed
|
|
53
|
+
expect(result).not.toContain('<script');
|
|
54
|
+
expect(result).not.toContain('<style');
|
|
55
|
+
expect(result).not.toContain('<meta');
|
|
56
|
+
expect(result).not.toContain('<link');
|
|
57
|
+
expect(result).not.toContain('<iframe');
|
|
58
|
+
expect(result).not.toContain('<canvas');
|
|
59
|
+
expect(result).not.toContain('<svg');
|
|
60
|
+
expect(result).not.toContain('<!-- comment -->');
|
|
61
|
+
|
|
62
|
+
// Body-level non-whitelisted attribute stripped
|
|
63
|
+
expect($('body').attr('data-theme')).toBeUndefined();
|
|
64
|
+
|
|
65
|
+
// Whitelisted attrs preserved
|
|
66
|
+
const main = $('#main');
|
|
67
|
+
expect(main.attr('id')).toBe('main');
|
|
68
|
+
expect(main.attr('data-testid')).toBeUndefined();
|
|
69
|
+
expect(main.attr('onclick')).toBeUndefined();
|
|
70
|
+
|
|
71
|
+
const input = $('#email');
|
|
72
|
+
expect(input.attr('id')).toBe('email');
|
|
73
|
+
expect(input.attr('name')).toBe('email');
|
|
74
|
+
expect(input.attr('type')).toBe('email');
|
|
75
|
+
expect(input.attr('data-tracking')).toBeUndefined();
|
|
76
|
+
|
|
77
|
+
const link = $('a[href="/home"]');
|
|
78
|
+
expect(link.attr('href')).toBe('/home');
|
|
79
|
+
// rel/target should be stripped (not whitelisted)
|
|
80
|
+
expect(link.attr('rel')).toBeUndefined();
|
|
81
|
+
expect(link.attr('target')).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('collapseLongContent', () => {
|
|
86
|
+
it('collapses long lists and appends a truncated marker', async () => {
|
|
87
|
+
const html = `
|
|
88
|
+
<ul id="list">
|
|
89
|
+
<li>Item 1</li>
|
|
90
|
+
<li>Item 2</li>
|
|
91
|
+
<li>Item 3</li>
|
|
92
|
+
<li>Item 4</li>
|
|
93
|
+
</ul>
|
|
94
|
+
<div id="cards">
|
|
95
|
+
<div class="card">A</div>
|
|
96
|
+
<div class="card">B</div>
|
|
97
|
+
<div class="card">C</div>
|
|
98
|
+
<div class="card">D</div>
|
|
99
|
+
</div>
|
|
100
|
+
`;
|
|
101
|
+
const $ = cheerio.load(html);
|
|
102
|
+
|
|
103
|
+
await collapseLongContent($, 2);
|
|
104
|
+
|
|
105
|
+
const listItems = $('#list').children('li');
|
|
106
|
+
expect(listItems.length).toBe(3); // 2 kept + 1 truncated
|
|
107
|
+
expect(listItems.eq(0).text()).toBe('Item 1');
|
|
108
|
+
expect(listItems.eq(1).text()).toBe('Item 2');
|
|
109
|
+
expect(listItems.eq(2).attr('data-collapsed')).toBe('true');
|
|
110
|
+
|
|
111
|
+
const cardChildren = $('#cards').children();
|
|
112
|
+
expect(cardChildren.length).toBe(3); // 2 cards + truncated div
|
|
113
|
+
expect(cardChildren.eq(0).hasClass('card')).toBe(true);
|
|
114
|
+
expect(cardChildren.eq(1).hasClass('card')).toBe(true);
|
|
115
|
+
expect(cardChildren.eq(2).attr('data-collapsed')).toBe('true');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('chunkBySection', () => {
|
|
120
|
+
it('returns section HTML for header/nav/main/footer/aside/body', async () => {
|
|
121
|
+
const html = `
|
|
122
|
+
<html>
|
|
123
|
+
<body>
|
|
124
|
+
<header><h1>Title</h1></header>
|
|
125
|
+
<nav><a href="/a">A</a></nav>
|
|
126
|
+
<main><p>Content</p></main>
|
|
127
|
+
<aside><p>Side</p></aside>
|
|
128
|
+
<footer><p>Foot</p></footer>
|
|
129
|
+
</body>
|
|
130
|
+
</html>
|
|
131
|
+
`;
|
|
132
|
+
const $ = cheerio.load(html);
|
|
133
|
+
|
|
134
|
+
const chunks = await chunkBySection($);
|
|
135
|
+
|
|
136
|
+
expect(chunks.header).toContain('Title');
|
|
137
|
+
expect(chunks.nav).toContain('href="/a"');
|
|
138
|
+
expect(chunks.main).toContain('Content');
|
|
139
|
+
expect(chunks.aside).toContain('Side');
|
|
140
|
+
expect(chunks.footer).toContain('Foot');
|
|
141
|
+
expect(chunks.body).toContain('<header>');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('honors the focus list and only returns requested keys', async () => {
|
|
145
|
+
const html = `
|
|
146
|
+
<body>
|
|
147
|
+
<header>H</header>
|
|
148
|
+
<main>M</main>
|
|
149
|
+
<footer>F</footer>
|
|
150
|
+
</body>
|
|
151
|
+
`;
|
|
152
|
+
const $ = cheerio.load(html);
|
|
153
|
+
|
|
154
|
+
const chunks = await chunkBySection($, ['main', 'footer']);
|
|
155
|
+
|
|
156
|
+
expect(Object.keys(chunks).sort()).toEqual(['footer', 'main']);
|
|
157
|
+
expect(chunks.main).toContain('M');
|
|
158
|
+
expect(chunks.footer).toContain('F');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('reduceRepeatedDivs', () => {
|
|
163
|
+
it('reduces repeated divs by class and appends a collapsed marker', () => {
|
|
164
|
+
const html = `
|
|
165
|
+
<div id="root">
|
|
166
|
+
<div class="card">1</div>
|
|
167
|
+
<div class="card">2</div>
|
|
168
|
+
<div class="card">3</div>
|
|
169
|
+
<div class="card">4</div>
|
|
170
|
+
<div class="other">X</div>
|
|
171
|
+
</div>
|
|
172
|
+
`;
|
|
173
|
+
const $ = cheerio.load(html);
|
|
174
|
+
|
|
175
|
+
reduceRepeatedDivs($, 2);
|
|
176
|
+
|
|
177
|
+
const rootChildren = $('#root').children('div');
|
|
178
|
+
const cardDivs = $('#root').children('div.card');
|
|
179
|
+
const collapsedMarkers = $('#root').children('div[data-collapsed="true"]');
|
|
180
|
+
|
|
181
|
+
// Only 2 card divs should remain
|
|
182
|
+
expect(cardDivs.length).toBe(2);
|
|
183
|
+
expect(cardDivs.eq(0).text()).toBe('1');
|
|
184
|
+
expect(cardDivs.eq(1).text()).toBe('2');
|
|
185
|
+
|
|
186
|
+
// One collapsed marker added for the "card" class
|
|
187
|
+
expect(collapsedMarkers.length).toBe(1);
|
|
188
|
+
expect(collapsedMarkers.attr('data-class')).toBe('card');
|
|
189
|
+
|
|
190
|
+
// "other" div should still be present
|
|
191
|
+
const other = $('#root').children('div.other');
|
|
192
|
+
expect(other.length).toBe(1);
|
|
193
|
+
expect(other.text().trim()).toBe('X');
|
|
194
|
+
|
|
195
|
+
// Total children: 2 cards + 1 other + 1 marker
|
|
196
|
+
expect(rootChildren.length).toBe(4);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('removeObfuscatedClassNames', () => {
|
|
201
|
+
it('removes short and hashed-looking class names', () => {
|
|
202
|
+
const html = `
|
|
203
|
+
<div id="foo" class="btn a abcd12 AbCdE1 custom-class"></div>
|
|
204
|
+
`;
|
|
205
|
+
const $ = cheerio.load(html);
|
|
206
|
+
|
|
207
|
+
removeObfuscatedClassNames($);
|
|
208
|
+
|
|
209
|
+
const classAttr = $('#foo').attr('class') || '';
|
|
210
|
+
const classes = classAttr.split(/\s+/).filter(Boolean);
|
|
211
|
+
|
|
212
|
+
// 'btn' + 'custom-class' should remain, short "a" and hashed ones removed
|
|
213
|
+
expect(classes).toContain('btn');
|
|
214
|
+
expect(classes).toContain('custom-class');
|
|
215
|
+
expect(classes).not.toContain('a');
|
|
216
|
+
expect(classes).not.toContain('abcd12');
|
|
217
|
+
expect(classes).not.toContain('AbCdE1');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('removes class attribute entirely if nothing remains', () => {
|
|
221
|
+
const html = `<div id="bar" class="a abcd12 AbCdE1"></div>`;
|
|
222
|
+
const $ = cheerio.load(html);
|
|
223
|
+
|
|
224
|
+
removeObfuscatedClassNames($);
|
|
225
|
+
|
|
226
|
+
expect($('#bar').attr('class')).toBeUndefined();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('removeAutoGeneratedClasses', () => {
|
|
231
|
+
it('removes known utility classes while keeping semantic ones', () => {
|
|
232
|
+
const html = `
|
|
233
|
+
<button
|
|
234
|
+
id="btn"
|
|
235
|
+
class="text-sm bg-red-500 hover:bg-red-600 flex items-center btn-primary custom-class"
|
|
236
|
+
>
|
|
237
|
+
Click
|
|
238
|
+
</button>
|
|
239
|
+
`;
|
|
240
|
+
const $ = cheerio.load(html);
|
|
241
|
+
|
|
242
|
+
removeAutoGeneratedClasses($);
|
|
243
|
+
|
|
244
|
+
const classAttr = $('#btn').attr('class') || '';
|
|
245
|
+
const classes = classAttr.split(/\s+/).filter(Boolean);
|
|
246
|
+
|
|
247
|
+
// Utility classes should be gone
|
|
248
|
+
expect(classes).not.toContain('text-sm');
|
|
249
|
+
expect(classes).not.toContain('bg-red-500');
|
|
250
|
+
expect(classes).not.toContain('hover:bg-red-600');
|
|
251
|
+
expect(classes).not.toContain('flex');
|
|
252
|
+
expect(classes).not.toContain('items-center');
|
|
253
|
+
|
|
254
|
+
// Semantic classes should remain
|
|
255
|
+
expect(classes).toContain('btn-primary');
|
|
256
|
+
expect(classes).toContain('custom-class');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('removes class attribute entirely if all classes are utilities', () => {
|
|
260
|
+
const html = `<div id="box" class="flex items-center text-sm"></div>`;
|
|
261
|
+
const $ = cheerio.load(html);
|
|
262
|
+
|
|
263
|
+
removeAutoGeneratedClasses($);
|
|
264
|
+
|
|
265
|
+
expect($('#box').attr('class')).toBeUndefined();
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('minifyDomWhitespace', () => {
|
|
270
|
+
it('collapses whitespace in text nodes while preserving words', () => {
|
|
271
|
+
const html = `
|
|
272
|
+
<div id="content">
|
|
273
|
+
Hello
|
|
274
|
+
world,
|
|
275
|
+
this is
|
|
276
|
+
a test.
|
|
277
|
+
</div>
|
|
278
|
+
`;
|
|
279
|
+
const $ = cheerio.load(html);
|
|
280
|
+
|
|
281
|
+
minifyDomWhitespace($);
|
|
282
|
+
|
|
283
|
+
const text = $('#content').text();
|
|
284
|
+
// We only care that internal runs are collapsed and words are separated
|
|
285
|
+
expect(text).toContain('Hello world, this is a test.');
|
|
286
|
+
// No stray newlines or multiple spaces
|
|
287
|
+
expect(/\s{2,}/.test(text)).toBe(false);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('preserves whitespace inside pre/code/textarea', () => {
|
|
291
|
+
const html = `
|
|
292
|
+
<pre id="code-block">
|
|
293
|
+
line 1
|
|
294
|
+
line 2 with spaces
|
|
295
|
+
</pre>
|
|
296
|
+
<textarea id="ta">
|
|
297
|
+
line A
|
|
298
|
+
line B
|
|
299
|
+
</textarea>
|
|
300
|
+
`;
|
|
301
|
+
const $ = cheerio.load(html);
|
|
302
|
+
|
|
303
|
+
const beforePre = $('#code-block').text();
|
|
304
|
+
const beforeTa = $('#ta').text();
|
|
305
|
+
|
|
306
|
+
minifyDomWhitespace($);
|
|
307
|
+
|
|
308
|
+
const afterPre = $('#code-block').text();
|
|
309
|
+
const afterTa = $('#ta').text();
|
|
310
|
+
|
|
311
|
+
expect(afterPre).toBe(beforePre);
|
|
312
|
+
expect(afterTa).toBe(beforeTa);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('keeps at least one space for pure-whitespace text nodes', () => {
|
|
316
|
+
const html = `<p>Foo <span>bar</span> baz</p>`;
|
|
317
|
+
const $ = cheerio.load(html);
|
|
318
|
+
|
|
319
|
+
minifyDomWhitespace($);
|
|
320
|
+
|
|
321
|
+
const text = $('p').text();
|
|
322
|
+
// We don’t want "Foobarbaz"
|
|
323
|
+
expect(text.includes('Foo')).toBe(true);
|
|
324
|
+
expect(text.includes('bar')).toBe(true);
|
|
325
|
+
expect(text.includes('baz')).toBe(true);
|
|
326
|
+
expect(text).toMatch(/Foo.*bar.*baz/);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
});
|
|
@@ -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
|
+
});
|
package/tools/chromeBrowser.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
removeAutoGeneratedClasses,
|
|
18
18
|
removeNonContentTags,
|
|
19
19
|
removeObfuscatedClassNames,
|
|
20
|
+
minifyDomWhitespace
|
|
20
21
|
} from './domControl.js';
|
|
21
22
|
import fs from 'fs';
|
|
22
23
|
import path from 'path';
|
|
@@ -870,6 +871,9 @@ export class ChromeBrowser {
|
|
|
870
871
|
// $ = await removeAutoGeneratedClasses($);
|
|
871
872
|
}
|
|
872
873
|
|
|
874
|
+
// Minify whitespace *after* structural cleanup
|
|
875
|
+
$ = minifyDomWhitespace($);
|
|
876
|
+
|
|
873
877
|
// (Optional: If you'd like to use `focus`, you could filter here by selectors/keywords.)
|
|
874
878
|
|
|
875
879
|
// --- 5) Return trimmed HTML ---
|