testronaut 1.9.4 → 1.10.1
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/.github/workflows/periapsis-vulnerability-daily.yml +1 -2
- package/.github/workflows/periapsis-vulnerability-pr-check.yml +1 -3
- package/README.md +26 -1
- package/TODO.md +11 -0
- package/bin/cli.js +45 -13
- package/bin/initHelpers.js +1 -0
- package/core/agent.js +75 -2
- package/core/config.js +16 -0
- package/core/launchProtocol.js +42 -0
- package/core/turnLoop.js +7 -3
- package/llm/anthropic/anthropicProvider.js +2 -0
- package/llm/gemini/geminiProvider.js +2 -0
- package/llm/openAI/openaiProvider.js +2 -0
- package/package.json +1 -1
- package/runner/testronaut.js +24 -8
- package/tests/binTests/cli.integration.test.js +26 -1
- package/tests/coreTests/agent.test.js +61 -0
- package/tests/coreTests/config.test.js +10 -0
- package/tests/coreTests/launchProtocol.test.js +38 -0
- package/tests/runnerTests/testronaut.test.js +33 -6
- package/tests/toolsTests/chromeBrowserProbe.test.js +38 -0
- package/tests/toolsTests/domControl.test.js +12 -0
- package/tests/toolsTests/toolSchema.test.js +5 -0
- package/tools/chromeBrowser.js +50 -5
- package/tools/domControl.js +20 -1
- package/tools/generateHtmlReport.js +1 -1
- package/tools/toolSchema.js +4 -0
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
858
|
-
|
|
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,
|
package/bin/initHelpers.js
CHANGED
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 =
|
|
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
package/runner/testronaut.js
CHANGED
|
@@ -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
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
{
|
|
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');
|
package/tools/chromeBrowser.js
CHANGED
|
@@ -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
|
-
|
|
605
|
-
|
|
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
|
-
|
|
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
|
|
package/tools/domControl.js
CHANGED
|
@@ -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 ?? '—')} /
|
|
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}
|
package/tools/toolSchema.js
CHANGED
|
@@ -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
|
},
|