testronaut 1.3.1 β 1.3.3
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/README.md +23 -0
- package/bin/cli.js +126 -17
- package/bin/init.js +2 -0
- package/bin/initHelpers.js +8 -0
- package/core/agent.js +10 -3
- package/core/config.js +91 -0
- package/core/missionDiscovery.js +55 -0
- package/core/turnLoop.js +118 -0
- package/package.json +3 -2
- package/runner/testronaut.js +66 -2
- package/tests/binTests/cli.helpers.test.js +39 -0
- package/tests/binTests/init.test.js +6 -0
- package/tests/binTests/initHelpersTests/defaultConfig.test.js +3 -0
- package/tests/coreTests/config.test.js +96 -0
- package/tests/coreTests/missionDiscovery.test.js +81 -0
- package/tests/coreTests/turnLoop.test.js +46 -1
- package/tests/runnerTests/testronaut.test.js +70 -5
- package/tests/toolsTests/domControl.test.js +35 -0
- package/tests/toolsTests/toolSchema.test.js +24 -0
- package/tools/chromeBrowser.js +211 -4
- package/tools/domControl.js +10 -3
- package/tools/toolSchema.js +43 -1
package/README.md
CHANGED
|
@@ -154,6 +154,27 @@ await runMissions({
|
|
|
154
154
|
postMission: logoutMission,
|
|
155
155
|
}, "Contact Form Flow");
|
|
156
156
|
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## π§° Developer Mode (Staging API)
|
|
161
|
+
|
|
162
|
+
Use the staging API base URL:
|
|
163
|
+
```bash
|
|
164
|
+
npx testronaut --dev
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
If the staging deployment is protected by Vercel, pass the bypass secret:
|
|
168
|
+
```bash
|
|
169
|
+
npx testronaut --dev --vercel-bypass=YOUR_SECRET login
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
You can also set the bypass secret via environment variables:
|
|
173
|
+
```bash
|
|
174
|
+
export VERCEL_AUTOMATION_BYPASS_SECRET=YOUR_SECRET
|
|
175
|
+
# or
|
|
176
|
+
export TESTRONAUT_VERCEL_BYPASS=YOUR_SECRET
|
|
177
|
+
```
|
|
157
178
|
---
|
|
158
179
|
|
|
159
180
|
## π Reports
|
|
@@ -178,6 +199,8 @@ Each includes:
|
|
|
178
199
|
- **LLMs** for reasoning, DOM parsing, and tool use
|
|
179
200
|
- **Token throttling** + adaptive cooldowns
|
|
180
201
|
- **Extensible architecture** for custom tools and workflows
|
|
202
|
+
- **DOM trimming controls** to cap list sizes (env `TESTRONAUT_DOM_LIST_LIMIT` or config `dom.listItemLimit`; use `all` cautiouslyβit can spike token use)
|
|
203
|
+
- **Resource guard** to ensure full list/table downloads (config `resourceGuard` or env `TESTRONAUT_RESOURCE_*`)
|
|
181
204
|
|
|
182
205
|
---
|
|
183
206
|
|
package/bin/cli.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* --model <id> / --model=<id> β sets TESTRONAUT_MODEL env (wins over config file)
|
|
14
14
|
* --turns <n> / --turns=<n> β sets TESTRONAUT_TURNS env (wins over config file)
|
|
15
15
|
* --init β scaffolds project + optional Playwright browsers
|
|
16
|
+
* --dev β use staging API base URL
|
|
16
17
|
* --help β prints help
|
|
17
18
|
*
|
|
18
19
|
* Notable helpers (defined below):
|
|
@@ -47,6 +48,7 @@ import { promisify } from 'util';
|
|
|
47
48
|
const exec = promisify(execCmd);
|
|
48
49
|
import url from 'url';
|
|
49
50
|
import { ensureBrowsers } from '../tools/playwrightSetup.js';
|
|
51
|
+
import { discoverMissionFiles } from '../core/missionDiscovery.js';
|
|
50
52
|
|
|
51
53
|
// Keep PW browsers inside the project to avoid global cache skew
|
|
52
54
|
process.env.PLAYWRIGHT_BROWSERS_PATH = process.env.PLAYWRIGHT_BROWSERS_PATH || '0';
|
|
@@ -54,11 +56,72 @@ process.env.PLAYWRIGHT_BROWSERS_PATH = process.env.PLAYWRIGHT_BROWSERS_PATH || '
|
|
|
54
56
|
const TMP_DIR = path.resolve('./missions/tmp');
|
|
55
57
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
+
const DEFAULT_API_BASE = 'http://api.testronaut.app';
|
|
60
|
+
const DEV_API_BASE = 'https://staging.api.testronaut.app';
|
|
59
61
|
|
|
62
|
+
let args = process.argv.slice(2);
|
|
60
63
|
|
|
61
|
-
|
|
64
|
+
// Look for --dev / --developer / --developer-mode / --staging
|
|
65
|
+
const devFlagIndex = args.findIndex(a =>
|
|
66
|
+
a === '--dev' ||
|
|
67
|
+
a === '--developer' ||
|
|
68
|
+
a === '--developer-mode' ||
|
|
69
|
+
a === '--staging'
|
|
70
|
+
);
|
|
71
|
+
let apiBase = process.env.TESTRONAUT_API_BASE || DEFAULT_API_BASE;
|
|
72
|
+
if (devFlagIndex >= 0) {
|
|
73
|
+
apiBase = DEV_API_BASE;
|
|
74
|
+
console.log(`π§ͺ Developer mode: using ${apiBase}`);
|
|
75
|
+
args.splice(devFlagIndex, 1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseVercelBypassArgs(argsList) {
|
|
79
|
+
const nextArgs = [...argsList];
|
|
80
|
+
let secret;
|
|
81
|
+
let invalid = false;
|
|
82
|
+
const idx = nextArgs.findIndex(a =>
|
|
83
|
+
a === '--vercel-bypass' ||
|
|
84
|
+
a.startsWith('--vercel-bypass=') ||
|
|
85
|
+
a === '--vercel_bypass' ||
|
|
86
|
+
a.startsWith('--vercel_bypass=')
|
|
87
|
+
);
|
|
88
|
+
if (idx >= 0) {
|
|
89
|
+
const rawArg = nextArgs[idx];
|
|
90
|
+
const hasInline = rawArg.includes('=');
|
|
91
|
+
if (hasInline) {
|
|
92
|
+
secret = rawArg.split('=')[1];
|
|
93
|
+
} else if (nextArgs[idx + 1]) {
|
|
94
|
+
secret = nextArgs[idx + 1];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!secret) invalid = true;
|
|
98
|
+
|
|
99
|
+
// Remove flag & value so they arenβt treated as filenames
|
|
100
|
+
const consume = hasInline ? 1 : (secret ? 2 : 1);
|
|
101
|
+
nextArgs.splice(idx, consume);
|
|
102
|
+
}
|
|
103
|
+
return { secret, args: nextArgs, invalid };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const vercelBypassResult = parseVercelBypassArgs(args);
|
|
107
|
+
if (vercelBypassResult.invalid) {
|
|
108
|
+
console.warn('β οΈ Invalid --vercel-bypass value. Provide a non-empty secret.');
|
|
109
|
+
}
|
|
110
|
+
args = vercelBypassResult.args;
|
|
111
|
+
const vercelBypassOverride = vercelBypassResult.secret;
|
|
112
|
+
|
|
113
|
+
const vercelBypassSecret =
|
|
114
|
+
vercelBypassOverride ||
|
|
115
|
+
process.env.VERCEL_AUTOMATION_BYPASS_SECRET ||
|
|
116
|
+
process.env.TESTRONAUT_VERCEL_BYPASS;
|
|
117
|
+
function createVercelBypassHeader(secret) {
|
|
118
|
+
return secret ? { 'x-vercel-protection-bypass': secret } : {};
|
|
119
|
+
}
|
|
120
|
+
const vercelBypassHeader = createVercelBypassHeader(vercelBypassSecret);
|
|
121
|
+
|
|
122
|
+
function buildApiHeaders(extra = {}) {
|
|
123
|
+
return { ...extra, ...vercelBypassHeader };
|
|
124
|
+
}
|
|
62
125
|
|
|
63
126
|
/**
|
|
64
127
|
* Read a JSONL steps file into an array of objects.
|
|
@@ -132,6 +195,8 @@ export const __test__ = {
|
|
|
132
195
|
findLatestReportPair,
|
|
133
196
|
mergeDuplicateTurns,
|
|
134
197
|
readJsonlSteps,
|
|
198
|
+
parseVercelBypassArgs,
|
|
199
|
+
createVercelBypassHeader,
|
|
135
200
|
};
|
|
136
201
|
|
|
137
202
|
// Look for --model=<id> or --model <id>
|
|
@@ -153,6 +218,48 @@ if (modelFlagIndex >= 0) {
|
|
|
153
218
|
args.splice(modelFlagIndex, modelOverride ? 2 : 1);
|
|
154
219
|
}
|
|
155
220
|
|
|
221
|
+
// Look for --debug / --debug=<bool> / --no-debug
|
|
222
|
+
// Normalize CLI boolean strings into true/false/null for optional flags.
|
|
223
|
+
const parseBool = (v) => {
|
|
224
|
+
const lower = String(v ?? '').trim().toLowerCase();
|
|
225
|
+
if (!lower) return null;
|
|
226
|
+
if (['1', 'true', 'yes', 'on'].includes(lower)) return true;
|
|
227
|
+
if (['0', 'false', 'no', 'off'].includes(lower)) return false;
|
|
228
|
+
return null;
|
|
229
|
+
};
|
|
230
|
+
__test__.parseBool = parseBool;
|
|
231
|
+
|
|
232
|
+
let debugOverride;
|
|
233
|
+
let debugConsumesNext = false;
|
|
234
|
+
const debugFlagIndex = args.findIndex(a =>
|
|
235
|
+
a === '--debug' ||
|
|
236
|
+
a.startsWith('--debug=') ||
|
|
237
|
+
a === '--no-debug'
|
|
238
|
+
);
|
|
239
|
+
if (debugFlagIndex >= 0) {
|
|
240
|
+
const rawArg = args[debugFlagIndex];
|
|
241
|
+
if (rawArg.includes('=')) {
|
|
242
|
+
debugOverride = parseBool(rawArg.split('=')[1]);
|
|
243
|
+
} else if (rawArg === '--no-debug') {
|
|
244
|
+
debugOverride = false;
|
|
245
|
+
} else if (args[debugFlagIndex + 1] && !args[debugFlagIndex + 1].startsWith('-')) {
|
|
246
|
+
debugOverride = parseBool(args[debugFlagIndex + 1]);
|
|
247
|
+
debugConsumesNext = true;
|
|
248
|
+
} else {
|
|
249
|
+
debugOverride = true; // bare --debug enables it
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (debugOverride !== null) {
|
|
253
|
+
process.env.TESTRONAUT_DEBUG = debugOverride ? '1' : '0';
|
|
254
|
+
console.log(`π οΈ Debug mode ${debugOverride ? 'enabled' : 'disabled'} (TESTRONAUT_DEBUG=${process.env.TESTRONAUT_DEBUG})`);
|
|
255
|
+
} else {
|
|
256
|
+
console.warn('β οΈ Invalid --debug value. Use true/false, 1/0, yes/no.');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const consume = 1 + (debugConsumesNext && debugOverride !== null ? 1 : 0);
|
|
260
|
+
args.splice(debugFlagIndex, consume);
|
|
261
|
+
}
|
|
262
|
+
|
|
156
263
|
// Look for --turns=<n> or --turns <n>
|
|
157
264
|
let turnsOverride;
|
|
158
265
|
const turnsFlagIndex = args.findIndex(a => a === '--turns' || a.startsWith('--turns='));
|
|
@@ -208,8 +315,6 @@ if (retryFlagIndex >= 0) {
|
|
|
208
315
|
args.splice(retryFlagIndex, retryOverride ? 2 : 1);
|
|
209
316
|
}
|
|
210
317
|
|
|
211
|
-
const missionsDir = path.resolve(process.cwd(), 'missions');
|
|
212
|
-
|
|
213
318
|
const allResults = [];
|
|
214
319
|
const runId = `run_${Date.now()}`;
|
|
215
320
|
const startTime = new Date();
|
|
@@ -228,6 +333,9 @@ Usage:
|
|
|
228
333
|
Options:
|
|
229
334
|
--init Scaffold project folders and a welcome mission
|
|
230
335
|
--turns=<n> Override max turns for this run (e.g., --turns=30)
|
|
336
|
+
--debug[=<bool>] Enable verbose debug logs (or set TESTRONAUT_DEBUG=1)
|
|
337
|
+
--dev Use the staging API base URL
|
|
338
|
+
--vercel-bypass=<secret> Send Vercel protection bypass header for protected deployments
|
|
231
339
|
--help Show this help message
|
|
232
340
|
--retry_limit=<n> Override agent turn retry limits (minimum 1, maximum 10)
|
|
233
341
|
|
|
@@ -302,14 +410,16 @@ if (args.includes('serve') || args.includes('view')) {
|
|
|
302
410
|
await new Promise(() => {}); // β
never resolves; Ctrl+C will terminate
|
|
303
411
|
}
|
|
304
412
|
|
|
305
|
-
|
|
306
|
-
|
|
413
|
+
const { root: missionsRoot, files: discoveredMissions } = await discoverMissionFiles({ cwd: process.cwd() });
|
|
414
|
+
|
|
415
|
+
if (!fs.existsSync(missionsRoot)) {
|
|
416
|
+
console.error(`β Missions directory not found: ${path.relative(process.cwd(), missionsRoot)}`);
|
|
307
417
|
process.exit(1);
|
|
308
418
|
}
|
|
309
419
|
|
|
310
420
|
const runFile = async (filePath) => {
|
|
311
421
|
try {
|
|
312
|
-
const modulePath = path.resolve(
|
|
422
|
+
const modulePath = path.resolve(missionsRoot, filePath);
|
|
313
423
|
const missionsModule = await import(`file://${modulePath}`);
|
|
314
424
|
|
|
315
425
|
if (typeof missionsModule.executeMission === 'function') {
|
|
@@ -331,9 +441,8 @@ if (args.length > 0) {
|
|
|
331
441
|
await runFile(file);
|
|
332
442
|
}
|
|
333
443
|
} else {
|
|
334
|
-
// Run
|
|
335
|
-
const
|
|
336
|
-
for (const file of files) {
|
|
444
|
+
// Run missions discovered from config (or default behavior)
|
|
445
|
+
for (const file of discoveredMissions) {
|
|
337
446
|
await runFile(file);
|
|
338
447
|
}
|
|
339
448
|
}
|
|
@@ -549,13 +658,13 @@ async function handleLogin() {
|
|
|
549
658
|
try {
|
|
550
659
|
const response = await fetch(`${apiBase}/api/user/cli`, { // Replace with your actual URL
|
|
551
660
|
method: 'POST',
|
|
552
|
-
headers: {
|
|
661
|
+
headers: buildApiHeaders({
|
|
553
662
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
554
|
-
},
|
|
663
|
+
}),
|
|
555
664
|
body: formData.toString(),
|
|
556
665
|
});
|
|
557
666
|
|
|
558
|
-
const data = await response
|
|
667
|
+
const data = await parseJsonSafe(response, 'login');
|
|
559
668
|
|
|
560
669
|
if (data.error) {
|
|
561
670
|
console.error('β Authentication failed:', data.error);
|
|
@@ -616,7 +725,7 @@ async function uploadReport() {
|
|
|
616
725
|
try {
|
|
617
726
|
const res = await fetch(`${apiBase}/api/user/cli/${sessionToken}`, {
|
|
618
727
|
method: 'POST',
|
|
619
|
-
headers: { 'Content-Type': 'application/json' },
|
|
728
|
+
headers: buildApiHeaders({ 'Content-Type': 'application/json' }),
|
|
620
729
|
body: reportJson,
|
|
621
730
|
});
|
|
622
731
|
const data = await res.json();
|
|
@@ -692,7 +801,7 @@ async function uploadReport() {
|
|
|
692
801
|
// console.log(` the fetch url is: ${apiBase}/api/user/cli/${sessionToken}/uploads/start`);
|
|
693
802
|
const startRes = await fetch(`${apiBase}/api/user/cli/${sessionToken}/uploads/start`, {
|
|
694
803
|
method: 'POST',
|
|
695
|
-
headers: { 'Content-Type': 'application/json' },
|
|
804
|
+
headers: buildApiHeaders({ 'Content-Type': 'application/json' }),
|
|
696
805
|
body: JSON.stringify({ reportId: savedReportId, stepIndex, fileName: path.basename(filePath), mime, size: stat.size, sha1: hash }),
|
|
697
806
|
});
|
|
698
807
|
const start = await parseJsonSafe(startRes, 'uploads/start');
|
|
@@ -714,7 +823,7 @@ async function uploadReport() {
|
|
|
714
823
|
// console.log(` finalizing uploadβ¦`);
|
|
715
824
|
const finishRes = await fetch(`${apiBase}/api/user/cli/${sessionToken}/uploads/finish`, {
|
|
716
825
|
method: 'POST',
|
|
717
|
-
headers: { 'Content-Type': 'application/json' },
|
|
826
|
+
headers: buildApiHeaders({ 'Content-Type': 'application/json' }),
|
|
718
827
|
body: JSON.stringify({
|
|
719
828
|
reportId: savedReportId,
|
|
720
829
|
stepIndex,
|
package/bin/init.js
CHANGED
|
@@ -139,6 +139,8 @@ export async function initializeTestronautProject() {
|
|
|
139
139
|
config.outputDir = config.outputDir || defaults.outputDir;
|
|
140
140
|
config.projectName = config.projectName || defaults.projectName;
|
|
141
141
|
config.maxTurns = config.maxTurns ?? defaults.maxTurns;
|
|
142
|
+
config.dom = { ...defaults.dom, ...(config.dom || {}) };
|
|
143
|
+
config.resourceGuard = { ...defaults.resourceGuard, ...(config.resourceGuard || {}) };
|
|
142
144
|
|
|
143
145
|
// βββββββββββββββββββββββββββββββββββββββββββββ
|
|
144
146
|
// STEP 4: Persist config and scaffold .env (first run only)
|
package/bin/initHelpers.js
CHANGED
|
@@ -52,6 +52,14 @@ export function defaultConfig(rootBasename) {
|
|
|
52
52
|
outputDir: 'missions/mission_reports',
|
|
53
53
|
projectName: rootBasename,
|
|
54
54
|
maxTurns: 20,
|
|
55
|
+
dom: {
|
|
56
|
+
listItemLimit: 3,
|
|
57
|
+
},
|
|
58
|
+
resourceGuard: {
|
|
59
|
+
enabled: true,
|
|
60
|
+
hrefIncludes: ['/document/', '/file/', '/download', '/attachment/'],
|
|
61
|
+
dataTypes: ['document', 'file', 'item', 'row'],
|
|
62
|
+
},
|
|
55
63
|
};
|
|
56
64
|
}
|
|
57
65
|
|
package/core/agent.js
CHANGED
|
@@ -35,10 +35,16 @@ import path from 'path';
|
|
|
35
35
|
* @param {Array<{goal:any, submissionType?:string, submissionName?:string, label?:string}>} goals
|
|
36
36
|
* @param {string} missionName
|
|
37
37
|
* @param {number} [maxTurns=20] - upper bound for turnLoop per goal
|
|
38
|
+
* @param {number} [retryLimit]
|
|
39
|
+
* @param {{ domListLimit?: number|typeof Infinity, debug?: boolean, resourceGuard?: { enabled:boolean, hrefIncludes:string[], dataTypes:string[] } }} [opts]
|
|
38
40
|
* @returns {Promise<Array<{missionName:string, submissionType:string, submissionName:string|null, status:'passed'|'failed', steps:any[], startTime:number, endTime:number}>>}
|
|
39
41
|
*/
|
|
40
|
-
export async function runAgent(goals, missionName, maxTurns = 20, retryLimit) {
|
|
41
|
-
const browser = new ChromeBrowser(
|
|
42
|
+
export async function runAgent(goals, missionName, maxTurns = 20, retryLimit, opts = {}) {
|
|
43
|
+
const browser = new ChromeBrowser({
|
|
44
|
+
domListLimit: opts.domListLimit,
|
|
45
|
+
debug: opts.debug,
|
|
46
|
+
resourceGuard: opts.resourceGuard,
|
|
47
|
+
});
|
|
42
48
|
await browser.start();
|
|
43
49
|
let result;
|
|
44
50
|
|
|
@@ -155,8 +161,9 @@ Use Ground Control as your persistent mission memory about:
|
|
|
155
161
|
steps,
|
|
156
162
|
stepsArchive,
|
|
157
163
|
missionName,
|
|
158
|
-
retryLimit,
|
|
164
|
+
retryLimit,
|
|
159
165
|
groundControl,
|
|
166
|
+
resourceGuard: opts.resourceGuard,
|
|
160
167
|
onStep: (s) => {
|
|
161
168
|
// Append each step as a JSON line
|
|
162
169
|
try {
|
package/core/config.js
CHANGED
|
@@ -82,6 +82,97 @@ export function getRetryLimit(cfg, fallback = 2) {
|
|
|
82
82
|
return { value, source: 'default', clamped };
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Resolve how many list-like items to keep in DOM snapshots.
|
|
87
|
+
* - Accepts numbers (clamped 0-100), or the strings "all"/"none".
|
|
88
|
+
* - Priority: env TESTRONAUT_DOM_LIST_LIMIT β config.dom.listItemLimit/listLimit/domListLimit β fallback.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} cfg
|
|
91
|
+
* @param {number} [fallback=3]
|
|
92
|
+
* @returns {{ value:number|typeof Infinity, mode:'number'|'all'|'none', source:'env'|'config'|'default', clamped:boolean }}
|
|
93
|
+
*/
|
|
94
|
+
export function getDomListLimit(cfg, fallback = 3) {
|
|
95
|
+
const clampList = (n) => {
|
|
96
|
+
const clamped = Math.min(100, Math.max(0, n));
|
|
97
|
+
return { value: clamped, clamped: clamped !== n };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const parseRaw = (raw) => {
|
|
101
|
+
if (raw === undefined || raw === null) return null;
|
|
102
|
+
|
|
103
|
+
if (typeof raw === 'string') {
|
|
104
|
+
const lower = raw.trim().toLowerCase();
|
|
105
|
+
if (!lower) return null;
|
|
106
|
+
if (lower === 'all') return { value: Infinity, mode: 'all', clamped: false };
|
|
107
|
+
if (lower === 'none') return { value: 0, mode: 'none', clamped: false };
|
|
108
|
+
const num = Number(lower);
|
|
109
|
+
if (!Number.isFinite(num)) return null;
|
|
110
|
+
const { value, clamped } = clampList(num);
|
|
111
|
+
return { value, mode: value === 0 ? 'none' : 'number', clamped };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
|
115
|
+
const { value, clamped } = clampList(raw);
|
|
116
|
+
return { value, mode: value === 0 ? 'none' : 'number', clamped };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return null;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const envParsed = parseRaw(process.env.TESTRONAUT_DOM_LIST_LIMIT);
|
|
123
|
+
if (envParsed) return { ...envParsed, source: 'env' };
|
|
124
|
+
|
|
125
|
+
const cfgRaw = cfg?.dom?.listItemLimit ?? cfg?.dom?.listLimit ?? cfg?.domListLimit;
|
|
126
|
+
const cfgParsed = parseRaw(cfgRaw);
|
|
127
|
+
if (cfgParsed) return { ...cfgParsed, source: 'config' };
|
|
128
|
+
|
|
129
|
+
const fbParsed = parseRaw(fallback);
|
|
130
|
+
return { ...fbParsed, source: 'default' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve resource guard settings for generic list/table harvesting.
|
|
135
|
+
* - Patterns are used to detect resource anchors/data attributes.
|
|
136
|
+
* - Enabled can be toggled via config.resourceGuard.enabled or TESTRONAUT_RESOURCE_GUARD.
|
|
137
|
+
*/
|
|
138
|
+
export function getResourceGuardConfig(cfg) {
|
|
139
|
+
const parseBool = (raw) => {
|
|
140
|
+
if (raw === undefined || raw === null) return null;
|
|
141
|
+
const lower = String(raw).trim().toLowerCase();
|
|
142
|
+
if (['1', 'true', 'yes', 'on'].includes(lower)) return true;
|
|
143
|
+
if (['0', 'false', 'no', 'off'].includes(lower)) return false;
|
|
144
|
+
return null;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const defaultHrefPatterns = ['/document/', '/file/', '/download', '/attachment/'];
|
|
148
|
+
const defaultDataTypes = ['document', 'file', 'item', 'row'];
|
|
149
|
+
|
|
150
|
+
const envEnabled = parseBool(process.env.TESTRONAUT_RESOURCE_GUARD);
|
|
151
|
+
const cfgEnabled = parseBool(cfg?.resourceGuard?.enabled);
|
|
152
|
+
const enabled = envEnabled ?? cfgEnabled ?? true;
|
|
153
|
+
|
|
154
|
+
const parseList = (raw) => {
|
|
155
|
+
if (Array.isArray(raw)) return raw.map(String).map(s => s.trim()).filter(Boolean);
|
|
156
|
+
if (typeof raw === 'string') {
|
|
157
|
+
return raw.split(',').map(s => s.trim()).filter(Boolean);
|
|
158
|
+
}
|
|
159
|
+
return [];
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const hrefIncludes = parseList(process.env.TESTRONAUT_RESOURCE_HREF_PATTERNS)
|
|
163
|
+
|| [];
|
|
164
|
+
const dataTypes = parseList(process.env.TESTRONAUT_RESOURCE_DATA_TYPES)
|
|
165
|
+
|| [];
|
|
166
|
+
|
|
167
|
+
const cfgHref = parseList(cfg?.resourceGuard?.hrefIncludes);
|
|
168
|
+
const cfgTypes = parseList(cfg?.resourceGuard?.dataTypes);
|
|
169
|
+
|
|
170
|
+
const finalHref = (hrefIncludes.length ? hrefIncludes : cfgHref.length ? cfgHref : defaultHrefPatterns).map(s => s.toLowerCase());
|
|
171
|
+
const finalTypes = (dataTypes.length ? dataTypes : cfgTypes.length ? cfgTypes : defaultDataTypes).map(s => s.toLowerCase());
|
|
172
|
+
|
|
173
|
+
return { enabled, hrefIncludes: finalHref, dataTypes: finalTypes };
|
|
174
|
+
}
|
|
175
|
+
|
|
85
176
|
/**
|
|
86
177
|
* Baseline limits for mission runs. Adjust here for global defaults.
|
|
87
178
|
*
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fg from 'fast-glob';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_ROOT = 'missions';
|
|
7
|
+
const DEFAULT_INCLUDE = ['**/*.mission.js', '**/*.mission.ts'];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Discover mission files based on testronaut-config.json.
|
|
11
|
+
* - When no missions block exists, preserve legacy behavior:
|
|
12
|
+
* read the ./missions folder (non-recursive) for *.mission.js files.
|
|
13
|
+
* - When missions is defined, apply root/include/exclude globs relative to root.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} opts
|
|
16
|
+
* @param {string} [opts.cwd=process.cwd()] - project root for config + resolution
|
|
17
|
+
* @returns {Promise<{ root:string, files:string[] }>} root is absolute; files are relative to root
|
|
18
|
+
*/
|
|
19
|
+
export async function discoverMissionFiles({ cwd = process.cwd() } = {}) {
|
|
20
|
+
const defaultRoot = path.resolve(cwd, DEFAULT_ROOT);
|
|
21
|
+
const cfg = await loadConfig(cwd);
|
|
22
|
+
const missionsCfg = cfg?.missions;
|
|
23
|
+
|
|
24
|
+
// Legacy behavior: no missions block β non-recursive *.mission.js in ./missions
|
|
25
|
+
if (!missionsCfg) {
|
|
26
|
+
if (!fs.existsSync(defaultRoot)) {
|
|
27
|
+
return { root: defaultRoot, files: [] };
|
|
28
|
+
}
|
|
29
|
+
const files = fs.readdirSync(defaultRoot)
|
|
30
|
+
.filter(f => f.endsWith('.mission.js'))
|
|
31
|
+
.sort();
|
|
32
|
+
return { root: defaultRoot, files };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const root = path.resolve(cwd, missionsCfg.root || DEFAULT_ROOT);
|
|
36
|
+
if (!fs.existsSync(root)) {
|
|
37
|
+
return { root, files: [] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const include = Array.isArray(missionsCfg.include) && missionsCfg.include.length
|
|
41
|
+
? missionsCfg.include
|
|
42
|
+
: DEFAULT_INCLUDE;
|
|
43
|
+
const exclude = Array.isArray(missionsCfg.exclude) ? missionsCfg.exclude : [];
|
|
44
|
+
|
|
45
|
+
const matches = await fg(include, {
|
|
46
|
+
cwd: root,
|
|
47
|
+
ignore: exclude,
|
|
48
|
+
onlyFiles: true,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const unique = Array.from(new Set(matches));
|
|
52
|
+
unique.sort();
|
|
53
|
+
|
|
54
|
+
return { root, files: unique };
|
|
55
|
+
}
|
package/core/turnLoop.js
CHANGED
|
@@ -63,6 +63,50 @@ console.log(`π§ Using LLM β provider: ${PROVIDER_ID}, model: ${MODEL_ID}`);
|
|
|
63
63
|
|
|
64
64
|
const llm = getLLM(PROVIDER_ID);
|
|
65
65
|
|
|
66
|
+
// Track resource/download coverage across turns (guard against partial loops).
|
|
67
|
+
function ensureDocProgress(agentMemory, cfg) {
|
|
68
|
+
if (!cfg?.enabled) return null;
|
|
69
|
+
if (!agentMemory.docProgress) {
|
|
70
|
+
agentMemory.docProgress = {
|
|
71
|
+
items: [],
|
|
72
|
+
downloaded: new Set(),
|
|
73
|
+
lastSummary: '',
|
|
74
|
+
lastScriptCount: 0,
|
|
75
|
+
patterns: cfg,
|
|
76
|
+
};
|
|
77
|
+
} else {
|
|
78
|
+
agentMemory.docProgress.patterns = cfg;
|
|
79
|
+
}
|
|
80
|
+
return agentMemory.docProgress;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Parse injected <pre data-testronaut-doc-list> summary into structured items.
|
|
84
|
+
function parseDocListFromDom(html) {
|
|
85
|
+
try {
|
|
86
|
+
const match = html.match(/<pre[^>]*data-testronaut-doc-list[^>]*>([\s\S]*?)<\/pre>/i);
|
|
87
|
+
if (!match) return { items: [], scriptDocs: 0 };
|
|
88
|
+
const text = match[1];
|
|
89
|
+
const lines = text.split('\n').map(l => l.trim()).filter(Boolean).slice(1);
|
|
90
|
+
const items = lines.map(l => {
|
|
91
|
+
const m = l.match(/-\s*\[(.*?)\]\s*(.*?)\s+(\/document\/\d+[^\s]*)?/i);
|
|
92
|
+
return {
|
|
93
|
+
id: m?.[1] || '',
|
|
94
|
+
title: m?.[2] || l.replace(/^-/, '').trim(),
|
|
95
|
+
href: m?.[3] || '',
|
|
96
|
+
};
|
|
97
|
+
}).filter(it => it.id || it.title || it.href);
|
|
98
|
+
return { items, scriptDocs: items.length };
|
|
99
|
+
} catch {
|
|
100
|
+
return { items: [], scriptDocs: 0 };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Extract numeric document IDs from common /document/<id> URLs.
|
|
105
|
+
function extractDocIdFromUrl(url = '') {
|
|
106
|
+
const m = url.match(/\/document\/(\d+)/i);
|
|
107
|
+
return m ? m[1] : '';
|
|
108
|
+
}
|
|
109
|
+
|
|
66
110
|
// Rolling token counters used for self-throttling
|
|
67
111
|
let totalTokensUsed = 0;
|
|
68
112
|
let turnTimestamps = [];
|
|
@@ -174,11 +218,17 @@ export const turnLoop = async (
|
|
|
174
218
|
ctx = {} // { steps, missionName, groundControl }
|
|
175
219
|
) => {
|
|
176
220
|
const { steps = [], missionName, groundControl = createEmptyGroundControl(), retryLimit: retryLimitRaw } = ctx;
|
|
221
|
+
const resourceGuardCfg = ctx.resourceGuard || {
|
|
222
|
+
enabled: true,
|
|
223
|
+
hrefIncludes: ['/document/', '/file/', '/download', '/attachment/'],
|
|
224
|
+
dataTypes: ['document', 'file', 'item', 'row'],
|
|
225
|
+
};
|
|
177
226
|
const stepsArchive = ctx.stepsArchive || steps;
|
|
178
227
|
const retryLimitClamped = Math.min(10, Math.max(1, Number.isFinite(retryLimitRaw) ? retryLimitRaw : DEFAULT_TURN_RETRY_LIMIT)); // retries allowed (excludes initial)
|
|
179
228
|
const maxAttempts = retryLimitClamped + 1; // includes initial attempt
|
|
180
229
|
ctx.groundControl = groundControl;
|
|
181
230
|
let agentMemory = { lastMenuExpanded: false };
|
|
231
|
+
ensureDocProgress(agentMemory, resourceGuardCfg);
|
|
182
232
|
let turnRetries = 0;
|
|
183
233
|
let stepSeq = ctx._stepSeq || 0;
|
|
184
234
|
|
|
@@ -373,6 +423,20 @@ export const turnLoop = async (
|
|
|
373
423
|
: `π₯ Downloaded "${maybeJson.fileName}" (${formatBytes(maybeJson.bytes)}) via ${maybeJson.mode}`;
|
|
374
424
|
step.events.push(msgLine);
|
|
375
425
|
console.log(msgLine);
|
|
426
|
+
|
|
427
|
+
if (resourceGuardCfg.enabled && (maybeJson._testronaut_file_event === 'download' || maybeJson._testronaut_file_event === 'upload')) {
|
|
428
|
+
const prog = ensureDocProgress(agentMemory, resourceGuardCfg);
|
|
429
|
+
if (prog) {
|
|
430
|
+
const id = extractDocIdFromUrl(maybeJson.url || maybeJson.selector || maybeJson.fileName);
|
|
431
|
+
if (id) prog.downloaded.add(id);
|
|
432
|
+
// also try title match
|
|
433
|
+
if (maybeJson.fileName) {
|
|
434
|
+
const byTitle = prog.items.find(i => maybeJson.fileName.includes(i.title));
|
|
435
|
+
if (byTitle?.id) prog.downloaded.add(byTitle.id);
|
|
436
|
+
}
|
|
437
|
+
step.events.push(`π Resource progress: ${prog.downloaded.size}/${prog.items.length || prog.lastScriptCount}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
376
440
|
}
|
|
377
441
|
} catch {
|
|
378
442
|
// non-JSON results ignored
|
|
@@ -413,6 +477,35 @@ export const turnLoop = async (
|
|
|
413
477
|
contentForModel = result;
|
|
414
478
|
}
|
|
415
479
|
|
|
480
|
+
// Keep doc list progress updated when we fetch DOM
|
|
481
|
+
if (!errorMessage && resourceGuardCfg.enabled) {
|
|
482
|
+
const prog = ensureDocProgress(agentMemory, resourceGuardCfg);
|
|
483
|
+
if (prog) {
|
|
484
|
+
if (fnName === 'get_dom') {
|
|
485
|
+
const { items, scriptDocs } = parseDocListFromDom(result);
|
|
486
|
+
if (items.length) {
|
|
487
|
+
prog.items = items;
|
|
488
|
+
prog.lastScriptCount = scriptDocs || items.length;
|
|
489
|
+
prog.lastSummary = `docs:${items.length}`;
|
|
490
|
+
step.events.push(`π Detected document list (${items.length} items)`);
|
|
491
|
+
}
|
|
492
|
+
} else if (fnName === 'list_local_files') {
|
|
493
|
+
try {
|
|
494
|
+
const parsed = JSON.parse(result);
|
|
495
|
+
const files = parsed?.files || [];
|
|
496
|
+
if (Array.isArray(files) && files.length) {
|
|
497
|
+
prog.items = files.map(f => ({ id: f, title: f, href: f }));
|
|
498
|
+
prog.lastScriptCount = files.length;
|
|
499
|
+
prog.lastSummary = `files:${files.length}`;
|
|
500
|
+
step.events.push(`π Detected local files (${files.length} items)`);
|
|
501
|
+
}
|
|
502
|
+
} catch {
|
|
503
|
+
// ignore parse errors
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
416
509
|
toolResponses.push({
|
|
417
510
|
role: 'tool',
|
|
418
511
|
tool_call_id: call.id,
|
|
@@ -465,6 +558,24 @@ export const turnLoop = async (
|
|
|
465
558
|
// βββββββββββββββββββββββββββββββββββββββββββββ
|
|
466
559
|
const finalResponse = finalResponseHandler(msg);
|
|
467
560
|
if (finalResponse !== null) {
|
|
561
|
+
const prog = ensureDocProgress(agentMemory, resourceGuardCfg);
|
|
562
|
+
if (prog?.items.length && prog.downloaded.size < prog.items.length) {
|
|
563
|
+
const remaining = prog.items
|
|
564
|
+
.filter(i => !prog.downloaded.has(i.id))
|
|
565
|
+
.map(i => i.title || i.id)
|
|
566
|
+
.slice(0, 10);
|
|
567
|
+
const remainText = remaining.length ? remaining.join('; ') : 'unknown items';
|
|
568
|
+
const guardMsg = `Auto-guard: downloaded ${prog.downloaded.size}/${prog.items.length}. Remaining: ${remainText}`;
|
|
569
|
+
console.log(`[guard] ${guardMsg}`);
|
|
570
|
+
step.events.push(guardMsg);
|
|
571
|
+
step.result = 'π‘ In Progress';
|
|
572
|
+
recordStep(step);
|
|
573
|
+
// Nudge model to continue
|
|
574
|
+
messages.push({ role: 'assistant', content: guardMsg });
|
|
575
|
+
// Continue loop without exiting
|
|
576
|
+
turnRetries = 0;
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
468
579
|
step.events.push(finalResponse.finalMessage);
|
|
469
580
|
step.result = finalResponse.success ? 'β
Mission Success' : 'β Mission Failure';
|
|
470
581
|
recordStep(step);
|
|
@@ -488,3 +599,10 @@ export const turnLoop = async (
|
|
|
488
599
|
|
|
489
600
|
return { success: false, steps: stepsArchive };
|
|
490
601
|
};
|
|
602
|
+
|
|
603
|
+
// Expose small helper bundle for unit tests (no production use).
|
|
604
|
+
export const __docProgressInternals = {
|
|
605
|
+
ensureDocProgress,
|
|
606
|
+
parseDocListFromDom,
|
|
607
|
+
extractDocIdFromUrl,
|
|
608
|
+
};
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "testronaut",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3",
|
|
4
4
|
"main": "./runner/testronaut.js",
|
|
5
5
|
"bin": {
|
|
6
|
-
"testronaut": "
|
|
6
|
+
"testronaut": "bin/cli.js"
|
|
7
7
|
},
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./runner/testronaut.js",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"@google/generative-ai": "^0.24.1",
|
|
25
25
|
"cheerio": "^1.1.0",
|
|
26
26
|
"dotenv": "^17.2.0",
|
|
27
|
+
"fast-glob": "^3.3.3",
|
|
27
28
|
"inquirer": "^12.9.1",
|
|
28
29
|
"node-fetch": "^3.3.2",
|
|
29
30
|
"openai": "^5.10.2",
|