testronaut 1.0.25 → 1.0.27

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/core/turnLoop.js CHANGED
@@ -21,6 +21,14 @@ let turnTimestamps = [];
21
21
  let shouldBackoff;
22
22
  let steps = [];
23
23
 
24
+ function formatBytes(n) {
25
+ if (!Number.isFinite(n)) return `${n}`;
26
+ const u = ['B','KB','MB','GB','TB'];
27
+ let i = 0, v = n;
28
+ while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
29
+ return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${u[i]}`;
30
+ }
31
+
24
32
  const pushDOMAssistant = async (browser, messages, agentMemory, { skipIfLastTool } = {}) => {
25
33
  if (skipIfLastTool && skipIfLastTool.includes(messages.at(-1)?.name)) {
26
34
  console.log(`[skip] Skipping DOM push after redundant tool: ${messages.at(-1)?.name}`);
@@ -187,6 +195,25 @@ export const turnLoop = async (
187
195
  result = errorMessage;
188
196
  }
189
197
 
198
+ // ------ Capture file upload/download events for mission report ------
199
+ try {
200
+ const maybeJson = JSON.parse(result);
201
+ if (maybeJson && maybeJson._testronaut_file_event) {
202
+ currentStep.files = currentStep.files || [];
203
+ currentStep.files.push(maybeJson);
204
+
205
+ if (maybeJson._testronaut_file_event === 'upload') {
206
+ const msgLine = `📤 Uploaded "${maybeJson.fileName}" (${formatBytes(maybeJson.bytes)}) via ${maybeJson.method}`;
207
+ currentStep.events.push(msgLine);
208
+ console.log(msgLine);
209
+ } else if (maybeJson._testronaut_file_event === 'download') {
210
+ const msgLine = `📥 Downloaded "${maybeJson.fileName}" (${formatBytes(maybeJson.bytes)}) via ${maybeJson.mode}`;
211
+ currentStep.events.push(msgLine);
212
+ console.log(msgLine);
213
+ }
214
+ }
215
+ } catch { /* not JSON / ignore */ }
216
+
190
217
  console.log(`[tool ] ← ${fnName} result:`, errorMessage ? '❌ Failed' : '✅ Success');
191
218
  currentStep.events.push(`[tool ] ← ${fnName} result: ${errorMessage ? '❌ Failed' : '✅ Success'}`)
192
219
  const truncated = result.length > 100 ? result.slice(0, 100) + '…' : result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "1.0.25",
3
+ "version": "1.0.27",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "./bin/cli.js"
@@ -19,8 +19,26 @@ import {
19
19
  removeObfuscatedClassNames,
20
20
  } from './domControl.js';
21
21
  import fs from 'fs';
22
+ import path from 'path';
22
23
  import { ensureBrowsers } from '../tools/playwrightSetup.js';
23
24
 
25
+ const FILES_DIR = path.join('missions', 'files');
26
+ const REPORTS_DIR = path.join('missions', 'mission_reports');
27
+ const FILES_LOG = path.join(REPORTS_DIR, 'files.jsonl');
28
+
29
+ function ensureDir(p) {
30
+ try { fs.mkdirSync(p, { recursive: true }); } catch {}
31
+ }
32
+
33
+ function appendFileEvent(eventObj = {}) {
34
+ try {
35
+ ensureDir(REPORTS_DIR);
36
+ fs.appendFileSync(FILES_LOG, JSON.stringify(eventObj) + '\n', 'utf8');
37
+ } catch (e) {
38
+ console.warn('⚠️ Could not append file event:', e?.message);
39
+ }
40
+ }
41
+
24
42
  export class ChromeBrowser {
25
43
  constructor() {
26
44
  this.browser = null;
@@ -504,11 +522,24 @@ export class ChromeBrowser {
504
522
  throw new Error('Could not input text with any strategy');
505
523
  }
506
524
 
525
+ _chromiumLaunchArgsForCI() {
526
+ const inCI = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.TESTRONAUT_CI_CHROMIUM);
527
+ return inCI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [];
528
+ }
529
+
507
530
 
508
531
  async start() {
509
532
  try {
510
533
  const pw = await loadPlaywright();
511
- this.browser = await pw.chromium.launch({ headless: true });
534
+ const launchArgs = [
535
+ '--disable-dev-shm-usage',
536
+ '--disable-gpu',
537
+ ...this._chromiumLaunchArgsForCI(),
538
+ ];
539
+ this.browser = await pw.chromium.launch({
540
+ headless: true,
541
+ args: launchArgs,
542
+ });
512
543
  } catch (err) {
513
544
  const msg = String(err?.message || err);
514
545
  const needsInstall =
@@ -525,9 +556,24 @@ export class ChromeBrowser {
525
556
 
526
557
  // Retry
527
558
  const pw = await loadPlaywright();
528
- return await pw.chromium.launch({ headless: true });
559
+ const launchArgs = [
560
+ '--disable-dev-shm-usage',
561
+ '--disable-gpu',
562
+ ...this._chromiumLaunchArgsForCI(),
563
+ ];
564
+ this.browser = await pw.chromium.launch({
565
+ headless: true,
566
+ args: launchArgs,
567
+ });
568
+ // return await pw.chromium.launch({ headless: true });
529
569
  }
530
- this.context = await this.browser.newContext();
570
+
571
+ ensureDir(FILES_DIR); // ⬅️ make sure missions/files exists
572
+
573
+ this.context = await this.browser.newContext({
574
+ acceptDownloads: true // ⬅️ important for Playwright download handling
575
+ });
576
+ // this.context = await this.browser.newContext();
531
577
  const first = await this.context.newPage();
532
578
  const id = this._addAndFocus(first);
533
579
 
@@ -740,34 +786,97 @@ export class ChromeBrowser {
740
786
 
741
787
 
742
788
 
743
- async get_dom({ limit = 100000, exclude = true, focus = [] }, agentMemory = {}) {
744
- let html;
789
+ async get_dom(
790
+ { limit = 100000, exclude = true, focus = [], loadState = 'networkidle', timeout = 10000 } = {},
791
+ agentMemory = {}
792
+ ) {
793
+ // --- 1) Ensure we have a live page ---
794
+ const hasContext = !!this.context;
795
+
796
+ // Recover a page if missing/closed
797
+ const recoverPage = async () => {
798
+ if (hasContext) {
799
+ const pages = (this.context.pages?.() || []).filter(p => !p.isClosed?.());
800
+ if (pages.length) {
801
+ // Prefer the last page (often the active one / popup)
802
+ this.page = pages[pages.length - 1];
803
+ return;
804
+ }
805
+ // No pages at all in a live context -> make one
806
+ this.page = await this.context.newPage();
807
+ return;
808
+ }
809
+ // No context yet but a browser exists -> create context + page
810
+ if (this.browser?.newContext) {
811
+ this.context = await this.browser.newContext();
812
+ this.page = await this.context.newPage();
813
+ return;
814
+ }
815
+ throw new Error('No browser context/page available in get_dom().');
816
+ };
817
+
818
+ if (!this.page || this.page.isClosed?.()) {
819
+ await recoverPage();
820
+ }
821
+
822
+ // --- 2) Try to reach a usable load state (best-effort) ---
745
823
  try {
746
- html = await this.page.content();
747
- } catch (err) {
748
- console.warn('⚠️ get_dom retrying due to navigation:', err.message);
749
- // Wait for navigation to settle
750
- await this.page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
751
- html = await this.page.content();
824
+ await this.page.waitForLoadState(loadState, { timeout });
825
+ } catch {
826
+ // Non-fatal: some apps never reach 'load', we’ll still try to read the DOM.
827
+ }
828
+
829
+ // If navigation changed active page (e.g., popup), reselect a live page
830
+ try {
831
+ const pages = this.context?.pages?.() || [];
832
+ if (pages.length && (this.page.isClosed?.() || !pages.includes(this.page))) {
833
+ this.page = pages[pages.length - 1];
834
+ }
835
+ } catch { /* ignore */ }
836
+
837
+ // --- 3) Get HTML content with a small retry on failure ---
838
+ let html = '';
839
+ for (let attempt = 0; attempt < 2; attempt++) {
840
+ try {
841
+ html = await this.page.content();
842
+ if (html && html.trim()) break;
843
+ } catch (err) {
844
+ // First failure: try waiting for network to settle, then retry
845
+ if (attempt === 0) {
846
+ try { await this.page.waitForLoadState('networkidle', { timeout: 5000 }); } catch {}
847
+ // Also re-check active page (popups can swap)
848
+ try {
849
+ const pages = this.context?.pages?.() || [];
850
+ if (pages.length) this.page = pages[pages.length - 1];
851
+ } catch {}
852
+ } else {
853
+ // Second failure: bubble up a clearer message
854
+ throw new Error(`get_dom() failed to read page content: ${err?.message || err}`);
855
+ }
856
+ }
752
857
  }
858
+
859
+ // Fallback to empty string if still nothing (keeps caller logic simple)
860
+ if (!html) html = '';
861
+
862
+ // --- 4) Optional DOM reduction/cleanup (your existing steps) ---
753
863
  let $ = cheerio.load(html);
754
-
755
- // Remove non-content tags
864
+
756
865
  if (exclude) {
757
866
  $ = await removeNonContentTags($);
758
- // console.log('🔧 Non-content tags removed from DOM.');
759
867
  $ = await reduceRepeatedDivs($);
760
- // console.log('🔧 Repeated divs reduced in DOM.');
868
+ // If you later re-enable:
761
869
  // $ = await removeObfuscatedClassNames($);
762
- // console.log('🔧 Obfuscated class names removed from DOM.')
763
870
  // $ = await removeAutoGeneratedClasses($);
764
- // console.log('🔧 Auto-generated classes removed from DOM.');
765
871
  }
766
- // console.log('🔧 DOM cleaned up:', $);
872
+
873
+ // (Optional: If you'd like to use `focus`, you could filter here by selectors/keywords.)
874
+
875
+ // --- 5) Return trimmed HTML ---
767
876
  const finalHtml = $.html();
768
- const trimmed = finalHtml.slice(0, limit);
769
- return trimmed;
877
+ return finalHtml.slice(0, limit);
770
878
  }
879
+
771
880
 
772
881
  async check_text({ text }) {
773
882
  const html = await this.page.content();
@@ -906,7 +1015,127 @@ export class ChromeBrowser {
906
1015
  return `Screenshot saved at: ./screenshots/${name}_${timestamp}.png`;
907
1016
  }
908
1017
 
909
-
1018
+ async upload_file({ selector, fileName, useChooser = false, timeoutMs = 15000 }) {
1019
+ if (!selector) throw new Error('upload_file: selector is required');
1020
+ if (!fileName) throw new Error('upload_file: fileName is required');
1021
+
1022
+ ensureDir(FILES_DIR);
1023
+ const filePath = path.resolve(FILES_DIR, fileName);
1024
+ if (!fs.existsSync(filePath)) {
1025
+ throw new Error(`upload_file: file not found at ${filePath}`);
1026
+ }
1027
+
1028
+ await this.page.waitForSelector(selector, { timeout: timeoutMs });
1029
+
1030
+ let method = 'input';
1031
+ if (useChooser) {
1032
+ method = 'chooser';
1033
+ const [fileChooser] = await Promise.all([
1034
+ this.page.waitForEvent('filechooser', { timeout: timeoutMs }),
1035
+ this.page.click(selector, { timeout: timeoutMs })
1036
+ ]);
1037
+ await fileChooser.setFiles(filePath);
1038
+ } else {
1039
+ const input = this.page.locator(selector).first();
1040
+ await input.waitFor({ state: 'visible', timeout: timeoutMs }).catch(() => {});
1041
+ await input.setInputFiles(filePath, { timeout: timeoutMs });
1042
+ }
1043
+
1044
+ const stats = fs.statSync(filePath);
1045
+ const payload = {
1046
+ _testronaut_file_event: 'upload',
1047
+ method, // 'input' | 'chooser'
1048
+ fileName,
1049
+ sourcePath: filePath,
1050
+ bytes: stats.size,
1051
+ selector,
1052
+ ts: new Date().toISOString()
1053
+ };
1054
+
1055
+ appendFileEvent(payload);
1056
+ return JSON.stringify(payload);
1057
+ }
1058
+
1059
+
1060
+ async download_file({ selector, url, fileName, timeoutMs = 30000 }) {
1061
+ ensureDir(FILES_DIR);
1062
+
1063
+ if (!selector && !url) {
1064
+ throw new Error('download_file: provide either selector or url');
1065
+ }
1066
+
1067
+ const sanitize = (name) => name.replace(/[\\/:*?"<>|]+/g, '_').trim();
1068
+
1069
+ // Mode 1: page click triggers a browser download
1070
+ if (selector) {
1071
+ await this.page.waitForSelector(selector, { timeout: timeoutMs });
1072
+
1073
+ const [download] = await Promise.all([
1074
+ this.page.waitForEvent('download', { timeout: timeoutMs }),
1075
+ this.page.click(selector)
1076
+ ]);
1077
+
1078
+ const suggested = sanitize(download.suggestedFilename?.() || 'download.bin');
1079
+ const targetPath = path.join(FILES_DIR, sanitize(fileName || suggested));
1080
+ await download.saveAs(targetPath);
1081
+
1082
+ const stats = fs.statSync(targetPath);
1083
+ const payload = {
1084
+ _testronaut_file_event: 'download',
1085
+ mode: 'click',
1086
+ fileName: path.basename(targetPath),
1087
+ destPath: targetPath,
1088
+ bytes: stats.size,
1089
+ selector,
1090
+ url: null,
1091
+ ts: new Date().toISOString()
1092
+ };
1093
+
1094
+ appendFileEvent(payload);
1095
+ return JSON.stringify(payload);
1096
+ }
1097
+
1098
+ // Mode 2: direct URL fetch (no page interaction)
1099
+ const res = await fetch(url);
1100
+ if (!res.ok) {
1101
+ throw new Error(`download_file: GET ${url} failed with ${res.status}`);
1102
+ }
1103
+
1104
+ // Try to infer a filename if not provided
1105
+ const cd = res.headers.get('content-disposition') || '';
1106
+ let inferred = 'download.bin';
1107
+ const match = /filename\*=UTF-8''([^;]+)|filename="([^"]+)"/i.exec(cd);
1108
+ if (match) {
1109
+ inferred = decodeURIComponent(match[1] || match[2]);
1110
+ } else {
1111
+ try {
1112
+ const u = new URL(url);
1113
+ const tail = u.pathname.split('/').filter(Boolean).pop();
1114
+ if (tail) inferred = tail;
1115
+ } catch {}
1116
+ }
1117
+
1118
+ const targetPath = path.join(FILES_DIR, sanitize(fileName || inferred));
1119
+ const buf = Buffer.from(await res.arrayBuffer());
1120
+ fs.writeFileSync(targetPath, buf);
1121
+
1122
+ const stats = fs.statSync(targetPath);
1123
+ const payload = {
1124
+ _testronaut_file_event: 'download',
1125
+ mode: 'url',
1126
+ fileName: path.basename(targetPath),
1127
+ destPath: targetPath,
1128
+ bytes: stats.size,
1129
+ selector: null,
1130
+ url,
1131
+ ts: new Date().toISOString()
1132
+ };
1133
+
1134
+ appendFileEvent(payload);
1135
+ return JSON.stringify(payload);
1136
+ }
1137
+
1138
+
910
1139
  async close() {
911
1140
  await this.browser?.close();
912
1141
  }
@@ -931,4 +1160,6 @@ export const CHROME_TOOL_MAP = {
931
1160
  click_and_follow_popup: (b, args) => b.click_and_follow_popup(args),
932
1161
  switch_to_page: (b, args) => b.switch_to_page(args),
933
1162
  close_current_page: (b, args) => b.close_current_page(args),
1163
+ upload_file: (b, args) => b.upload_file(args),
1164
+ download_file: (b, args) => b.download_file(args),
934
1165
  };
@@ -174,59 +174,118 @@ const toolsSchema = [
174
174
  },
175
175
  },
176
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'"
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
+ }
189
195
  },
190
- timeoutMs: {
191
- type: 'number',
192
- default: 20000,
193
- description: 'How long to wait for the popup or navigation'
194
- }
195
- },
196
- required: ['selector']
196
+ required: ['selector']
197
+ }
197
198
  }
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']
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
+ {
228
+ type: 'function',
229
+ function: {
230
+ name: 'upload_file',
231
+ description:
232
+ 'Upload a file into an <input type="file"> or via a file chooser. Defaults to missions/files as the file source.',
233
+ parameters: {
234
+ type: 'object',
235
+ properties: {
236
+ selector: {
237
+ type: 'string',
238
+ description: 'CSS selector for the file input or a button that opens a file chooser'
239
+ },
240
+ fileName: {
241
+ type: 'string',
242
+ description: 'File name located in missions/files (e.g., "sample.pdf")'
243
+ },
244
+ useChooser: {
245
+ type: 'boolean',
246
+ default: false,
247
+ description: 'If true, click the selector and fulfill the file chooser instead of setInputFiles'
248
+ },
249
+ timeoutMs: {
250
+ type: 'number',
251
+ default: 15000
252
+ }
253
+ },
254
+ required: ['selector', 'fileName']
255
+ }
256
+ }
257
+ },
258
+ {
259
+ type: 'function',
260
+ function: {
261
+ name: 'download_file',
262
+ description:
263
+ 'Download a file to missions/files. Either click a selector that triggers a download or fetch a direct URL.',
264
+ parameters: {
265
+ type: 'object',
266
+ properties: {
267
+ selector: {
268
+ type: 'string',
269
+ description: 'An element to click that triggers a download'
270
+ },
271
+ url: {
272
+ type: 'string',
273
+ description: 'Direct URL to download without clicking the page'
274
+ },
275
+ fileName: {
276
+ type: 'string',
277
+ description: 'Optional override for the saved filename'
278
+ },
279
+ timeoutMs: {
280
+ type: 'number',
281
+ default: 30000
282
+ }
283
+ },
284
+ // At least one of selector or url should be provided (enforced in code)
285
+ required: []
286
+ }
215
287
  }
216
288
  }
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
-
228
-
229
-
230
289
  ];
231
290
 
232
291
  export default toolsSchema;