testronaut 1.3.1 → 1.3.2

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
@@ -178,6 +178,8 @@ Each includes:
178
178
  - **LLMs** for reasoning, DOM parsing, and tool use
179
179
  - **Token throttling** + adaptive cooldowns
180
180
  - **Extensible architecture** for custom tools and workflows
181
+ - **DOM trimming controls** to cap list sizes (env `TESTRONAUT_DOM_LIST_LIMIT` or config `dom.listItemLimit`; use `all` cautiously—it can spike token use)
182
+ - **Resource guard** to ensure full list/table downloads (config `resourceGuard` or env `TESTRONAUT_RESOURCE_*`)
181
183
 
182
184
  ---
183
185
 
package/bin/cli.js CHANGED
@@ -47,6 +47,7 @@ import { promisify } from 'util';
47
47
  const exec = promisify(execCmd);
48
48
  import url from 'url';
49
49
  import { ensureBrowsers } from '../tools/playwrightSetup.js';
50
+ import { discoverMissionFiles } from '../core/missionDiscovery.js';
50
51
 
51
52
  // Keep PW browsers inside the project to avoid global cache skew
52
53
  process.env.PLAYWRIGHT_BROWSERS_PATH = process.env.PLAYWRIGHT_BROWSERS_PATH || '0';
@@ -153,6 +154,48 @@ if (modelFlagIndex >= 0) {
153
154
  args.splice(modelFlagIndex, modelOverride ? 2 : 1);
154
155
  }
155
156
 
157
+ // Look for --debug / --debug=<bool> / --no-debug
158
+ // Normalize CLI boolean strings into true/false/null for optional flags.
159
+ const parseBool = (v) => {
160
+ const lower = String(v ?? '').trim().toLowerCase();
161
+ if (!lower) return null;
162
+ if (['1', 'true', 'yes', 'on'].includes(lower)) return true;
163
+ if (['0', 'false', 'no', 'off'].includes(lower)) return false;
164
+ return null;
165
+ };
166
+ __test__.parseBool = parseBool;
167
+
168
+ let debugOverride;
169
+ let debugConsumesNext = false;
170
+ const debugFlagIndex = args.findIndex(a =>
171
+ a === '--debug' ||
172
+ a.startsWith('--debug=') ||
173
+ a === '--no-debug'
174
+ );
175
+ if (debugFlagIndex >= 0) {
176
+ const rawArg = args[debugFlagIndex];
177
+ if (rawArg.includes('=')) {
178
+ debugOverride = parseBool(rawArg.split('=')[1]);
179
+ } else if (rawArg === '--no-debug') {
180
+ debugOverride = false;
181
+ } else if (args[debugFlagIndex + 1] && !args[debugFlagIndex + 1].startsWith('-')) {
182
+ debugOverride = parseBool(args[debugFlagIndex + 1]);
183
+ debugConsumesNext = true;
184
+ } else {
185
+ debugOverride = true; // bare --debug enables it
186
+ }
187
+
188
+ if (debugOverride !== null) {
189
+ process.env.TESTRONAUT_DEBUG = debugOverride ? '1' : '0';
190
+ console.log(`🛠️ Debug mode ${debugOverride ? 'enabled' : 'disabled'} (TESTRONAUT_DEBUG=${process.env.TESTRONAUT_DEBUG})`);
191
+ } else {
192
+ console.warn('⚠️ Invalid --debug value. Use true/false, 1/0, yes/no.');
193
+ }
194
+
195
+ const consume = 1 + (debugConsumesNext && debugOverride !== null ? 1 : 0);
196
+ args.splice(debugFlagIndex, consume);
197
+ }
198
+
156
199
  // Look for --turns=<n> or --turns <n>
157
200
  let turnsOverride;
158
201
  const turnsFlagIndex = args.findIndex(a => a === '--turns' || a.startsWith('--turns='));
@@ -208,8 +251,6 @@ if (retryFlagIndex >= 0) {
208
251
  args.splice(retryFlagIndex, retryOverride ? 2 : 1);
209
252
  }
210
253
 
211
- const missionsDir = path.resolve(process.cwd(), 'missions');
212
-
213
254
  const allResults = [];
214
255
  const runId = `run_${Date.now()}`;
215
256
  const startTime = new Date();
@@ -228,6 +269,7 @@ Usage:
228
269
  Options:
229
270
  --init Scaffold project folders and a welcome mission
230
271
  --turns=<n> Override max turns for this run (e.g., --turns=30)
272
+ --debug[=<bool>] Enable verbose debug logs (or set TESTRONAUT_DEBUG=1)
231
273
  --help Show this help message
232
274
  --retry_limit=<n> Override agent turn retry limits (minimum 1, maximum 10)
233
275
 
@@ -302,14 +344,16 @@ if (args.includes('serve') || args.includes('view')) {
302
344
  await new Promise(() => {}); // ✅ never resolves; Ctrl+C will terminate
303
345
  }
304
346
 
305
- if (!fs.existsSync(missionsDir)) {
306
- console.error('❌ No `missions` directory found.');
347
+ const { root: missionsRoot, files: discoveredMissions } = await discoverMissionFiles({ cwd: process.cwd() });
348
+
349
+ if (!fs.existsSync(missionsRoot)) {
350
+ console.error(`❌ Missions directory not found: ${path.relative(process.cwd(), missionsRoot)}`);
307
351
  process.exit(1);
308
352
  }
309
353
 
310
354
  const runFile = async (filePath) => {
311
355
  try {
312
- const modulePath = path.resolve(missionsDir, filePath);
356
+ const modulePath = path.resolve(missionsRoot, filePath);
313
357
  const missionsModule = await import(`file://${modulePath}`);
314
358
 
315
359
  if (typeof missionsModule.executeMission === 'function') {
@@ -331,9 +375,8 @@ if (args.length > 0) {
331
375
  await runFile(file);
332
376
  }
333
377
  } 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) {
378
+ // Run missions discovered from config (or default behavior)
379
+ for (const file of discoveredMissions) {
337
380
  await runFile(file);
338
381
  }
339
382
  }
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.2",
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",
@@ -23,9 +23,20 @@
23
23
  */
24
24
 
25
25
 
26
+ import fs from 'fs';
27
+ import path from 'path';
26
28
  import { runAgent } from '../core/agent.js';
27
29
  import { redactPasswordInText } from '../core/redaction.js';
28
- import { loadConfig, enforceTurnBudget, getRetryLimit } from '../core/config.js';
30
+ import { loadConfig, enforceTurnBudget, getRetryLimit, getDomListLimit, getResourceGuardConfig } from '../core/config.js';
31
+
32
+ // Check process env for debug toggles (shared helper for tests and CLI).
33
+ const isDebugEnabled = () => {
34
+ const raw = String(process.env.TESTRONAUT_DEBUG || '').trim().toLowerCase();
35
+ return ['1', 'true', 'yes', 'on'].includes(raw);
36
+ };
37
+
38
+ // Presentable string for DOM list limit logging/debug output.
39
+ const formatListLimit = (v) => v === Infinity ? 'all' : v;
29
40
 
30
41
  /**
31
42
  * Run a mission flow.
@@ -40,12 +51,56 @@ export async function runMissions({ preMission, mission, postMission }, missionN
40
51
  const maxTurns = effectiveMax;
41
52
  const retryInfo = getRetryLimit(cfg);
42
53
  const retryLimit = retryInfo.value;
54
+ const domListLimitInfo = getDomListLimit(cfg);
55
+ const resourceGuard = getResourceGuardConfig(cfg);
56
+ const debugEnabled = isDebugEnabled();
43
57
  if (notes.length) {
44
58
  console.warn(notes.join('\n'));
45
59
  }
46
60
  if (retryInfo.clamped) {
47
61
  console.warn(`⚠️ Retry limit clamped to ${retryLimit} (allowed range 1-10).`);
48
62
  }
63
+ if (domListLimitInfo?.clamped) {
64
+ console.warn(`⚠️ DOM list limit clamped to ${domListLimitInfo.value} (allowed 0-100 or "all"/"none").`);
65
+ }
66
+ if (domListLimitInfo?.mode === 'all') {
67
+ console.warn('⚠️ DOM list limit is set to "all". This can dramatically increase token usage and may break flows on heavy pages.');
68
+ }
69
+ if (debugEnabled) {
70
+ const cfgDomRaw = cfg?.dom?.listItemLimit ?? cfg?.dom?.listLimit ?? cfg?.domListLimit;
71
+ console.log('[debug] Debug mode enabled');
72
+ console.log('[debug] Turn limits', {
73
+ maxTurns,
74
+ softMax: limits.softMaxTurns,
75
+ hardMax: limits.hardMaxTurns,
76
+ hardMin: limits.hardMinTurns,
77
+ });
78
+ console.log('[debug] Retry limit', { retryLimit, source: retryInfo.source, clamped: retryInfo.clamped });
79
+ console.log('[debug] DOM list limit', {
80
+ env: process.env.TESTRONAUT_DOM_LIST_LIMIT ?? 'unset',
81
+ config: cfgDomRaw ?? 'unset',
82
+ resolved: formatListLimit(domListLimitInfo?.value),
83
+ mode: domListLimitInfo?.mode,
84
+ source: domListLimitInfo?.source,
85
+ clamped: domListLimitInfo?.clamped,
86
+ });
87
+ console.log('[debug] Resource guard', {
88
+ enabled: resourceGuard.enabled,
89
+ hrefIncludes: resourceGuard.hrefIncludes,
90
+ dataTypes: resourceGuard.dataTypes,
91
+ });
92
+ // Helpful for upload missions: show missions/files contents
93
+ const filesDir = path.resolve(process.cwd(), 'missions/files');
94
+ try {
95
+ const entries = fs.readdirSync(filesDir);
96
+ const pdfs = entries.filter(f => f.toLowerCase().endsWith('.pdf'));
97
+ console.log(`[debug] missions/files dir: ${filesDir}`);
98
+ console.log(`[debug] missions/files entries (${entries.length}):`, entries);
99
+ if (pdfs.length) console.log(`[debug] missions/files PDFs (${pdfs.length}):`, pdfs);
100
+ } catch (err) {
101
+ console.log(`[debug] missions/files not readable: ${err.message}`);
102
+ }
103
+ }
49
104
 
50
105
  // 2) Normalize submissions
51
106
  const normalizeToArray = (x) => (Array.isArray(x) ? x : x ? [x] : []);
@@ -128,7 +183,13 @@ export async function runMissions({ preMission, mission, postMission }, missionN
128
183
  );
129
184
 
130
185
  // 3) Execute
131
- const success = await runAgent(goals, missionName, maxTurns, retryLimit);
186
+ const success = await runAgent(
187
+ goals,
188
+ missionName,
189
+ maxTurns,
190
+ retryLimit,
191
+ { domListLimit: domListLimitInfo?.value, debug: debugEnabled, resourceGuard }
192
+ );
132
193
  if (!success) {
133
194
  console.log(`❌ Aborting after failed goal.`);
134
195
  return;
@@ -141,3 +202,6 @@ export async function runMissions({ preMission, mission, postMission }, missionN
141
202
  console.log('\n✅ Mission flow complete.');
142
203
  return success;
143
204
  }
205
+
206
+ // Exposed for unit tests.
207
+ export const __test__ = { isDebugEnabled, formatListLimit };
@@ -33,4 +33,13 @@ describe('cli helpers', () => {
33
33
  expect(latest?.htmlFile).toBe('run_200.html');
34
34
  expect(latest?.jsonFile).toBe('run_200.json');
35
35
  });
36
+
37
+ it('parses booleans in a tolerant way', () => {
38
+ const { parseBool } = __test__;
39
+ expect(parseBool('true')).toBe(true);
40
+ expect(parseBool('0')).toBe(false);
41
+ expect(parseBool('off')).toBe(false);
42
+ expect(parseBool('')).toBeNull();
43
+ expect(parseBool('maybe')).toBeNull();
44
+ });
36
45
  });
@@ -77,6 +77,8 @@ describe('initializeTestronautProject', () => {
77
77
  expect(cfg.initialized).toBe(true);
78
78
  expect(cfg.outputDir).toBe('missions/mission_reports');
79
79
  expect(typeof cfg.maxTurns).toBe('number');
80
+ expect(cfg.dom?.listItemLimit).toBe(3);
81
+ expect(cfg.resourceGuard?.enabled).toBe(true);
80
82
 
81
83
  // .env
82
84
  const envPath = path.join(temp, '.env');
@@ -108,6 +110,8 @@ describe('initializeTestronautProject', () => {
108
110
  const cfg = readJson(path.join(temp, 'testronaut-config.json'));
109
111
  expect(cfg.provider).toBe('gemini');
110
112
  expect(cfg.model).toBe('gemini-2.5-flash');
113
+ expect(cfg.dom?.listItemLimit).toBe(3);
114
+ expect(cfg.resourceGuard?.enabled).toBe(true);
111
115
 
112
116
  const envTxt = fs.readFileSync(path.join(temp, '.env'), 'utf8');
113
117
  expect(envTxt).toMatch(/GEMINI_API_KEY=AIza/);
@@ -185,5 +189,7 @@ describe('initializeTestronautProject', () => {
185
189
  expect(cfg.projectName).toBe('my-project'); // preserved
186
190
  expect(cfg.outputDir).toBe('missions/mission_reports'); // default filled in
187
191
  expect(cfg.maxTurns).toBe(20); // default filled in
192
+ expect(cfg.dom?.listItemLimit).toBe(3);
193
+ expect(cfg.resourceGuard?.enabled).toBe(true);
188
194
  });
189
195
  });
@@ -8,5 +8,8 @@ describe('defaultConfig', () => {
8
8
  expect(cfg.outputDir).toBe('missions/mission_reports');
9
9
  expect(cfg.projectName).toBe('my-app');
10
10
  expect(cfg.maxTurns).toBe(20);
11
+ expect(cfg.dom?.listItemLimit).toBe(3);
12
+ expect(cfg.resourceGuard?.enabled).toBe(true);
13
+ expect(cfg.resourceGuard?.hrefIncludes).toContain('/document/');
11
14
  });
12
15
  });