testronaut 2.1.0 → 2.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "bin/cli.js"
@@ -69,4 +69,44 @@ describe('ChromeBrowser.check_text', () => {
69
69
 
70
70
  await expect(browser.check_text({ text: 'Hidden message' })).resolves.toBe('NOT FOUND: Hidden message');
71
71
  });
72
+
73
+ it('reports the iframe containing a visible match', async () => {
74
+ const noMatches = { count: vi.fn().mockResolvedValue(0) };
75
+ const frameMatches = {
76
+ count: vi.fn().mockResolvedValue(1),
77
+ nth: vi.fn().mockReturnValue({ isVisible: vi.fn().mockResolvedValue(true) }),
78
+ };
79
+ const mainFrame = { getByText: vi.fn().mockReturnValue(noMatches) };
80
+ const childFrame = {
81
+ getByText: vi.fn().mockReturnValue(frameMatches),
82
+ name: vi.fn().mockReturnValue(''),
83
+ frameElement: vi.fn().mockResolvedValue({ getAttribute: vi.fn().mockResolvedValue('webviewer-1') }),
84
+ url: vi.fn().mockReturnValue('https://example.test/viewer'),
85
+ };
86
+ const browser = Object.create(ChromeBrowser.prototype);
87
+ browser.page = {
88
+ mainFrame: vi.fn().mockReturnValue(mainFrame),
89
+ frames: vi.fn().mockReturnValue([mainFrame, childFrame]),
90
+ };
91
+
92
+ await expect(browser.check_text({ text: 'Quarterly report' }))
93
+ .resolves.toBe('FOUND in frame webviewer-1: Quarterly report');
94
+ });
95
+ });
96
+
97
+ describe('ChromeBrowser iframe interaction targeting', () => {
98
+ it('builds an explicit frame-qualified click locator', () => {
99
+ const locator = { first: vi.fn().mockReturnThis() };
100
+ const frameContext = { locator: vi.fn().mockReturnValue(locator) };
101
+ const browser = Object.create(ChromeBrowser.prototype);
102
+ browser.page = { frameLocator: vi.fn().mockReturnValue(frameContext) };
103
+
104
+ const [make] = browser._buildClickCandidates({
105
+ frame: 'iframe#webviewer-1', selector: 'button[aria-label="Settings"]', searchAllFrames: true,
106
+ });
107
+
108
+ expect(make()).toBe(locator);
109
+ expect(browser.page.frameLocator).toHaveBeenCalledWith('iframe#webviewer-1');
110
+ expect(frameContext.locator).toHaveBeenCalledWith('button[aria-label="Settings"]');
111
+ });
72
112
  });
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { ChromeBrowser } from '../../tools/chromeBrowser.js';
3
+
4
+ describe('ChromeBrowser.get_dom iframe serialization', () => {
5
+ it('keeps serialized same-origin content and inaccessible-frame markers', async () => {
6
+ const browser = Object.create(ChromeBrowser.prototype);
7
+ browser.domListLimit = 3;
8
+ browser.debugEnabled = false;
9
+ browser.context = null;
10
+ browser.page = {
11
+ isClosed: vi.fn().mockReturnValue(false),
12
+ waitForLoadState: vi.fn().mockResolvedValue(undefined),
13
+ content: vi.fn().mockResolvedValue('<html><body></body></html>'),
14
+ evaluate: vi.fn().mockResolvedValue(`
15
+ <html><body>
16
+ <testronaut-frame id="viewer" data-testronaut-frame="viewer" data-testronaut-frame-status="same-origin">
17
+ <html><body><button aria-label="Settings">Settings</button><p>Quarterly report</p></body></html>
18
+ </testronaut-frame>
19
+ <testronaut-frame id="payment" data-testronaut-frame="payment" data-testronaut-frame-status="cross-origin">
20
+ [cross-origin frame, content not accessible]
21
+ </testronaut-frame>
22
+ </body></html>`),
23
+ };
24
+
25
+ const result = await browser.get_dom();
26
+
27
+ expect(result).toContain('data-testronaut-frame="viewer"');
28
+ expect(result).toContain('<iframe');
29
+ expect(result).toContain('Quarterly report');
30
+ expect(result).toContain('aria-label="Settings"');
31
+ expect(result).toContain('[cross-origin frame, content not accessible]');
32
+ });
33
+ });
@@ -56,7 +56,7 @@ describe('domControl helpers', () => {
56
56
  expect(result).not.toContain('<style');
57
57
  expect(result).not.toContain('<meta');
58
58
  expect(result).not.toContain('<link');
59
- expect(result).not.toContain('<iframe');
59
+ expect(result).toContain('<iframe src="frame.html"></iframe>');
60
60
  expect(result).not.toContain('<canvas');
61
61
  expect(result).not.toContain('<svg');
62
62
  expect(result).not.toContain('<!-- comment -->');
@@ -21,6 +21,14 @@ describe('tools/toolSchema', () => {
21
21
  const entry = toolsSchema.find(tool => tool.function.name === 'get_dom');
22
22
  expect(entry.function.parameters.properties.selector.type).toBe('string');
23
23
  });
24
+ it('exposes iframe targeting and iframe text search', () => {
25
+ const fill = toolsSchema.find(tool => tool.function.name === 'fill');
26
+ const click = toolsSchema.find(tool => tool.function.name === 'click');
27
+ const checkText = toolsSchema.find(tool => tool.function.name === 'check_text');
28
+ expect(fill.function.parameters.properties.frame.type).toBe('string');
29
+ expect(click.function.parameters.properties.frame.type).toBe('string');
30
+ expect(checkText.function.parameters.properties.searchAllFrames.default).toBe(true);
31
+ });
24
32
  it('includes resource_progress tool with empty params', () => {
25
33
  const entry = toolsSchema.find(t => t.function?.name === 'resource_progress');
26
34
  expect(entry?.type).toBe('function');
@@ -180,8 +180,13 @@ export class ChromeBrowser {
180
180
  }
181
181
 
182
182
  // Build candidate locator MAKERS (Page|Frame-aware)
183
- _buildClickCandidates({ selector, text, role, attrs, scope, searchAllFrames }) {
184
- const frames = searchAllFrames ? this._allFrames({ includeMain: true }) : [this.page.mainFrame()];
183
+ _interactionContexts(frame, searchAllFrames) {
184
+ if (frame) return [this.page.frameLocator(frame)];
185
+ return searchAllFrames ? this._allFrames({ includeMain: true }) : [this.page.mainFrame()];
186
+ }
187
+
188
+ _buildClickCandidates({ selector, text, role, attrs, scope, searchAllFrames, frame }) {
189
+ const frames = this._interactionContexts(frame, searchAllFrames);
185
190
  const makers = [];
186
191
  const add = (fn) => fn && makers.push(fn);
187
192
 
@@ -211,8 +216,9 @@ export class ChromeBrowser {
211
216
  }
212
217
  }
213
218
 
214
- const seen = new Set();
215
- return makers.filter(fn => { const k = fn.toString(); if (seen.has(k)) return false; seen.add(k); return true; });
219
+ // Do not de-duplicate by function source: closures for different frames have
220
+ // identical source text but resolve against different documents.
221
+ return makers;
216
222
  }
217
223
 
218
224
 
@@ -459,8 +465,8 @@ export class ChromeBrowser {
459
465
  }
460
466
 
461
467
  // Build candidates for text entry (Page|Frame-aware)
462
- _buildFillCandidates({ selector, placeholder, role = 'textbox', attrs, scope, searchAllFrames }) {
463
- const frames = searchAllFrames ? this._allFrames({ includeMain: true }) : [this.page.mainFrame()];
468
+ _buildFillCandidates({ selector, placeholder, role = 'textbox', attrs, scope, searchAllFrames, frame }) {
469
+ const frames = this._interactionContexts(frame, searchAllFrames);
464
470
  const makers = [];
465
471
  const add = (fn) => fn && makers.push(fn);
466
472
 
@@ -499,9 +505,9 @@ export class ChromeBrowser {
499
505
  }
500
506
  }
501
507
 
502
- // de-dup
503
- const seen = new Set();
504
- return makers.filter(fn => { const k = fn.toString(); if (seen.has(k)) return false; seen.add(k); return true; });
508
+ // Do not de-duplicate by function source: closures for different frames have
509
+ // identical source text but resolve against different documents.
510
+ return makers;
505
511
  }
506
512
 
507
513
 
@@ -714,6 +720,7 @@ export class ChromeBrowser {
714
720
  async fill({
715
721
  selector,
716
722
  text,
723
+ frame,
717
724
  placeholder, // string | RegExp
718
725
  role = 'textbox', // generic default
719
726
  attrs,
@@ -729,9 +736,9 @@ export class ChromeBrowser {
729
736
 
730
737
  const makeCandidates = () => {
731
738
  if (strategy === 'strict') {
732
- return this._buildFillCandidates({ selector, scope, searchAllFrames });
739
+ return this._buildFillCandidates({ selector, scope, searchAllFrames, frame });
733
740
  }
734
- return this._buildFillCandidates({ selector, placeholder, role, attrs, scope, searchAllFrames });
741
+ return this._buildFillCandidates({ selector, placeholder, role, attrs, scope, searchAllFrames, frame });
735
742
  };
736
743
 
737
744
  return this._withRetries(async () => {
@@ -778,7 +785,7 @@ export class ChromeBrowser {
778
785
  * @param {number} [opts.delayMs=200] // micro settle
779
786
  */
780
787
  async click({
781
- selector, text, role, attrs, scope,
788
+ selector, text, role, attrs, scope, frame,
782
789
  searchAllFrames = true,
783
790
  strategy = 'auto',
784
791
  waitFor,
@@ -794,8 +801,8 @@ export class ChromeBrowser {
794
801
 
795
802
  // Build your candidates the way you already do (generic/hint-driven)
796
803
  const makers = (strategy === 'strict')
797
- ? this._buildClickCandidates({ selector, scope, searchAllFrames })
798
- : this._buildClickCandidates({ selector, text, role, attrs, scope, searchAllFrames });
804
+ ? this._buildClickCandidates({ selector, scope, searchAllFrames, frame })
805
+ : this._buildClickCandidates({ selector, text, role, attrs, scope, searchAllFrames, frame });
799
806
 
800
807
  // choose & click (same as your generic version)
801
808
  let chosen = null;
@@ -1014,9 +1021,57 @@ export class ChromeBrowser {
1014
1021
  // Fallback to empty string if still nothing (keeps caller logic simple)
1015
1022
  if (!html) html = '';
1016
1023
 
1024
+ // page.content() only serializes iframe elements, not their documents. Build a
1025
+ // read-only clone that embeds accessible frame documents so selectors and the
1026
+ // global output limit apply to the complete snapshot.
1027
+ try {
1028
+ html = await this.page.evaluate(() => {
1029
+ const serializeDocument = (doc) => {
1030
+ const clone = doc.documentElement.cloneNode(true);
1031
+ const sourceFrames = Array.from(doc.querySelectorAll('iframe'));
1032
+ const clonedFrames = Array.from(clone.querySelectorAll('iframe'));
1033
+
1034
+ sourceFrames.forEach((sourceFrame, index) => {
1035
+ const clonedFrame = clonedFrames[index];
1036
+ if (!clonedFrame) return;
1037
+ // iframe is a raw-text element in HTML parsers. Use a temporary
1038
+ // custom element so Cheerio parses the embedded controls as markup.
1039
+ const serializedFrame = doc.createElement('testronaut-frame');
1040
+ for (const attribute of Array.from(clonedFrame.attributes)) {
1041
+ serializedFrame.setAttribute(attribute.name, attribute.value);
1042
+ }
1043
+ clonedFrame.replaceWith(serializedFrame);
1044
+ const label = sourceFrame.id || sourceFrame.name || sourceFrame.getAttribute('src') || `frame-${index + 1}`;
1045
+ serializedFrame.setAttribute('data-testronaut-frame', label);
1046
+ try {
1047
+ const childDocument = sourceFrame.contentDocument;
1048
+ if (!childDocument?.documentElement) throw new Error('inaccessible');
1049
+ serializedFrame.setAttribute('data-testronaut-frame-status', 'same-origin');
1050
+ serializedFrame.innerHTML = serializeDocument(childDocument);
1051
+ } catch {
1052
+ serializedFrame.setAttribute('data-testronaut-frame-status', 'cross-origin');
1053
+ serializedFrame.textContent = '[cross-origin frame, content not accessible]';
1054
+ }
1055
+ });
1056
+
1057
+ return clone.outerHTML;
1058
+ };
1059
+ return serializeDocument(document);
1060
+ });
1061
+ } catch {
1062
+ // Preserve the main-document snapshot if serialization races a navigation.
1063
+ }
1064
+
1017
1065
  // --- 4) Optional DOM reduction/cleanup (your existing steps) ---
1018
1066
  let $ = cheerio.load(html);
1019
1067
 
1068
+ // Restore the public marker shape after the HTML parser has materialized
1069
+ // the temporary element's descendants.
1070
+ $('testronaut-frame').each((_, element) => {
1071
+ element.name = 'iframe';
1072
+ element.tagName = 'iframe';
1073
+ });
1074
+
1020
1075
  if (selector) {
1021
1076
  let selection;
1022
1077
  try {
@@ -1141,12 +1196,27 @@ export class ChromeBrowser {
1141
1196
  }
1142
1197
 
1143
1198
 
1144
- async check_text({ text }) {
1145
- const matches = this.page.getByText(text);
1146
- const count = await matches.count();
1147
- for (let index = 0; index < count; index++) {
1148
- if (await matches.nth(index).isVisible().catch(() => false)) {
1149
- return `FOUND: ${text}`;
1199
+ async check_text({ text, searchAllFrames = true }) {
1200
+ const mainFrame = this.page.mainFrame?.();
1201
+ const frames = searchAllFrames && this.page.frames
1202
+ ? [mainFrame, ...this.page.frames().filter(frame => frame !== mainFrame)].filter(Boolean)
1203
+ : [mainFrame || this.page];
1204
+
1205
+ for (const frame of frames) {
1206
+ const matches = frame.getByText(text);
1207
+ const count = await matches.count();
1208
+ for (let index = 0; index < count; index++) {
1209
+ if (!await matches.nth(index).isVisible().catch(() => false)) continue;
1210
+ if (frame === mainFrame || frame === this.page) return `FOUND: ${text}`;
1211
+
1212
+ let label = frame.name?.() || '';
1213
+ if (!label) {
1214
+ label = await frame.frameElement?.()
1215
+ .then(element => element.getAttribute('id'))
1216
+ .catch(() => '') || '';
1217
+ }
1218
+ label ||= frame.url?.() || 'unnamed';
1219
+ return `FOUND in frame ${label}: ${text}`;
1150
1220
  }
1151
1221
  }
1152
1222
  return `NOT FOUND: ${text}`;
@@ -7,7 +7,7 @@
7
7
  * normalize, and compress HTML so it’s cheaper and clearer for LLMs.
8
8
  *
9
9
  * Responsibilities:
10
- * - Remove non-content tags (scripts, styles, iframes, etc.) and noisy attributes.
10
+ * - Remove non-content tags (scripts, styles, etc.) and noisy attributes.
11
11
  * - Collapse overly long lists/sections into truncated markers.
12
12
  * - Reduce repeated div blocks to a representative sample.
13
13
  * - Strip obfuscated/utility CSS classes that don’t help reasoning.
@@ -23,7 +23,8 @@
23
23
 
24
24
  /**
25
25
  * Remove tags and attributes that are unlikely to help LLM reasoning.
26
- * - Strips: script, style, meta, link, noscript, iframe, canvas, svg
26
+ * - Strips: script, style, meta, link, noscript, canvas, svg
27
+ * - Keeps iframe shells and any same-origin content injected by get_dom.
27
28
  * - Keeps only a safe whitelist of attributes on remaining elements.
28
29
  * - Removes HTML comments.
29
30
  *
@@ -31,7 +32,7 @@
31
32
  * @returns {$} same Cheerio instance for chaining
32
33
  */
33
34
  export const removeNonContentTags = async ($) => {
34
- $('script, style, meta, link, noscript, iframe, canvas, svg').remove();
35
+ $('script, style, meta, link, noscript, canvas, svg').remove();
35
36
 
36
37
  $('*').each((_, el) => {
37
38
  const $el = $(el);
@@ -39,6 +40,7 @@ export const removeNonContentTags = async ($) => {
39
40
  'href', 'src', 'type', 'alt', 'title',
40
41
  'placeholder', 'name', 'id', 'class', 'value',
41
42
  'data-testid', 'disabled', 'role', 'data-state',
43
+ 'data-testronaut-frame', 'data-testronaut-frame-status',
42
44
  // keep key data-* attributes that help identify rows/items without bloating too much
43
45
  'data-type', 'data-id', 'data-title', 'data-parentid', 'data-order'
44
46
  ];
@@ -44,6 +44,7 @@ const toolsSchema = [
44
44
  properties: {
45
45
  selector: { type: 'string' },
46
46
  text: { type: 'string' },
47
+ frame: { type: 'string', description: 'Optional CSS selector for the containing iframe.' },
47
48
  },
48
49
  required: ['selector', 'text'],
49
50
  },
@@ -58,6 +59,7 @@ const toolsSchema = [
58
59
  type: 'object',
59
60
  properties: {
60
61
  selector: { type: 'string' },
62
+ frame: { type: 'string', description: 'Optional CSS selector for the containing iframe.' },
61
63
  waitFor: {
62
64
  type: 'string',
63
65
  enum: ['load', 'domcontentloaded', 'networkidle'],
@@ -108,6 +110,11 @@ const toolsSchema = [
108
110
  type: 'object',
109
111
  properties: {
110
112
  text: { type: 'string' },
113
+ searchAllFrames: {
114
+ type: 'boolean',
115
+ default: true,
116
+ description: 'Search iframe documents as well as the main document.',
117
+ },
111
118
  },
112
119
  required: ['text'],
113
120
  },