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.
@@ -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&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
+ });
@@ -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 ---