testronaut 1.0.26 → 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.26",
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;
@@ -549,7 +567,13 @@ export class ChromeBrowser {
549
567
  });
550
568
  // return await pw.chromium.launch({ headless: true });
551
569
  }
552
- 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();
553
577
  const first = await this.context.newPage();
554
578
  const id = this._addAndFocus(first);
555
579
 
@@ -991,7 +1015,127 @@ export class ChromeBrowser {
991
1015
  return `Screenshot saved at: ./screenshots/${name}_${timestamp}.png`;
992
1016
  }
993
1017
 
994
-
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
+
995
1139
  async close() {
996
1140
  await this.browser?.close();
997
1141
  }
@@ -1016,4 +1160,6 @@ export const CHROME_TOOL_MAP = {
1016
1160
  click_and_follow_popup: (b, args) => b.click_and_follow_popup(args),
1017
1161
  switch_to_page: (b, args) => b.switch_to_page(args),
1018
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),
1019
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;