testronaut 1.0.20 → 1.0.21

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": "1.0.20",
3
+ "version": "1.0.21",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "./bin/cli.js"
@@ -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
- const context = await this.browser.newContext();
21
- this.page = await context.newPage();
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
  };
@@ -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
  ];