testronaut 2.1.1 → 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 +1 -1
- package/tests/toolsTests/chromeBrowserClickText.test.js +40 -0
- package/tests/toolsTests/chromeBrowserGetDomFrames.test.js +33 -0
- package/tests/toolsTests/domControl.test.js +1 -1
- package/tests/toolsTests/toolSchema.test.js +8 -0
- package/tools/chromeBrowser.js +90 -20
- package/tools/domControl.js +5 -3
- package/tools/toolSchema.js +7 -0
package/package.json
CHANGED
|
@@ -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).
|
|
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');
|
package/tools/chromeBrowser.js
CHANGED
|
@@ -180,8 +180,13 @@ export class ChromeBrowser {
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
// Build candidate locator MAKERS (Page|Frame-aware)
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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 =
|
|
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-
|
|
503
|
-
|
|
504
|
-
return makers
|
|
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
|
|
1146
|
-
const
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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}`;
|
package/tools/domControl.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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,
|
|
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
|
];
|
package/tools/toolSchema.js
CHANGED
|
@@ -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
|
},
|