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 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
- // const apiBase = `http://localhost:3002` //
58
- const apiBase = 'http://api.testronaut.app'; // Replace with your actual API base URL
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
- const args = process.argv.slice(2);
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
- if (!fs.existsSync(missionsDir)) {
306
- console.error('❌ No `missions` directory found.');
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(missionsDir, filePath);
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 all *.mission.js files
335
- const files = fs.readdirSync(missionsDir).filter(f => f.endsWith('.mission.js'));
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.json();
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)
@@ -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.1",
3
+ "version": "1.3.3",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
- "testronaut": "./bin/cli.js"
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",