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 +27 -0
- package/package.json +1 -1
- package/tools/chromeBrowser.js +148 -2
- package/tools/toolSchema.js +108 -49
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
package/tools/chromeBrowser.js
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/tools/toolSchema.js
CHANGED
|
@@ -174,59 +174,118 @@ const toolsSchema = [
|
|
|
174
174
|
},
|
|
175
175
|
},
|
|
176
176
|
{
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
|
|
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;
|