testronaut 1.9.4 → 1.10.0

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
@@ -191,6 +191,31 @@ Run a specific mission:
191
191
  testronaut login.mission.js
192
192
  ```
193
193
 
194
+ Reuse cookies, local storage, and IndexedDB authentication state across an ordered set of mission files:
195
+
196
+ ```bash
197
+ testronaut --session=shared-auth checkout.mission.js account.mission.js
198
+ ```
199
+
200
+ Each mission still receives a fresh browser context. The default
201
+ `--session=isolated` mode does not carry authentication forward. Shared-auth
202
+ suites can wrap a reusable login prerequisite in `launchProtocol()` so later
203
+ mission files probe the restored authenticated page without an LLM call:
204
+
205
+ ```js
206
+ import { launchProtocol, runMissions } from 'testronaut';
207
+
208
+ const login = launchProtocol('Visit the app and log in.', {
209
+ id: 'authenticated:test-user',
210
+ probe: { url: process.env.URL, selector: '#dashboard' },
211
+ });
212
+
213
+ await runMissions({ preMission: login, mission: 'Test the dashboard.' }, 'dashboard');
214
+ ```
215
+
216
+ Unwrapped `preMission` values remain fully compatible and always run normally.
217
+ Place logout last in a shared-auth suite.
218
+
194
219
  Preview or inspect without launching a browser:
195
220
  ```bash
196
221
  testronaut list
@@ -371,7 +396,7 @@ Each includes:
371
396
  - **LLMs** for reasoning, DOM parsing, and tool use
372
397
  - **Token throttling** + adaptive cooldowns
373
398
  - **Extensible architecture** for custom tools and workflows
374
- - **DOM trimming controls** to cap list sizes (env `TESTRONAUT_DOM_LIST_LIMIT` or config `dom.listItemLimit`; use `all` cautiously—it can spike token use)
399
+ - **DOM trimming controls** to cap list/table sizes (`TESTRONAUT_DOM_LIST_LIMIT`) and automatic post-action snapshots (`TESTRONAUT_AUTO_DOM_LIMIT` or `dom.automaticLimit`)
375
400
  - **Resource guard** to ensure full list/table downloads (config `resourceGuard` or env `TESTRONAUT_RESOURCE_*`)
376
401
 
377
402
  ---
package/TODO.md CHANGED
@@ -9,6 +9,17 @@
9
9
  - [ ] Treat `--json` output as a stable automation contract with documented schemas and tests for every supported command.
10
10
  - [ ] If authentication ever moves outside project config, continue reading existing `sessionToken` values and provide an opt-in migration path before deprecating legacy storage.
11
11
 
12
+ ## Session and Reporting Follow-Ups
13
+
14
+ - [ ] Evaluate a true shared browser-context mode for suites that require session storage or in-memory application state; define page reset, crash recovery, and concurrency behavior first.
15
+ - [ ] Add optional shared-auth checkpoints/reset controls for suites that intentionally change users or tenants.
16
+ - [ ] Refresh short-lived Testronaut API session tokens during long runs once the API and credential-storage contract is defined.
17
+ - [ ] Store new CLI authentication outside project config, prefer environment credentials in CI, and migrate legacy project `sessionToken` values without breaking existing projects.
18
+ - [ ] Add structured post-run self-critique fields after the versioned report schema is available.
19
+ - [ ] Separate mission totals from phase totals and add an explicit `phase` field without losing compatibility with `submissionType`.
20
+ - [ ] Design an optional mission scope/side-effect policy that agents can enforce and reports can display.
21
+ - [ ] Evaluate semantic DOM diffs/chunks after configurable limits, selector scoping, and table trimming have production data.
22
+
12
23
  ## Automated MFA Follow-Ups
13
24
 
14
25
  - [ ] Add an integration smoke test against a local or staging API fixture for `get_mfa_code`.
package/bin/cli.js CHANGED
@@ -62,6 +62,8 @@ import { matchesTagFilter, normalizeTagMatch, normalizeTags } from '../core/tags
62
62
  process.env.PLAYWRIGHT_BROWSERS_PATH = process.env.PLAYWRIGHT_BROWSERS_PATH || '0';
63
63
 
64
64
  const TMP_DIR = path.resolve('./missions/tmp');
65
+ const runId = `run_${Date.now()}`;
66
+ const startTime = new Date();
65
67
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
66
68
  const CLI_VERSION = JSON.parse(
67
69
  fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf8')
@@ -89,6 +91,28 @@ const uploadScreenshots = !args.includes('--no-upload-screenshots');
89
91
  args = args.filter(arg => arg !== '--no-upload-screenshots');
90
92
  process.env.TESTRONAUT_SCREENSHOTS = screenshotsEnabled ? '1' : '0';
91
93
 
94
+ let sessionMode = 'isolated';
95
+ const sessionFlagIndex = args.findIndex(a => a === '--session' || a.startsWith('--session='));
96
+ if (sessionFlagIndex >= 0) {
97
+ const raw = args[sessionFlagIndex];
98
+ const inline = raw.includes('=');
99
+ const value = inline ? raw.slice(raw.indexOf('=') + 1) : args[sessionFlagIndex + 1];
100
+ if (!['isolated', 'shared-auth'].includes(value)) {
101
+ console.error('❌ --session must be "isolated" or "shared-auth".');
102
+ process.exit(1);
103
+ }
104
+ sessionMode = value;
105
+ args.splice(sessionFlagIndex, inline ? 1 : 2);
106
+ }
107
+ if (sessionMode === 'shared-auth') {
108
+ const storageStatePath = path.join(TMP_DIR, `${runId}-storage-state.json`);
109
+ process.env.TESTRONAUT_STORAGE_STATE_PATH = storageStatePath;
110
+ process.once('exit', () => {
111
+ try { fs.unlinkSync(storageStatePath); } catch {}
112
+ });
113
+ }
114
+ process.env.TESTRONAUT_SESSION_MODE = sessionMode;
115
+
92
116
  // Detect how the CLI was invoked so help text matches the actual command
93
117
  function detectCliName(npmCommand = process.env.npm_command, argv1 = process.argv[1]) {
94
118
  // npx sets npm_command to 'exec'
@@ -486,7 +510,8 @@ __test__.parseTagArgs = parseTagArgs;
486
510
  let modelOverride;
487
511
  const modelFlagIndex = args.findIndex(a => a === '--model' || a.startsWith('--model='));
488
512
  if (modelFlagIndex >= 0) {
489
- if (args[modelFlagIndex].includes('=')) {
513
+ const inline = args[modelFlagIndex].includes('=');
514
+ if (inline) {
490
515
  modelOverride = args[modelFlagIndex].split('=')[1];
491
516
  } else if (args[modelFlagIndex + 1]) {
492
517
  modelOverride = args[modelFlagIndex + 1];
@@ -498,7 +523,7 @@ if (modelFlagIndex >= 0) {
498
523
  }
499
524
 
500
525
  // Remove flag & value from args so they don't look like mission filenames
501
- args.splice(modelFlagIndex, modelOverride ? 2 : 1);
526
+ args.splice(modelFlagIndex, inline ? 1 : (modelOverride ? 2 : 1));
502
527
  }
503
528
 
504
529
  // Look for --provider=<id> or --provider <id>
@@ -601,7 +626,8 @@ if (debugFlagIndex >= 0) {
601
626
  let turnsOverride;
602
627
  const turnsFlagIndex = args.findIndex(a => a === '--turns' || a.startsWith('--turns='));
603
628
  if (turnsFlagIndex >= 0) {
604
- if (args[turnsFlagIndex].includes('=')) {
629
+ const inline = args[turnsFlagIndex].includes('=');
630
+ if (inline) {
605
631
  turnsOverride = args[turnsFlagIndex].split('=')[1];
606
632
  } else if (args[turnsFlagIndex + 1]) {
607
633
  turnsOverride = args[turnsFlagIndex + 1];
@@ -618,7 +644,7 @@ if (turnsFlagIndex >= 0) {
618
644
  }
619
645
 
620
646
  // Remove flag & value so they aren’t treated as filenames
621
- args.splice(turnsFlagIndex, turnsOverride ? 2 : 1);
647
+ args.splice(turnsFlagIndex, inline ? 1 : (turnsOverride ? 2 : 1));
622
648
  }
623
649
 
624
650
  // Look for --retry_limit / --retry-limit
@@ -631,7 +657,8 @@ const retryFlagIndex = args.findIndex(a =>
631
657
  );
632
658
  if (retryFlagIndex >= 0) {
633
659
  const rawArg = args[retryFlagIndex];
634
- if (rawArg.includes('=')) {
660
+ const inline = rawArg.includes('=');
661
+ if (inline) {
635
662
  retryOverride = rawArg.split('=')[1];
636
663
  } else if (args[retryFlagIndex + 1]) {
637
664
  retryOverride = args[retryFlagIndex + 1];
@@ -649,7 +676,7 @@ if (retryFlagIndex >= 0) {
649
676
  }
650
677
 
651
678
  // Remove flag & value so they aren’t treated as filenames
652
- args.splice(retryFlagIndex, retryOverride ? 2 : 1);
679
+ args.splice(retryFlagIndex, inline ? 1 : (retryOverride ? 2 : 1));
653
680
  }
654
681
 
655
682
  // Look for --human-input / --no-human-input / --disable-human-input
@@ -697,7 +724,8 @@ const humanInputTimeoutFlagIndex = args.findIndex(a =>
697
724
  );
698
725
  if (humanInputTimeoutFlagIndex >= 0) {
699
726
  const rawArg = args[humanInputTimeoutFlagIndex];
700
- if (rawArg.includes('=')) {
727
+ const inline = rawArg.includes('=');
728
+ if (inline) {
701
729
  humanInputTimeoutOverride = rawArg.split('=')[1];
702
730
  } else if (args[humanInputTimeoutFlagIndex + 1]) {
703
731
  humanInputTimeoutOverride = args[humanInputTimeoutFlagIndex + 1];
@@ -713,12 +741,10 @@ if (humanInputTimeoutFlagIndex >= 0) {
713
741
  }
714
742
  }
715
743
 
716
- args.splice(humanInputTimeoutFlagIndex, humanInputTimeoutOverride ? 2 : 1);
744
+ args.splice(humanInputTimeoutFlagIndex, inline ? 1 : (humanInputTimeoutOverride ? 2 : 1));
717
745
  }
718
746
 
719
747
  const allResults = [];
720
- const runId = `run_${Date.now()}`;
721
- const startTime = new Date();
722
748
 
723
749
  const HELP_TEXT = `
724
750
  🧑‍🚀 testronaut - Autonomous Agent Mission Runner
@@ -738,6 +764,8 @@ Options:
738
764
  --turns=<n> Override max turns for this run (e.g., --turns=30)
739
765
  --debug[=<bool>] Enable verbose debug logs (or set TESTRONAUT_DEBUG=1)
740
766
  --provider=<id> Override LLM provider (openai, gemini, or anthropic)
767
+ --model=<id> Override the provider model for this run
768
+ --session=<mode> Browser auth reuse: isolated (default) or shared-auth
741
769
  -o, --options key=value Set run options, such as mfa=github-test-mfa or inbox=github-staging
742
770
  --dev Use the staging API base URL
743
771
  --vercel-bypass=<secret> Send Vercel protection bypass header for protected deployments
@@ -849,13 +877,16 @@ if (args.length === 1 && args[0] === 'list') {
849
877
  return;
850
878
  }
851
879
  const listedMissions = [];
880
+ const listTags = cliTags ?? normalizeTags(tagConfig?.tags, { allowUntagged: true });
881
+ const listTagMatch = normalizeTagMatch(cliTagMatch ?? tagConfig?.tagMatch);
852
882
  if (!jsonOutput) console.log(`Missions in ${path.relative(process.cwd(), missionsRoot) || '.'}:`);
853
883
  for (const file of discoveredMissions) {
854
884
  try {
855
885
  const mission = await loadMissionModule(path.resolve(missionsRoot, file));
856
886
  const tags = normalizeTags(mission.tags);
857
- listedMissions.push({ file, tags });
858
- if (!jsonOutput) console.log(` ${file}${tags.length ? ` [${tags.join(', ')}]` : ''}`);
887
+ const selected = matchesTagFilter(tags, listTags, listTagMatch);
888
+ listedMissions.push({ file, tags, selected });
889
+ if (!jsonOutput) console.log(` ${selected ? '✓' : '·'} ${file}${tags.length ? ` [${tags.join(', ')}]` : ''}`);
859
890
  } catch (error) {
860
891
  console.log(` ${file} [could not load: ${error.message}]`);
861
892
  process.exitCode = 1;
@@ -907,7 +938,7 @@ if (dryRun) {
907
938
  invalid = true;
908
939
  }
909
940
  }
910
- writeOutput({ dryRun: true, outputDir, provider: resolveProviderModel({ cwd: process.cwd() }), screenshots: screenshotsEnabled, missions });
941
+ writeOutput({ dryRun: true, outputDir, provider: resolveProviderModel({ cwd: process.cwd() }), session: { mode: sessionMode }, screenshots: screenshotsEnabled, missions });
911
942
  if (invalid) process.exitCode = 1;
912
943
  return;
913
944
  }
@@ -1008,6 +1039,7 @@ const report = {
1008
1039
  provider: llmProvider,
1009
1040
  model: llmModel,
1010
1041
  },
1042
+ session: { mode: sessionMode },
1011
1043
  summary: {
1012
1044
  totalMissions: flatMissions.length,
1013
1045
  passed: flatMissions.filter(m => m.status === 'passed').length,
@@ -60,6 +60,7 @@ export function defaultConfig(rootBasename) {
60
60
  addTags: [],
61
61
  dom: {
62
62
  listItemLimit: 3,
63
+ automaticLimit: 15000,
63
64
  },
64
65
  resourceGuard: {
65
66
  enabled: true,
package/core/agent.js CHANGED
@@ -25,9 +25,10 @@
25
25
  import 'dotenv/config';
26
26
  import { ChromeBrowser } from '../tools/chromeBrowser.js';
27
27
  import { turnLoop } from './turnLoop.js';
28
- import { createEmptyGroundControl, summarizeGroundControlForPrompt } from '../tools/contextControl.js';
28
+ import { applyGroundControlUpdate, createEmptyGroundControl, summarizeGroundControlForPrompt } from '../tools/contextControl.js';
29
29
  import fs from 'fs';
30
30
  import path from 'path';
31
+ import { hasCompletedLaunchProtocol, markLaunchProtocolCompleted } from './launchProtocol.js';
31
32
 
32
33
  /**
33
34
  * Execute goals with a browser agent.
@@ -36,7 +37,7 @@ import path from 'path';
36
37
  * @param {string} missionName
37
38
  * @param {number} [maxTurns=20] - upper bound for turnLoop per goal
38
39
  * @param {number} [retryLimit]
39
- * @param {{ domListLimit?: number|typeof Infinity, debug?: boolean, resourceGuard?: { enabled:boolean, hrefIncludes:string[], dataTypes:string[] }, humanInput?: { enabled:boolean, timeoutSeconds:number } }} [opts]
40
+ * @param {{ domListLimit?: number|typeof Infinity, automaticDomLimit?: number, storageStatePath?: string, debug?: boolean, resourceGuard?: { enabled:boolean, hrefIncludes:string[], dataTypes:string[] }, humanInput?: { enabled:boolean, timeoutSeconds:number } }} [opts]
40
41
  * @returns {Promise<Array<{missionName:string, submissionType:string, submissionName:string|null, status:'passed'|'failed', steps:any[], startTime:number, endTime:number}>>}
41
42
  */
42
43
  export async function runAgent(goals, missionName, maxTurns = 20, retryLimit, opts = {}) {
@@ -44,6 +45,7 @@ export async function runAgent(goals, missionName, maxTurns = 20, retryLimit, op
44
45
  domListLimit: opts.domListLimit,
45
46
  debug: opts.debug,
46
47
  resourceGuard: opts.resourceGuard,
48
+ storageStatePath: opts.storageStatePath,
47
49
  });
48
50
  await browser.start();
49
51
  let result;
@@ -57,6 +59,7 @@ export async function runAgent(goals, missionName, maxTurns = 20, retryLimit, op
57
59
  const groundControl = createEmptyGroundControl();
58
60
 
59
61
  for (const goal of goals) {
62
+ const telemetryStart = groundControl.telemetry.length;
60
63
  const steps = [];
61
64
  const stepsArchive = [];
62
65
  // Unique-ish JSONL file for this mission’s steps
@@ -67,6 +70,69 @@ export async function runAgent(goals, missionName, maxTurns = 20, retryLimit, op
67
70
  // Start clean
68
71
  fs.writeFileSync(stepFile, '');
69
72
 
73
+ let protocolResult = null;
74
+ const protocol = goal.launchProtocol;
75
+ const sharedAuth = process.env.TESTRONAUT_SESSION_MODE === 'shared-auth';
76
+ if (protocol && sharedAuth && hasCompletedLaunchProtocol(protocol.id)) {
77
+ let probeResult;
78
+ try {
79
+ probeResult = await browser.probe(protocol.probe);
80
+ } catch (error) {
81
+ probeResult = { passed: false, reason: error?.message || String(error) };
82
+ }
83
+
84
+ if (probeResult.passed) {
85
+ const currentUrl = probeResult.url || protocol.probe.url || null;
86
+ applyGroundControlUpdate(groundControl, {
87
+ app: {
88
+ baseUrl: protocol.probe.url || currentUrl,
89
+ currentUrl,
90
+ routeRole: 'authenticated',
91
+ },
92
+ session: { loggedIn: true },
93
+ navigation: { currentLabel: `Launch protocol ${protocol.id} verified` },
94
+ constraints: { stayWithinBaseUrl: true },
95
+ });
96
+ const now = Date.now();
97
+ const step = {
98
+ turn: 0,
99
+ events: [`📡 Launch protocol probe passed: ${protocol.id}`],
100
+ result: '✅ Mission Success',
101
+ summary: `Reused launch protocol ${protocol.id}`,
102
+ missionName,
103
+ tokensUsed: 0,
104
+ inputTokens: 0,
105
+ outputTokens: 0,
106
+ };
107
+ fs.appendFileSync(stepFile, JSON.stringify(step) + '\n');
108
+ missionResults.push({
109
+ missionName,
110
+ submissionType: goal.submissionType || goal.label,
111
+ submissionName: goal.submissionName || null,
112
+ status: 'passed',
113
+ steps: [step],
114
+ stepFile,
115
+ startTime: now,
116
+ endTime: now,
117
+ telemetry: [],
118
+ launchProtocol: { id: protocol.id, status: 'reused', probe: 'passed' },
119
+ });
120
+ continue;
121
+ }
122
+ protocolResult = {
123
+ id: protocol.id,
124
+ status: 'executed',
125
+ probe: 'failed',
126
+ reason: probeResult.reason,
127
+ };
128
+ } else if (protocol) {
129
+ protocolResult = {
130
+ id: protocol.id,
131
+ status: 'executed',
132
+ probe: sharedAuth ? 'not-needed' : 'disabled',
133
+ };
134
+ }
135
+
70
136
  // Ensure user message is a string (functions/objects → toString fallback)
71
137
  const userContent =
72
138
  typeof goal.goal === 'string'
@@ -197,6 +263,7 @@ Email invitation and magic links:
197
263
  groundControl,
198
264
  resourceGuard: opts.resourceGuard,
199
265
  humanInput: opts.humanInput,
266
+ automaticDomLimit: opts.automaticDomLimit,
200
267
  onStep: (s) => {
201
268
  // Append each step as a JSON line
202
269
  try {
@@ -232,8 +299,14 @@ Email invitation and magic links:
232
299
  stepFile, // full history is in this JSONL (one step per line)
233
300
  startTime: Date.now() - 1, // TODO: wire actual timings if needed
234
301
  endTime: Date.now(),
302
+ telemetry: JSON.parse(JSON.stringify(groundControl.telemetry.slice(telemetryStart))),
303
+ ...(protocolResult ? { launchProtocol: protocolResult } : {}),
235
304
  });
236
305
 
306
+ if (protocol && result?.success) {
307
+ markLaunchProtocolCompleted(protocol.id);
308
+ }
309
+
237
310
  if (!result?.success) {
238
311
  console.log('🛑 Agent stopped due to failed goal.\n');
239
312
  return missionResults;
package/core/config.js CHANGED
@@ -130,6 +130,22 @@ export function getDomListLimit(cfg, fallback = 3) {
130
130
  return { ...fbParsed, source: 'default' };
131
131
  }
132
132
 
133
+ /** Resolve the character limit for automatic DOM snapshots injected after actions. */
134
+ export function getAutomaticDomLimit(cfg, fallback = 15000) {
135
+ const parse = raw => {
136
+ if (raw === undefined || raw === null || String(raw).trim() === '') return null;
137
+ const value = Number(raw);
138
+ if (!Number.isFinite(value)) return null;
139
+ const clamped = Math.min(100000, Math.max(1000, Math.trunc(value)));
140
+ return { value: clamped, clamped: clamped !== value };
141
+ };
142
+ const env = parse(process.env.TESTRONAUT_AUTO_DOM_LIMIT);
143
+ if (env) return { ...env, source: 'env' };
144
+ const configured = parse(cfg?.dom?.automaticLimit ?? cfg?.automaticDomLimit);
145
+ if (configured) return { ...configured, source: 'config' };
146
+ return { ...parse(fallback), source: 'default' };
147
+ }
148
+
133
149
  /**
134
150
  * Resolve resource guard settings for generic list/table harvesting.
135
151
  * - Patterns are used to detect resource anchors/data attributes.
@@ -0,0 +1,42 @@
1
+ const completedProtocols = new Set();
2
+
3
+ /**
4
+ * Add optional shared-session reuse metadata to an ordinary mission goal.
5
+ * The wrapped goal remains string-coercible for backward compatibility.
6
+ */
7
+ export function launchProtocol(goal, { id, probe } = {}) {
8
+ const protocolId = String(id || '').trim();
9
+ if (!protocolId) throw new Error('launchProtocol requires a non-empty id.');
10
+ if (!probe || (!probe.selector && !probe.text)) {
11
+ throw new Error('launchProtocol requires a probe selector or text value.');
12
+ }
13
+
14
+ return Object.freeze({
15
+ __testronautLaunchProtocol: true,
16
+ goal,
17
+ protocol: Object.freeze({
18
+ id: protocolId,
19
+ probe: Object.freeze({ ...probe }),
20
+ }),
21
+ toString() {
22
+ return typeof goal === 'string' ? goal : goal?.toString?.() ?? String(goal);
23
+ },
24
+ });
25
+ }
26
+
27
+ export function unwrapLaunchProtocol(value) {
28
+ if (!value?.__testronautLaunchProtocol) return { goal: value, protocol: null };
29
+ return { goal: value.goal, protocol: value.protocol };
30
+ }
31
+
32
+ export function hasCompletedLaunchProtocol(id) {
33
+ return completedProtocols.has(id);
34
+ }
35
+
36
+ export function markLaunchProtocolCompleted(id) {
37
+ completedProtocols.add(id);
38
+ }
39
+
40
+ export function resetLaunchProtocols() {
41
+ completedProtocols.clear();
42
+ }
package/core/turnLoop.js CHANGED
@@ -190,7 +190,7 @@ function formatBytes(n) {
190
190
  * @param {object} agentMemory - Memory object storing recent agent state.
191
191
  * @param {{skipIfLastTool?: string[]}} opts - Optional tool skip conditions.
192
192
  */
193
- const pushDOMAssistant = async (browser, messages, agentMemory, { skipIfLastTool } = {}) => {
193
+ const pushDOMAssistant = async (browser, messages, agentMemory, { skipIfLastTool, limit = 15000 } = {}) => {
194
194
  // Avoid redundant DOM pushes immediately following tools that already include DOM context.
195
195
  if (skipIfLastTool && skipIfLastTool.includes(messages.at(-1)?.name)) {
196
196
  console.log(`[skip] Skipping DOM push after redundant tool: ${messages.at(-1)?.name}`);
@@ -198,7 +198,7 @@ const pushDOMAssistant = async (browser, messages, agentMemory, { skipIfLastTool
198
198
  }
199
199
 
200
200
  const domCallId = `get_dom_${Date.now()}`;
201
- const automaticDomLimit = 15000;
201
+ const automaticDomLimit = limit;
202
202
 
203
203
  // Inject a synthetic assistant tool call
204
204
  messages.push({
@@ -437,6 +437,8 @@ export const turnLoop = async (
437
437
  const tokensUsed = usage.total_tokens || 0;
438
438
  console.log(`📊 Token Usage This Turn → Total: ${tokensUsed}`);
439
439
  step.tokensUsed = tokensUsed;
440
+ step.inputTokens = usage.input_tokens ?? usage.prompt_tokens ?? usage.providerRaw?.input_tokens ?? usage.providerRaw?.prompt_tokens;
441
+ step.outputTokens = usage.output_tokens ?? usage.completion_tokens ?? usage.providerRaw?.output_tokens ?? usage.providerRaw?.completion_tokens;
440
442
  recordTokenUsage(turnTimestamps, tokensUsed);
441
443
  ({ turnTimestamps, totalTokensUsed } = pruneOldTokenUsage(turnTimestamps));
442
444
  console.log(`📈 Running Total Tokens Used (Rolling 60s): ${totalTokensUsed}`);
@@ -466,7 +468,7 @@ export const turnLoop = async (
466
468
  const args = JSON.parse(call.function.arguments || '{}');
467
469
  const safeArgs = redactArgs(fnName, args);
468
470
  console.log(`[model] → ${fnName}`, safeArgs);
469
- step.events.push(`[model] → ${fnName} ${safeArgs}`);
471
+ step.events.push(`[model] → ${fnName} ${JSON.stringify(safeArgs)}`);
470
472
 
471
473
  let result;
472
474
  let errorMessage = null;
@@ -824,6 +826,7 @@ export const turnLoop = async (
824
826
  step.events.push(`[auto] → Injecting DOM after ${domRefreshSource}...`);
825
827
  const domRefresh = await pushDOMAssistant(browser, messages, agentMemory, {
826
828
  skipIfLastTool: ['get_dom', 'check_text'],
829
+ limit: ctx.automaticDomLimit,
827
830
  });
828
831
  if (domRefresh?.injected) {
829
832
  console.log(`[auto] → DOM size after ${domRefreshSource}: ${domRefresh.domLength} chars`);
@@ -884,6 +887,7 @@ export const turnLoop = async (
884
887
  // ─────────────────────────────────────────────
885
888
  await pushDOMAssistant(browser, messages, agentMemory, {
886
889
  skipIfLastTool: ['get_dom', 'check_text'],
890
+ limit: ctx.automaticDomLimit,
887
891
  });
888
892
  console.log(`[auto] → Injected DOM for next reasoning step`);
889
893
  step.events.push(`[auto] → Injected DOM for next reasoning step`);
@@ -112,6 +112,8 @@ export class AnthropicProvider {
112
112
 
113
113
  const usage = {
114
114
  total_tokens: (response.usage?.input_tokens ?? 0) + (response.usage?.output_tokens ?? 0),
115
+ input_tokens: response.usage?.input_tokens,
116
+ output_tokens: response.usage?.output_tokens,
115
117
  providerRaw: response.usage,
116
118
  };
117
119
  return { message: fromAnthropicMessage(response), usage };
@@ -219,6 +219,8 @@ export class GeminiProvider {
219
219
  const usageMeta = res?.response?.usageMetadata;
220
220
  const usage = {
221
221
  total_tokens: usageMeta?.totalTokenCount,
222
+ input_tokens: usageMeta?.promptTokenCount,
223
+ output_tokens: usageMeta?.candidatesTokenCount,
222
224
  providerRaw: usageMeta,
223
225
  };
224
226
 
@@ -63,6 +63,8 @@ export class OpenAIProvider {
63
63
  const message = res.choices?.[0]?.message ?? { role: 'assistant', content: '' };
64
64
  const usage = {
65
65
  total_tokens: res.usage?.total_tokens,
66
+ input_tokens: res.usage?.prompt_tokens,
67
+ output_tokens: res.usage?.completion_tokens,
66
68
  providerRaw: res.usage,
67
69
  };
68
70
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "1.9.4",
3
+ "version": "1.10.0",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "bin/cli.js"
@@ -29,8 +29,11 @@ import 'dotenv/config';
29
29
  import fs from 'fs';
30
30
  import path from 'path';
31
31
  import { redactPasswordInText } from '../core/redaction.js';
32
- import { loadConfig, enforceTurnBudget, getRetryLimit, getDomListLimit, getResourceGuardConfig, getHumanInputConfig } from '../core/config.js';
32
+ import { loadConfig, enforceTurnBudget, getRetryLimit, getDomListLimit, getAutomaticDomLimit, getResourceGuardConfig, getHumanInputConfig } from '../core/config.js';
33
33
  import { normalizeTags } from '../core/tags.js';
34
+ import { launchProtocol, unwrapLaunchProtocol } from '../core/launchProtocol.js';
35
+
36
+ export { launchProtocol };
34
37
 
35
38
  // Check process env for debug toggles (shared helper for tests and CLI).
36
39
  const isDebugEnabled = () => {
@@ -55,6 +58,7 @@ export async function runMissions({ preMission, mission, postMission, tags = []
55
58
  const retryInfo = getRetryLimit(cfg);
56
59
  const retryLimit = retryInfo.value;
57
60
  const domListLimitInfo = getDomListLimit(cfg);
61
+ const automaticDomLimitInfo = getAutomaticDomLimit(cfg);
58
62
  const resourceGuard = getResourceGuardConfig(cfg);
59
63
  const humanInput = getHumanInputConfig(cfg);
60
64
  const debugEnabled = isDebugEnabled();
@@ -99,6 +103,7 @@ export async function runMissions({ preMission, mission, postMission, tags = []
99
103
  source: domListLimitInfo?.source,
100
104
  clamped: domListLimitInfo?.clamped,
101
105
  });
106
+ console.log('[debug] Automatic DOM limit', automaticDomLimitInfo);
102
107
  console.log('[debug] Resource guard', {
103
108
  enabled: resourceGuard.enabled,
104
109
  hrefIncludes: resourceGuard.hrefIncludes,
@@ -161,12 +166,16 @@ export async function runMissions({ preMission, mission, postMission, tags = []
161
166
 
162
167
  if (pre.length) {
163
168
  goals.push(
164
- ...pre.map((fn, i) => ({
165
- goal: fn,
166
- label: 'pre-mission', // legacy label (kept for logs)
167
- submissionType: 'premission', // normalized
168
- submissionName: pickName('premission', fn, i),
169
- }))
169
+ ...pre.map((fn, i) => {
170
+ const { goal, protocol } = unwrapLaunchProtocol(fn);
171
+ return {
172
+ goal,
173
+ launchProtocol: protocol,
174
+ label: 'pre-mission', // legacy label (kept for logs)
175
+ submissionType: 'premission', // normalized
176
+ submissionName: pickName('premission', goal, i),
177
+ };
178
+ })
170
179
  );
171
180
  }
172
181
 
@@ -210,7 +219,14 @@ export async function runMissions({ preMission, mission, postMission, tags = []
210
219
  missionName,
211
220
  maxTurns,
212
221
  retryLimit,
213
- { domListLimit: domListLimitInfo?.value, debug: debugEnabled, resourceGuard, humanInput }
222
+ {
223
+ domListLimit: domListLimitInfo?.value,
224
+ automaticDomLimit: automaticDomLimitInfo.value,
225
+ storageStatePath: process.env.TESTRONAUT_STORAGE_STATE_PATH,
226
+ debug: debugEnabled,
227
+ resourceGuard,
228
+ humanInput,
229
+ }
214
230
  );
215
231
  if (!success) {
216
232
  console.log(`❌ Aborting after failed goal.`);
@@ -37,10 +37,22 @@ describe('CLI inspection commands', () => {
37
37
  const result = run(makeProject(), ['list', '--json']);
38
38
  expect(result.status).toBe(0);
39
39
  expect(JSON.parse(result.stdout).missions).toEqual([
40
- { file: 'login.mission.js', tags: ['authentication', 'smoke'] },
40
+ { file: 'login.mission.js', tags: ['authentication', 'smoke'], selected: true },
41
41
  ]);
42
42
  });
43
43
 
44
+ it('applies tag filters to list output', () => {
45
+ const result = run(makeProject(), ['list', '--tag=checkout', '--json']);
46
+ expect(result.status).toBe(0);
47
+ expect(JSON.parse(result.stdout).missions[0].selected).toBe(false);
48
+ });
49
+
50
+ it('does not discard a following inline flag', () => {
51
+ const result = run(makeProject(), ['--model=gpt-5.6', '--turns=30', '--dry-run', '--json']);
52
+ expect(result.status).toBe(0);
53
+ expect(JSON.parse(result.stdout).missions[0].file).toBe('missions/login.mission.js');
54
+ });
55
+
44
56
  it('reports effective config without exposing the session token', () => {
45
57
  const result = run(makeProject(), ['config', '--json']);
46
58
  expect(result.status).toBe(0);
@@ -74,4 +86,17 @@ describe('CLI inspection commands', () => {
74
86
  expect(result.status).toBe(0);
75
87
  expect(JSON.parse(result.stdout).missions[0].missionName).toBe('https://example.test');
76
88
  });
89
+
90
+ it('records shared-auth session mode in the report', () => {
91
+ const cwd = makeProject();
92
+ fs.writeFileSync(path.join(cwd, 'missions', 'login.mission.js'), [
93
+ 'export const tags = ["authentication"];',
94
+ 'export async function executeMission() {',
95
+ ' return { missionName: "login", submissionType: "mission", status: "passed", steps: [] };',
96
+ '}',
97
+ ].join('\n'));
98
+ const result = run(cwd, ['--session=shared-auth', '--json', '--quiet']);
99
+ expect(result.status).toBe(0);
100
+ expect(JSON.parse(result.stdout).session).toEqual({ mode: 'shared-auth' });
101
+ });
77
102
  });
@@ -6,6 +6,7 @@ vi.mock('../../tools/chromeBrowser.js', () => {
6
6
  function ChromeBrowser() {}
7
7
  ChromeBrowser.prototype.start = async function () {};
8
8
  ChromeBrowser.prototype.close = async function () {};
9
+ ChromeBrowser.prototype.probe = async function () { return { passed: true }; };
9
10
  return { ChromeBrowser };
10
11
  });
11
12
 
@@ -17,6 +18,7 @@ vi.mock('../../core/turnLoop.js', () => ({
17
18
  import { ChromeBrowser } from '../../tools/chromeBrowser.js';
18
19
  import { turnLoop } from '../../core/turnLoop.js';
19
20
  import { runAgent } from '../../core/agent.js';
21
+ import { resetLaunchProtocols } from '../../core/launchProtocol.js';
20
22
 
21
23
  let startSpy, closeSpy;
22
24
 
@@ -26,6 +28,8 @@ describe('core/agent.runAgent', () => {
26
28
  startSpy = vi.spyOn(ChromeBrowser.prototype, 'start').mockResolvedValue();
27
29
  closeSpy = vi.spyOn(ChromeBrowser.prototype, 'close').mockResolvedValue();
28
30
  vi.clearAllMocks();
31
+ resetLaunchProtocols();
32
+ delete process.env.TESTRONAUT_SESSION_MODE;
29
33
  });
30
34
 
31
35
  afterEach(() => {
@@ -104,6 +108,63 @@ describe('core/agent.runAgent', () => {
104
108
  expect(res2[0].steps[0].result).toBe('SUCCESS: step2');
105
109
  });
106
110
 
111
+ it('attaches telemetry recorded during each goal to that result', async () => {
112
+ turnLoop.mockImplementationOnce(async (browser, messages, maxTurns, ct, rc, cs, ctx) => {
113
+ ctx.groundControl.telemetry.push({ kind: 'note', text: 'durable observation' });
114
+ return { success: true };
115
+ });
116
+ const [result] = await runAgent([{ goal: 'Observe' }], 'Telemetry', 3);
117
+ expect(result.telemetry).toEqual([{ kind: 'note', text: 'durable observation' }]);
118
+ });
119
+
120
+ it('reuses a completed launch protocol after a passing shared-auth probe', async () => {
121
+ process.env.TESTRONAUT_SESSION_MODE = 'shared-auth';
122
+ const probeSpy = vi.spyOn(ChromeBrowser.prototype, 'probe').mockResolvedValue({
123
+ passed: true,
124
+ url: 'https://example.test/dashboard',
125
+ });
126
+ const goal = {
127
+ goal: 'Log in',
128
+ submissionType: 'premission',
129
+ launchProtocol: { id: 'authenticated:user', probe: { selector: '#dashboard' } },
130
+ };
131
+ turnLoop.mockResolvedValueOnce({ success: true });
132
+ await runAgent([goal], 'First', 3);
133
+ turnLoop.mockResolvedValueOnce({ success: true });
134
+ const [reused] = await runAgent([
135
+ goal,
136
+ { goal: 'Use the current dashboard', submissionType: 'mission' },
137
+ ], 'Second', 3);
138
+
139
+ expect(turnLoop).toHaveBeenCalledTimes(2);
140
+ expect(probeSpy).toHaveBeenCalledWith({ selector: '#dashboard' });
141
+ expect(reused.launchProtocol).toEqual({ id: 'authenticated:user', status: 'reused', probe: 'passed' });
142
+ expect(reused.steps[0].tokensUsed).toBe(0);
143
+ const mainMessages = turnLoop.mock.calls[1][1];
144
+ expect(mainMessages[0].content).toContain('https://example.test/dashboard');
145
+ expect(mainMessages[0].content).toContain('"loggedIn": true');
146
+ probeSpy.mockRestore();
147
+ });
148
+
149
+ it('falls back to the original preMission when a reuse probe fails', async () => {
150
+ process.env.TESTRONAUT_SESSION_MODE = 'shared-auth';
151
+ const goal = {
152
+ goal: 'Log in',
153
+ submissionType: 'premission',
154
+ launchProtocol: { id: 'authenticated:user', probe: { selector: '#dashboard' } },
155
+ };
156
+ turnLoop.mockResolvedValue({ success: true });
157
+ await runAgent([goal], 'First', 3);
158
+ const probeSpy = vi.spyOn(ChromeBrowser.prototype, 'probe').mockResolvedValueOnce({ passed: false, reason: 'signed out' });
159
+ const [executed] = await runAgent([goal], 'Second', 3);
160
+
161
+ expect(turnLoop).toHaveBeenCalledTimes(2);
162
+ expect(executed.launchProtocol).toEqual({
163
+ id: 'authenticated:user', status: 'executed', probe: 'failed', reason: 'signed out',
164
+ });
165
+ probeSpy.mockRestore();
166
+ });
167
+
107
168
  it('includes missionName and submission metadata in results', async () => {
108
169
  turnLoop.mockResolvedValueOnce({ success: true });
109
170
 
@@ -13,6 +13,7 @@ import {
13
13
  resolveTurnLimits,
14
14
  enforceTurnBudget,
15
15
  getDomListLimit,
16
+ getAutomaticDomLimit,
16
17
  getResourceGuardConfig,
17
18
  getHumanInputConfig,
18
19
  } from '../../core/config.js';
@@ -21,6 +22,15 @@ describe('core/config', () => {
21
22
  beforeEach(() => {
22
23
  vi.resetAllMocks();
23
24
  delete process.env.STRICT_LIMITS;
25
+ delete process.env.TESTRONAUT_AUTO_DOM_LIMIT;
26
+ });
27
+
28
+ describe('getAutomaticDomLimit', () => {
29
+ it('uses config and clamps unsafe values', () => {
30
+ expect(getAutomaticDomLimit({ dom: { automaticLimit: 9000 } })).toMatchObject({ value: 9000, source: 'config' });
31
+ process.env.TESTRONAUT_AUTO_DOM_LIMIT = '250000';
32
+ expect(getAutomaticDomLimit({})).toMatchObject({ value: 100000, source: 'env', clamped: true });
33
+ });
24
34
  });
25
35
 
26
36
  describe('loadConfig', () => {
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it, beforeEach } from 'vitest';
2
+ import {
3
+ launchProtocol,
4
+ unwrapLaunchProtocol,
5
+ hasCompletedLaunchProtocol,
6
+ markLaunchProtocolCompleted,
7
+ resetLaunchProtocols,
8
+ } from '../../core/launchProtocol.js';
9
+
10
+ describe('launchProtocol', () => {
11
+ beforeEach(() => resetLaunchProtocols());
12
+
13
+ it('wraps ordinary mission text without changing string coercion', () => {
14
+ const wrapped = launchProtocol('Log in', {
15
+ id: 'authenticated:user',
16
+ probe: { url: 'https://example.test', selector: '#dashboard' },
17
+ });
18
+ expect(String(wrapped)).toBe('Log in');
19
+ expect(unwrapLaunchProtocol(wrapped)).toEqual({
20
+ goal: 'Log in',
21
+ protocol: {
22
+ id: 'authenticated:user',
23
+ probe: { url: 'https://example.test', selector: '#dashboard' },
24
+ },
25
+ });
26
+ });
27
+
28
+ it('tracks completed protocol ids for one process run', () => {
29
+ expect(hasCompletedLaunchProtocol('auth')).toBe(false);
30
+ markLaunchProtocolCompleted('auth');
31
+ expect(hasCompletedLaunchProtocol('auth')).toBe(true);
32
+ });
33
+
34
+ it('rejects incomplete metadata', () => {
35
+ expect(() => launchProtocol('x', { probe: { selector: '#ready' } })).toThrow(/id/);
36
+ expect(() => launchProtocol('x', { id: 'ready', probe: {} })).toThrow(/selector or text/);
37
+ });
38
+ });
@@ -11,15 +11,16 @@ vi.mock('../../core/config.js', () => ({
11
11
  enforceTurnBudget: vi.fn(),
12
12
  getRetryLimit: vi.fn(),
13
13
  getDomListLimit: vi.fn(),
14
+ getAutomaticDomLimit: vi.fn(),
14
15
  getResourceGuardConfig: vi.fn(),
15
16
  getHumanInputConfig: vi.fn(),
16
17
  }));
17
18
 
18
19
  import { runAgent } from '../../core/agent.js';
19
- import { loadConfig, enforceTurnBudget, getRetryLimit, getDomListLimit, getResourceGuardConfig, getHumanInputConfig } from '../../core/config.js';
20
+ import { loadConfig, enforceTurnBudget, getRetryLimit, getDomListLimit, getAutomaticDomLimit, getResourceGuardConfig, getHumanInputConfig } from '../../core/config.js';
20
21
 
21
22
  // Adjust the import path if your file lives elsewhere
22
- import { runMissions, __test__ as testronautInternals } from '../../runner/testronaut.js';
23
+ import { runMissions, launchProtocol, __test__ as testronautInternals } from '../../runner/testronaut.js';
23
24
 
24
25
  describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
25
26
  const OLD_ENV = { ...process.env };
@@ -29,6 +30,7 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
29
30
  // Default retry limit unless overridden in a test
30
31
  getRetryLimit.mockReturnValue({ value: 2, source: 'default', clamped: false });
31
32
  getDomListLimit.mockReturnValue({ value: 3, mode: 'number', source: 'default', clamped: false });
33
+ getAutomaticDomLimit.mockReturnValue({ value: 15000, source: 'default', clamped: false });
32
34
  getResourceGuardConfig.mockReturnValue({ enabled: true, hrefIncludes: ['/document/'], dataTypes: ['document'] });
33
35
  getHumanInputConfig.mockReturnValue({ enabled: true, timeoutSeconds: 60, source: { enabled: 'default', timeout: 'default' }, clamped: false });
34
36
  });
@@ -58,13 +60,38 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
58
60
  expect.stringContaining('Clamping to 200')
59
61
  );
60
62
  expect(runAgent).toHaveBeenCalledWith(
61
- expect.any(Array), 'Budgeted Run', 200, 2, { domListLimit: 3, debug: false, resourceGuard: { enabled: true, hrefIncludes: ['/document/'], dataTypes: ['document'] }, humanInput: { enabled: true, timeoutSeconds: 60, source: { enabled: 'default', timeout: 'default' }, clamped: false } }
63
+ expect.any(Array), 'Budgeted Run', 200, 2, { domListLimit: 3, automaticDomLimit: 15000, storageStatePath: undefined, debug: false, resourceGuard: { enabled: true, hrefIncludes: ['/document/'], dataTypes: ['document'] }, humanInput: { enabled: true, timeoutSeconds: 60, source: { enabled: 'default', timeout: 'default' }, clamped: false } }
62
64
  );
63
65
 
64
66
  warn.mockRestore();
65
67
  log.mockRestore();
66
68
  });
67
69
 
70
+ it('unwraps launch protocols while preserving preMission authoring', async () => {
71
+ loadConfig.mockResolvedValue({ maxTurns: 20 });
72
+ enforceTurnBudget.mockReturnValue({
73
+ effectiveMax: 20,
74
+ limits: { softMaxTurns: 50, hardMaxTurns: 200, hardMinTurns: 5 },
75
+ notes: [],
76
+ });
77
+ runAgent.mockResolvedValue([{ steps: [{ result: 'SUCCESS' }], status: 'passed' }]);
78
+ const protocol = launchProtocol('Log in', {
79
+ id: 'authenticated:user',
80
+ probe: { url: 'https://example.test', selector: '#dashboard' },
81
+ });
82
+
83
+ await runMissions({ preMission: protocol, mission: 'Test dashboard' }, 'Protocol mission');
84
+ const goals = runAgent.mock.calls[0][0];
85
+ expect(goals[0]).toMatchObject({
86
+ goal: 'Log in',
87
+ submissionType: 'premission',
88
+ launchProtocol: {
89
+ id: 'authenticated:user',
90
+ probe: { url: 'https://example.test', selector: '#dashboard' },
91
+ },
92
+ });
93
+ });
94
+
68
95
  it('does not warn when no notes are returned', async () => {
69
96
  loadConfig.mockResolvedValue({ maxTurns: 20 });
70
97
  enforceTurnBudget.mockReturnValue({
@@ -85,7 +112,7 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
85
112
  await runMissions({ mission: 'No warnings' }, 'Clean');
86
113
 
87
114
  expect(warn).not.toHaveBeenCalled();
88
- expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'Clean', 20, 3, { domListLimit: 3, debug: false, resourceGuard: { enabled: true, hrefIncludes: ['/document/'], dataTypes: ['document'] }, humanInput: { enabled: true, timeoutSeconds: 60, source: { enabled: 'default', timeout: 'default' }, clamped: false } });
115
+ expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'Clean', 20, 3, { domListLimit: 3, automaticDomLimit: 15000, storageStatePath: undefined, debug: false, resourceGuard: { enabled: true, hrefIncludes: ['/document/'], dataTypes: ['document'] }, humanInput: { enabled: true, timeoutSeconds: 60, source: { enabled: 'default', timeout: 'default' }, clamped: false } });
89
116
 
90
117
  warn.mockRestore();
91
118
  log.mockRestore();
@@ -213,7 +240,7 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
213
240
  expect(goals[1].submissionName).toMatch(/^My Mission/);
214
241
 
215
242
  // Effective max turns and retry limit passed through
216
- expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'My Mission', 15, 2, { domListLimit: 3, debug: false, resourceGuard: { enabled: true, hrefIncludes: ['/document/'], dataTypes: ['document'] }, humanInput: { enabled: true, timeoutSeconds: 60, source: { enabled: 'default', timeout: 'default' }, clamped: false } });
243
+ expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'My Mission', 15, 2, { domListLimit: 3, automaticDomLimit: 15000, storageStatePath: undefined, debug: false, resourceGuard: { enabled: true, hrefIncludes: ['/document/'], dataTypes: ['document'] }, humanInput: { enabled: true, timeoutSeconds: 60, source: { enabled: 'default', timeout: 'default' }, clamped: false } });
217
244
 
218
245
  log.mockRestore();
219
246
  });
@@ -252,7 +279,7 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
252
279
 
253
280
  await runMissions({ mission: 'Debug' }, 'Debug Mission');
254
281
 
255
- expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'Debug Mission', 20, 2, { domListLimit: 3, debug: true, resourceGuard: { enabled: true, hrefIncludes: ['/document/'], dataTypes: ['document'] }, humanInput: { enabled: true, timeoutSeconds: 60, source: { enabled: 'default', timeout: 'default' }, clamped: false } });
282
+ expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'Debug Mission', 20, 2, { domListLimit: 3, automaticDomLimit: 15000, storageStatePath: undefined, debug: true, resourceGuard: { enabled: true, hrefIncludes: ['/document/'], dataTypes: ['document'] }, humanInput: { enabled: true, timeoutSeconds: 60, source: { enabled: 'default', timeout: 'default' }, clamped: false } });
256
283
 
257
284
  log.mockRestore();
258
285
  });
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { ChromeBrowser } from '../../tools/chromeBrowser.js';
3
+
4
+ function makeBrowser({ visible = true, content = 'Mission Tasks' } = {}) {
5
+ const locator = {
6
+ first: () => locator,
7
+ waitFor: vi.fn(() => visible ? Promise.resolve() : Promise.reject(new Error('not visible'))),
8
+ textContent: vi.fn().mockResolvedValue(content),
9
+ };
10
+ const browser = Object.create(ChromeBrowser.prototype);
11
+ browser.page = {
12
+ goto: vi.fn().mockResolvedValue(undefined),
13
+ waitForLoadState: vi.fn().mockResolvedValue(undefined),
14
+ locator: vi.fn(() => locator),
15
+ url: vi.fn(() => 'https://example.test/dashboard'),
16
+ };
17
+ return { browser, locator };
18
+ }
19
+
20
+ describe('ChromeBrowser.probe', () => {
21
+ it('passes after navigating when selector and text match', async () => {
22
+ const { browser } = makeBrowser();
23
+ await expect(browser.probe({
24
+ url: 'https://example.test',
25
+ selector: '#dashboard',
26
+ text: 'Mission Tasks',
27
+ })).resolves.toMatchObject({ passed: true });
28
+ expect(browser.page.goto).toHaveBeenCalledWith('https://example.test', expect.any(Object));
29
+ });
30
+
31
+ it('fails without invoking an agent when the selector is not visible', async () => {
32
+ const { browser } = makeBrowser({ visible: false });
33
+ await expect(browser.probe({ selector: '#dashboard' })).resolves.toMatchObject({
34
+ passed: false,
35
+ reason: 'selector not visible: #dashboard',
36
+ });
37
+ });
38
+ });
@@ -16,6 +16,7 @@ import {
16
16
  collapseLongContent,
17
17
  chunkBySection,
18
18
  reduceRepeatedDivs,
19
+ reduceTableRows,
19
20
  removeObfuscatedClassNames,
20
21
  removeAutoGeneratedClasses,
21
22
  minifyDomWhitespace,
@@ -232,6 +233,17 @@ describe('domControl helpers', () => {
232
233
  });
233
234
  });
234
235
 
236
+ describe('reduceTableRows', () => {
237
+ it('keeps headers, trims body rows, and adds a marker', () => {
238
+ const $ = cheerio.load('<table><thead><tr><th>Name</th></tr></thead><tbody><tr><td>A</td></tr><tr><td>B</td></tr><tr><td>C</td></tr></tbody></table>');
239
+ reduceTableRows($, 2);
240
+ expect($('thead tr')).toHaveLength(1);
241
+ expect($('tbody > tr')).toHaveLength(3);
242
+ expect($('tbody > tr').eq(1).text()).toBe('B');
243
+ expect($('tbody > tr').eq(2).attr('data-collapsed')).toBe('true');
244
+ });
245
+ });
246
+
235
247
  describe('removeObfuscatedClassNames', () => {
236
248
  it('removes short and hashed-looking class names', () => {
237
249
  const html = `
@@ -5,6 +5,11 @@ describe('tools/toolSchema', () => {
5
5
  it('does not advertise the unimplemented get_dom_chunk tool', () => {
6
6
  expect(toolsSchema.map(tool => tool.function.name)).not.toContain('get_dom_chunk');
7
7
  });
8
+
9
+ it('allows get_dom calls to be scoped by selector', () => {
10
+ const entry = toolsSchema.find(tool => tool.function.name === 'get_dom');
11
+ expect(entry.function.parameters.properties.selector.type).toBe('string');
12
+ });
8
13
  it('includes resource_progress tool with empty params', () => {
9
14
  const entry = toolsSchema.find(t => t.function?.name === 'resource_progress');
10
15
  expect(entry?.type).toBe('function');
@@ -14,6 +14,7 @@ import * as cheerio from 'cheerio';
14
14
  import {
15
15
  chunkBySection,
16
16
  reduceRepeatedDivs,
17
+ reduceTableRows,
17
18
  removeAutoGeneratedClasses,
18
19
  removeNonContentTags,
19
20
  removeObfuscatedClassNames,
@@ -57,6 +58,7 @@ export class ChromeBrowser {
57
58
  this.domListLimit = options.domListLimit;
58
59
  this.debugEnabled = !!options.debug;
59
60
  this._domLimitLogged = false;
61
+ this.storageStatePath = options.storageStatePath;
60
62
 
61
63
  const defaultResourceGuard = {
62
64
  enabled: true,
@@ -601,9 +603,12 @@ export class ChromeBrowser {
601
603
 
602
604
  ensureDir(FILES_DIR); // ⬅️ make sure missions/files exists
603
605
 
604
- this.context = await this.browser.newContext({
605
- acceptDownloads: true // ⬅️ important for Playwright download handling
606
- });
606
+ const contextOptions = { acceptDownloads: true };
607
+ if (this.storageStatePath && fs.existsSync(this.storageStatePath)) {
608
+ contextOptions.storageState = this.storageStatePath;
609
+ this._debugLog('Restoring shared authentication state', { path: this.storageStatePath });
610
+ }
611
+ this.context = await this.browser.newContext(contextOptions);
607
612
  // this.context = await this.browser.newContext();
608
613
  const first = await this.context.newPage();
609
614
  const id = this._addAndFocus(first);
@@ -636,6 +641,26 @@ export class ChromeBrowser {
636
641
  return `navigated to ${this.page.url()}`;
637
642
  }
638
643
 
644
+ async probe({ url, selector, text, timeout = 5000 } = {}) {
645
+ if (url) {
646
+ await this.page.goto(url, { timeout: 30_000, waitUntil: 'domcontentloaded' });
647
+ await this.page.waitForLoadState('domcontentloaded').catch(() => {});
648
+ }
649
+
650
+ let locator = this.page.locator(selector || 'body').first();
651
+ if (selector) {
652
+ const visible = await locator.waitFor({ state: 'visible', timeout }).then(() => true).catch(() => false);
653
+ if (!visible) return { passed: false, reason: `selector not visible: ${selector}`, url: this.page.url() };
654
+ }
655
+ if (text) {
656
+ const content = await locator.textContent({ timeout }).catch(() => '');
657
+ if (!String(content || '').includes(String(text))) {
658
+ return { passed: false, reason: `text not found: ${text}`, url: this.page.url() };
659
+ }
660
+ }
661
+ return { passed: true, url: this.page.url() };
662
+ }
663
+
639
664
  async navigateEmailLink({ url, allowedHosts = [] }) {
640
665
  const destination = new URL(url);
641
666
  if (destination.protocol !== 'https:' || destination.username || destination.password) {
@@ -859,7 +884,7 @@ export class ChromeBrowser {
859
884
 
860
885
 
861
886
  async get_dom(
862
- { limit = 100000, exclude = true, focus = [], loadState = 'networkidle', timeout = 10000 } = {},
887
+ { limit = 100000, exclude = true, selector, focus = [], loadState = 'networkidle', timeout = 10000 } = {},
863
888
  agentMemory = {}
864
889
  ) {
865
890
  const matchesHrefPattern = (href = '') => {
@@ -985,9 +1010,21 @@ export class ChromeBrowser {
985
1010
  // --- 4) Optional DOM reduction/cleanup (your existing steps) ---
986
1011
  let $ = cheerio.load(html);
987
1012
 
1013
+ if (selector) {
1014
+ let selection;
1015
+ try {
1016
+ selection = $(selector);
1017
+ } catch (error) {
1018
+ throw new Error(`Invalid get_dom selector "${selector}": ${error.message}`);
1019
+ }
1020
+ if (!selection.length) return `[get_dom: no elements matched selector "${selector}"]`;
1021
+ $ = cheerio.load(selection.toString());
1022
+ }
1023
+
988
1024
  if (exclude) {
989
1025
  $ = await removeNonContentTags($);
990
1026
  $ = await reduceRepeatedDivs($, listLimit);
1027
+ reduceTableRows($, listLimit);
991
1028
  // If you later re-enable:
992
1029
  // $ = await removeObfuscatedClassNames($);
993
1030
  // $ = await removeAutoGeneratedClasses($);
@@ -1386,7 +1423,15 @@ export class ChromeBrowser {
1386
1423
 
1387
1424
 
1388
1425
  async close() {
1389
- await this.browser?.close();
1426
+ try {
1427
+ if (this.storageStatePath && this.context) {
1428
+ ensureDir(path.dirname(this.storageStatePath));
1429
+ await this.context.storageState({ path: this.storageStatePath, indexedDB: true });
1430
+ this._debugLog('Saved shared authentication state', { path: this.storageStatePath });
1431
+ }
1432
+ } finally {
1433
+ await this.browser?.close();
1434
+ }
1390
1435
  }
1391
1436
  }
1392
1437
 
@@ -123,7 +123,7 @@ export const chunkBySection = async ($, focus) => {
123
123
  footer: $('footer').html() || '',
124
124
  aside: $('aside').html() || '',
125
125
  body: $('body').html() || ''
126
- };
126
+ };
127
127
 
128
128
  if (Array.isArray(focus) && focus.length > 0) {
129
129
  return Object.fromEntries(
@@ -144,6 +144,25 @@ export const chunkBySection = async ($, focus) => {
144
144
 
145
145
  }
146
146
 
147
+ /** Keep table headers and only the first N body rows in each table. */
148
+ export function reduceTableRows($, maxAllowed = 3) {
149
+ let limit = maxAllowed === Infinity ? Infinity : Number(maxAllowed);
150
+ if (limit === Infinity) return;
151
+ limit = Number.isFinite(limit) ? Math.max(0, limit) : 3;
152
+
153
+ $('table').each((_, table) => {
154
+ const $table = $(table);
155
+ const rows = $table.find('tbody > tr');
156
+ const candidates = rows.length ? rows : $table.children('tr');
157
+ if (candidates.length <= limit) return;
158
+ candidates.slice(limit).remove();
159
+ const colspan = Math.max(1, $table.find('tr').first().children('th, td').length);
160
+ const marker = `<tr data-collapsed="true"><td colspan="${colspan}">[...truncated]</td></tr>`;
161
+ if (rows.length) $table.children('tbody').first().append(marker);
162
+ else $table.append(marker);
163
+ });
164
+ }
165
+
147
166
  /**
148
167
  * Reduce repeated sibling <div> blocks that share the same class name.
149
168
  * - For each parent <body> or <div>, group child divs by `class`.
@@ -102,7 +102,7 @@ export function generateHtmlReport(report, outputPath) {
102
102
  ${humanInput}
103
103
  ${planSpan}
104
104
  <span class="step-result ${ok ? 'ok' : 'bad'}" ${resultTooltip ? `title="${esc(resultTooltip)}"` : ''}>${esc(resultRaw)}</span>
105
- <span class="tokens">tokens: ${esc(step.tokensUsed ?? '—')} / total: ${esc(step.totalTokensUsed ?? '—')}</span>
105
+ <span class="tokens">tokens: ${esc(step.tokensUsed ?? '—')} (in: ${esc(step.inputTokens ?? '—')}, out: ${esc(step.outputTokens ?? '—')}) / rolling: ${esc(step.totalTokensUsed ?? '—')}</span>
106
106
  </summary>
107
107
  <pre class="events">${esc(events.join('\n')) || '(no events)'}</pre>
108
108
  ${imgTag}
@@ -83,6 +83,10 @@ const toolsSchema = [
83
83
  default: true,
84
84
  description: 'Whether to exclude noisy tags like script/style/etc.',
85
85
  },
86
+ selector: {
87
+ type: 'string',
88
+ description: 'Optional CSS selector that scopes the returned DOM to matching elements.',
89
+ },
86
90
  },
87
91
  },
88
92
  },