testronaut 1.0.20 → 1.0.22
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 +15 -1
- package/package.json +1 -1
- package/tools/chromeBrowser.js +124 -2
- package/tools/toolSchema.js +53 -1
package/bin/cli.js
CHANGED
|
@@ -54,6 +54,20 @@ Examples:
|
|
|
54
54
|
if (args.includes('--init')) {
|
|
55
55
|
await initializeTestronautProject();
|
|
56
56
|
await createWelcomeMission();
|
|
57
|
+
|
|
58
|
+
console.log(`
|
|
59
|
+
✅ Project initialized!
|
|
60
|
+
|
|
61
|
+
Next steps:
|
|
62
|
+
1. Get an API key from your AI provider
|
|
63
|
+
2. Add it to your .env file as OPENAI_API_KEY
|
|
64
|
+
3. Run your first mission:
|
|
65
|
+
npx testronaut
|
|
66
|
+
|
|
67
|
+
📚 Docs: https://docs.testronaut.app/docs/guides/cli-auth
|
|
68
|
+
`);
|
|
69
|
+
|
|
70
|
+
|
|
57
71
|
process.exit(0);
|
|
58
72
|
}
|
|
59
73
|
|
|
@@ -499,7 +513,7 @@ async function serveLatestReport() {
|
|
|
499
513
|
await new Promise((resolve, reject) => {
|
|
500
514
|
server.once('error', reject);
|
|
501
515
|
server.listen(0, '127.0.0.1', resolve);
|
|
502
|
-
|
|
516
|
+
|
|
503
517
|
process.on('SIGINT', () => {
|
|
504
518
|
console.log('\n🛑 Shutting down…');
|
|
505
519
|
server.close(() => {
|
package/package.json
CHANGED
package/tools/chromeBrowser.js
CHANGED
|
@@ -12,13 +12,76 @@ import fs from 'fs';
|
|
|
12
12
|
export class ChromeBrowser {
|
|
13
13
|
constructor() {
|
|
14
14
|
this.browser = null;
|
|
15
|
+
this.context = null;
|
|
15
16
|
this.page = null;
|
|
17
|
+
|
|
18
|
+
// simple registry
|
|
19
|
+
this.pages = []; // [{ id, page }]
|
|
20
|
+
this.currentId = ''; // page id we currently control
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
_makePageId(page) {
|
|
24
|
+
// playwright's Page has a non-public guid in TS; fallback to random
|
|
25
|
+
return `p_${Math.random().toString(36).slice(2)}`;
|
|
26
|
+
}
|
|
27
|
+
_getCurrent() {
|
|
28
|
+
const cur = this.pages.find(p => p.id === this.currentId) ?? this.pages[this.pages.length - 1];
|
|
29
|
+
if (!cur) throw new Error('No active page in registry');
|
|
30
|
+
return cur;
|
|
31
|
+
}
|
|
32
|
+
_addAndFocus(page) {
|
|
33
|
+
const id = this._makePageId(page);
|
|
34
|
+
this.pages.push({ id, page });
|
|
35
|
+
this.currentId = id;
|
|
36
|
+
this.page = page;
|
|
37
|
+
|
|
38
|
+
page.on('close', () => {
|
|
39
|
+
// remove from registry
|
|
40
|
+
this.pages = this.pages.filter(p => p.id !== id);
|
|
41
|
+
// if we just lost the active page, pick a reasonable fallback
|
|
42
|
+
if (this.currentId === id) {
|
|
43
|
+
this.currentId = this.pages[this.pages.length - 1]?.id
|
|
44
|
+
?? this.pages[0]?.id
|
|
45
|
+
?? '';
|
|
46
|
+
this.page = this.currentId
|
|
47
|
+
? this.pages.find(p => p.id === this.currentId).page
|
|
48
|
+
: null;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return id;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_switchTo(target) {
|
|
56
|
+
if (target === 'main') {
|
|
57
|
+
this.currentId = this.pages[0]?.id ?? '';
|
|
58
|
+
} else if (target === 'latest') {
|
|
59
|
+
this.currentId = this.pages[this.pages.length - 1]?.id ?? '';
|
|
60
|
+
} else {
|
|
61
|
+
const found = this.pages.find(p => p.id === target);
|
|
62
|
+
if (!found) throw new Error(`No page with id "${target}"`);
|
|
63
|
+
this.currentId = found.id;
|
|
64
|
+
}
|
|
65
|
+
const cur = this._getCurrent();
|
|
66
|
+
this.page = cur.page; // keep legacy this.page in sync
|
|
67
|
+
return this.currentId;
|
|
16
68
|
}
|
|
17
69
|
|
|
18
70
|
async start() {
|
|
19
71
|
this.browser = await chromium.launch({ headless: true });
|
|
20
|
-
|
|
21
|
-
|
|
72
|
+
this.context = await this.browser.newContext();
|
|
73
|
+
const first = await this.context.newPage();
|
|
74
|
+
const id = this._addAndFocus(first);
|
|
75
|
+
|
|
76
|
+
// optional: keep registry in sync if pages are closed elsewhere
|
|
77
|
+
this.context.on('close', () => { this.pages = []; this.currentId = ''; });
|
|
78
|
+
first.on('close', () => {
|
|
79
|
+
this.pages = this.pages.filter(p => p.id !== id);
|
|
80
|
+
if (this.currentId === id) {
|
|
81
|
+
this.currentId = this.pages[this.pages.length - 1]?.id ?? this.pages[0]?.id ?? '';
|
|
82
|
+
this.page = this._getCurrent()?.page ?? null;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
22
85
|
}
|
|
23
86
|
|
|
24
87
|
async navigate({ url }) {
|
|
@@ -125,6 +188,62 @@ export class ChromeBrowser {
|
|
|
125
188
|
}
|
|
126
189
|
|
|
127
190
|
|
|
191
|
+
async click_and_follow_popup({ selector, expectedUrlHost, timeoutMs = 20_000 }) {
|
|
192
|
+
const cur = this._getCurrent(); // { id, page }
|
|
193
|
+
await cur.page.waitForSelector(selector, { timeout: 30_000 });
|
|
194
|
+
|
|
195
|
+
// Two possibilities:
|
|
196
|
+
// 1) Popup opens (context 'page' event)
|
|
197
|
+
// 2) Same-tab navigation (no popup)
|
|
198
|
+
const waitPopup = this.context.waitForEvent('page', { timeout: timeoutMs }).catch(() => null);
|
|
199
|
+
const waitNav = cur.page.waitForNavigation({ timeout: timeoutMs }).catch(() => null);
|
|
200
|
+
|
|
201
|
+
// Trigger the click; whichever event resolves first will "win"
|
|
202
|
+
await Promise.allSettled([
|
|
203
|
+
(async () => { await cur.page.click(selector); })(),
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
const result = await Promise.race([waitPopup, waitNav]);
|
|
207
|
+
|
|
208
|
+
// Popup case: result is a Page (has .url() function)
|
|
209
|
+
if (result && typeof result.url === 'function') {
|
|
210
|
+
const popup = result;
|
|
211
|
+
await popup.waitForLoadState('domcontentloaded', { timeout: timeoutMs }).catch(() => {});
|
|
212
|
+
if (expectedUrlHost) {
|
|
213
|
+
try {
|
|
214
|
+
await popup.waitForURL(u => new URL(u).host.includes(expectedUrlHost), { timeout: timeoutMs });
|
|
215
|
+
} catch {/* best-effort only */}
|
|
216
|
+
}
|
|
217
|
+
const id = this._addAndFocus(popup);
|
|
218
|
+
return { ok: true, pageId: id, url: popup.url(), note: 'Switched to popup' };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Same-tab case: stay on current page but ensure DOM is ready
|
|
222
|
+
await cur.page.waitForLoadState('domcontentloaded', { timeout: timeoutMs }).catch(() => {});
|
|
223
|
+
return { ok: true, pageId: this.currentId, url: cur.page.url(), note: 'No popup; same tab' };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async switch_to_page({ target }) {
|
|
227
|
+
const id = this._switchTo(target); // 'main' | 'latest' | explicit id
|
|
228
|
+
const cur = this._getCurrent();
|
|
229
|
+
return { ok: true, pageId: id, url: cur.page.url() };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async close_current_page() {
|
|
233
|
+
const cur = this._getCurrent();
|
|
234
|
+
const closingId = cur.id;
|
|
235
|
+
await cur.page.close().catch(() => {});
|
|
236
|
+
|
|
237
|
+
this.pages = this.pages.filter(p => p.id !== closingId);
|
|
238
|
+
// choose a new current (latest, or main)
|
|
239
|
+
this.currentId = this.pages[this.pages.length - 1]?.id ?? this.pages[0]?.id ?? '';
|
|
240
|
+
this.page = this._getCurrent()?.page ?? null;
|
|
241
|
+
|
|
242
|
+
if (!this.currentId) return { ok: true, note: 'Closed last page; no pages remain' };
|
|
243
|
+
return { ok: true, pageId: this.currentId, note: 'Closed page and switched to another open page' };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
128
247
|
// async get_dom_focus_hint({ prompt }) {
|
|
129
248
|
// const lower = prompt.toLowerCase();
|
|
130
249
|
// const focus = [];
|
|
@@ -179,4 +298,7 @@ export const CHROME_TOOL_MAP = {
|
|
|
179
298
|
},
|
|
180
299
|
expand_menu: (b, args) => b.expand_menu(args),
|
|
181
300
|
screenshot: (b, args) => b.screenshot(args),
|
|
301
|
+
click_and_follow_popup: (b, args) => b.click_and_follow_popup(args),
|
|
302
|
+
switch_to_page: (b, args) => b.switch_to_page(args),
|
|
303
|
+
close_current_page: (b, args) => b.close_current_page(args),
|
|
182
304
|
};
|
package/tools/toolSchema.js
CHANGED
|
@@ -32,7 +32,7 @@ const toolsSchema = [
|
|
|
32
32
|
type: 'function',
|
|
33
33
|
function: {
|
|
34
34
|
name: 'click',
|
|
35
|
-
description: 'Click a button or link by CSS selector',
|
|
35
|
+
description: 'Click a button or link by CSS selector. If this click opens an OAuth window/new tab, prefer using click_and_follow_popup instead.',
|
|
36
36
|
parameters: {
|
|
37
37
|
type: 'object',
|
|
38
38
|
properties: {
|
|
@@ -172,7 +172,59 @@ const toolsSchema = [
|
|
|
172
172
|
required: [],
|
|
173
173
|
},
|
|
174
174
|
},
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
type: 'function',
|
|
178
|
+
function: {
|
|
179
|
+
name: 'click_and_follow_popup',
|
|
180
|
+
description:
|
|
181
|
+
'Click a selector that opens a new tab/window (e.g., OAuth) and switch control to it. Falls back to same-tab navigation if no popup appears.',
|
|
182
|
+
parameters: {
|
|
183
|
+
type: 'object',
|
|
184
|
+
properties: {
|
|
185
|
+
selector: { type: 'string', description: 'CSS or text selector to click' },
|
|
186
|
+
expectedUrlHost: {
|
|
187
|
+
type: 'string',
|
|
188
|
+
description: "Optional hostname hint like 'accounts.google.com'"
|
|
189
|
+
},
|
|
190
|
+
timeoutMs: {
|
|
191
|
+
type: 'number',
|
|
192
|
+
default: 20000,
|
|
193
|
+
description: 'How long to wait for the popup or navigation'
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
required: ['selector']
|
|
197
|
+
}
|
|
175
198
|
}
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
type: 'function',
|
|
202
|
+
function: {
|
|
203
|
+
name: 'switch_to_page',
|
|
204
|
+
description:
|
|
205
|
+
"Switch agent focus between known tabs/pages (e.g., back to 'main' or to the latest popup). Use a pageId previously returned by click_and_follow_popup to target a specific tab.",
|
|
206
|
+
parameters: {
|
|
207
|
+
type: 'object',
|
|
208
|
+
properties: {
|
|
209
|
+
target: {
|
|
210
|
+
type: 'string',
|
|
211
|
+
description: "One of: 'main', 'latest', or a pageId returned by click_and_follow_popup"
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
required: ['target']
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
type: 'function',
|
|
220
|
+
function: {
|
|
221
|
+
name: 'close_current_page',
|
|
222
|
+
description:
|
|
223
|
+
'Close the current tab/page and automatically switch back to another open page if available.',
|
|
224
|
+
parameters: { type: 'object', properties: {} }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
176
228
|
|
|
177
229
|
|
|
178
230
|
];
|