testronaut 1.3.1 → 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/bin/cli.js +126 -17
- package/bin/init.js +2 -0
- package/bin/initHelpers.js +8 -0
- package/core/agent.js +10 -3
- package/core/config.js +91 -0
- package/core/missionDiscovery.js +55 -0
- package/core/turnLoop.js +118 -0
- package/package.json +3 -2
- package/runner/testronaut.js +66 -2
- package/tests/binTests/cli.helpers.test.js +39 -0
- package/tests/binTests/init.test.js +6 -0
- package/tests/binTests/initHelpersTests/defaultConfig.test.js +3 -0
- package/tests/coreTests/config.test.js +96 -0
- package/tests/coreTests/missionDiscovery.test.js +81 -0
- package/tests/coreTests/turnLoop.test.js +46 -1
- package/tests/runnerTests/testronaut.test.js +70 -5
- package/tests/toolsTests/domControl.test.js +35 -0
- package/tests/toolsTests/toolSchema.test.js +24 -0
- package/tools/chromeBrowser.js +211 -4
- package/tools/domControl.js +10 -3
- package/tools/toolSchema.js +43 -1
package/runner/testronaut.js
CHANGED
|
@@ -23,9 +23,20 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
import fs from 'fs';
|
|
27
|
+
import path from 'path';
|
|
26
28
|
import { runAgent } from '../core/agent.js';
|
|
27
29
|
import { redactPasswordInText } from '../core/redaction.js';
|
|
28
|
-
import { loadConfig, enforceTurnBudget, getRetryLimit } from '../core/config.js';
|
|
30
|
+
import { loadConfig, enforceTurnBudget, getRetryLimit, getDomListLimit, getResourceGuardConfig } from '../core/config.js';
|
|
31
|
+
|
|
32
|
+
// Check process env for debug toggles (shared helper for tests and CLI).
|
|
33
|
+
const isDebugEnabled = () => {
|
|
34
|
+
const raw = String(process.env.TESTRONAUT_DEBUG || '').trim().toLowerCase();
|
|
35
|
+
return ['1', 'true', 'yes', 'on'].includes(raw);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Presentable string for DOM list limit logging/debug output.
|
|
39
|
+
const formatListLimit = (v) => v === Infinity ? 'all' : v;
|
|
29
40
|
|
|
30
41
|
/**
|
|
31
42
|
* Run a mission flow.
|
|
@@ -40,12 +51,56 @@ export async function runMissions({ preMission, mission, postMission }, missionN
|
|
|
40
51
|
const maxTurns = effectiveMax;
|
|
41
52
|
const retryInfo = getRetryLimit(cfg);
|
|
42
53
|
const retryLimit = retryInfo.value;
|
|
54
|
+
const domListLimitInfo = getDomListLimit(cfg);
|
|
55
|
+
const resourceGuard = getResourceGuardConfig(cfg);
|
|
56
|
+
const debugEnabled = isDebugEnabled();
|
|
43
57
|
if (notes.length) {
|
|
44
58
|
console.warn(notes.join('\n'));
|
|
45
59
|
}
|
|
46
60
|
if (retryInfo.clamped) {
|
|
47
61
|
console.warn(`⚠️ Retry limit clamped to ${retryLimit} (allowed range 1-10).`);
|
|
48
62
|
}
|
|
63
|
+
if (domListLimitInfo?.clamped) {
|
|
64
|
+
console.warn(`⚠️ DOM list limit clamped to ${domListLimitInfo.value} (allowed 0-100 or "all"/"none").`);
|
|
65
|
+
}
|
|
66
|
+
if (domListLimitInfo?.mode === 'all') {
|
|
67
|
+
console.warn('⚠️ DOM list limit is set to "all". This can dramatically increase token usage and may break flows on heavy pages.');
|
|
68
|
+
}
|
|
69
|
+
if (debugEnabled) {
|
|
70
|
+
const cfgDomRaw = cfg?.dom?.listItemLimit ?? cfg?.dom?.listLimit ?? cfg?.domListLimit;
|
|
71
|
+
console.log('[debug] Debug mode enabled');
|
|
72
|
+
console.log('[debug] Turn limits', {
|
|
73
|
+
maxTurns,
|
|
74
|
+
softMax: limits.softMaxTurns,
|
|
75
|
+
hardMax: limits.hardMaxTurns,
|
|
76
|
+
hardMin: limits.hardMinTurns,
|
|
77
|
+
});
|
|
78
|
+
console.log('[debug] Retry limit', { retryLimit, source: retryInfo.source, clamped: retryInfo.clamped });
|
|
79
|
+
console.log('[debug] DOM list limit', {
|
|
80
|
+
env: process.env.TESTRONAUT_DOM_LIST_LIMIT ?? 'unset',
|
|
81
|
+
config: cfgDomRaw ?? 'unset',
|
|
82
|
+
resolved: formatListLimit(domListLimitInfo?.value),
|
|
83
|
+
mode: domListLimitInfo?.mode,
|
|
84
|
+
source: domListLimitInfo?.source,
|
|
85
|
+
clamped: domListLimitInfo?.clamped,
|
|
86
|
+
});
|
|
87
|
+
console.log('[debug] Resource guard', {
|
|
88
|
+
enabled: resourceGuard.enabled,
|
|
89
|
+
hrefIncludes: resourceGuard.hrefIncludes,
|
|
90
|
+
dataTypes: resourceGuard.dataTypes,
|
|
91
|
+
});
|
|
92
|
+
// Helpful for upload missions: show missions/files contents
|
|
93
|
+
const filesDir = path.resolve(process.cwd(), 'missions/files');
|
|
94
|
+
try {
|
|
95
|
+
const entries = fs.readdirSync(filesDir);
|
|
96
|
+
const pdfs = entries.filter(f => f.toLowerCase().endsWith('.pdf'));
|
|
97
|
+
console.log(`[debug] missions/files dir: ${filesDir}`);
|
|
98
|
+
console.log(`[debug] missions/files entries (${entries.length}):`, entries);
|
|
99
|
+
if (pdfs.length) console.log(`[debug] missions/files PDFs (${pdfs.length}):`, pdfs);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.log(`[debug] missions/files not readable: ${err.message}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
49
104
|
|
|
50
105
|
// 2) Normalize submissions
|
|
51
106
|
const normalizeToArray = (x) => (Array.isArray(x) ? x : x ? [x] : []);
|
|
@@ -128,7 +183,13 @@ export async function runMissions({ preMission, mission, postMission }, missionN
|
|
|
128
183
|
);
|
|
129
184
|
|
|
130
185
|
// 3) Execute
|
|
131
|
-
const success = await runAgent(
|
|
186
|
+
const success = await runAgent(
|
|
187
|
+
goals,
|
|
188
|
+
missionName,
|
|
189
|
+
maxTurns,
|
|
190
|
+
retryLimit,
|
|
191
|
+
{ domListLimit: domListLimitInfo?.value, debug: debugEnabled, resourceGuard }
|
|
192
|
+
);
|
|
132
193
|
if (!success) {
|
|
133
194
|
console.log(`❌ Aborting after failed goal.`);
|
|
134
195
|
return;
|
|
@@ -141,3 +202,6 @@ export async function runMissions({ preMission, mission, postMission }, missionN
|
|
|
141
202
|
console.log('\n✅ Mission flow complete.');
|
|
142
203
|
return success;
|
|
143
204
|
}
|
|
205
|
+
|
|
206
|
+
// Exposed for unit tests.
|
|
207
|
+
export const __test__ = { isDebugEnabled, formatListLimit };
|
|
@@ -33,4 +33,43 @@ describe('cli helpers', () => {
|
|
|
33
33
|
expect(latest?.htmlFile).toBe('run_200.html');
|
|
34
34
|
expect(latest?.jsonFile).toBe('run_200.json');
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
it('parses booleans in a tolerant way', () => {
|
|
38
|
+
const { parseBool } = __test__;
|
|
39
|
+
expect(parseBool('true')).toBe(true);
|
|
40
|
+
expect(parseBool('0')).toBe(false);
|
|
41
|
+
expect(parseBool('off')).toBe(false);
|
|
42
|
+
expect(parseBool('')).toBeNull();
|
|
43
|
+
expect(parseBool('maybe')).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('parses vercel bypass flags and removes them from args', () => {
|
|
47
|
+
const { parseVercelBypassArgs } = __test__;
|
|
48
|
+
const res = parseVercelBypassArgs(['--dev', '--vercel-bypass=secret123', 'login']);
|
|
49
|
+
expect(res.secret).toBe('secret123');
|
|
50
|
+
expect(res.args).toEqual(['--dev', 'login']);
|
|
51
|
+
expect(res.invalid).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('parses vercel bypass value from next arg', () => {
|
|
55
|
+
const { parseVercelBypassArgs } = __test__;
|
|
56
|
+
const res = parseVercelBypassArgs(['--vercel_bypass', 'abc', 'login']);
|
|
57
|
+
expect(res.secret).toBe('abc');
|
|
58
|
+
expect(res.args).toEqual(['login']);
|
|
59
|
+
expect(res.invalid).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('handles missing vercel bypass value', () => {
|
|
63
|
+
const { parseVercelBypassArgs } = __test__;
|
|
64
|
+
const res = parseVercelBypassArgs(['--vercel-bypass']);
|
|
65
|
+
expect(res.secret).toBeUndefined();
|
|
66
|
+
expect(res.args).toEqual([]);
|
|
67
|
+
expect(res.invalid).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('builds the vercel bypass header when provided', () => {
|
|
71
|
+
const { createVercelBypassHeader } = __test__;
|
|
72
|
+
expect(createVercelBypassHeader('abc')).toEqual({ 'x-vercel-protection-bypass': 'abc' });
|
|
73
|
+
expect(createVercelBypassHeader('')).toEqual({});
|
|
74
|
+
});
|
|
36
75
|
});
|
|
@@ -77,6 +77,8 @@ describe('initializeTestronautProject', () => {
|
|
|
77
77
|
expect(cfg.initialized).toBe(true);
|
|
78
78
|
expect(cfg.outputDir).toBe('missions/mission_reports');
|
|
79
79
|
expect(typeof cfg.maxTurns).toBe('number');
|
|
80
|
+
expect(cfg.dom?.listItemLimit).toBe(3);
|
|
81
|
+
expect(cfg.resourceGuard?.enabled).toBe(true);
|
|
80
82
|
|
|
81
83
|
// .env
|
|
82
84
|
const envPath = path.join(temp, '.env');
|
|
@@ -108,6 +110,8 @@ describe('initializeTestronautProject', () => {
|
|
|
108
110
|
const cfg = readJson(path.join(temp, 'testronaut-config.json'));
|
|
109
111
|
expect(cfg.provider).toBe('gemini');
|
|
110
112
|
expect(cfg.model).toBe('gemini-2.5-flash');
|
|
113
|
+
expect(cfg.dom?.listItemLimit).toBe(3);
|
|
114
|
+
expect(cfg.resourceGuard?.enabled).toBe(true);
|
|
111
115
|
|
|
112
116
|
const envTxt = fs.readFileSync(path.join(temp, '.env'), 'utf8');
|
|
113
117
|
expect(envTxt).toMatch(/GEMINI_API_KEY=AIza/);
|
|
@@ -185,5 +189,7 @@ describe('initializeTestronautProject', () => {
|
|
|
185
189
|
expect(cfg.projectName).toBe('my-project'); // preserved
|
|
186
190
|
expect(cfg.outputDir).toBe('missions/mission_reports'); // default filled in
|
|
187
191
|
expect(cfg.maxTurns).toBe(20); // default filled in
|
|
192
|
+
expect(cfg.dom?.listItemLimit).toBe(3);
|
|
193
|
+
expect(cfg.resourceGuard?.enabled).toBe(true);
|
|
188
194
|
});
|
|
189
195
|
});
|
|
@@ -8,5 +8,8 @@ describe('defaultConfig', () => {
|
|
|
8
8
|
expect(cfg.outputDir).toBe('missions/mission_reports');
|
|
9
9
|
expect(cfg.projectName).toBe('my-app');
|
|
10
10
|
expect(cfg.maxTurns).toBe(20);
|
|
11
|
+
expect(cfg.dom?.listItemLimit).toBe(3);
|
|
12
|
+
expect(cfg.resourceGuard?.enabled).toBe(true);
|
|
13
|
+
expect(cfg.resourceGuard?.hrefIncludes).toContain('/document/');
|
|
11
14
|
});
|
|
12
15
|
});
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
getRetryLimit,
|
|
12
12
|
resolveTurnLimits,
|
|
13
13
|
enforceTurnBudget,
|
|
14
|
+
getDomListLimit,
|
|
15
|
+
getResourceGuardConfig,
|
|
14
16
|
} from '../../core/config.js';
|
|
15
17
|
|
|
16
18
|
describe('core/config', () => {
|
|
@@ -105,6 +107,100 @@ describe('core/config', () => {
|
|
|
105
107
|
});
|
|
106
108
|
});
|
|
107
109
|
|
|
110
|
+
describe('getDomListLimit', () => {
|
|
111
|
+
const OLD_ENV = { ...process.env };
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
process.env = { ...OLD_ENV };
|
|
114
|
+
delete process.env.TESTRONAUT_DOM_LIST_LIMIT;
|
|
115
|
+
});
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
process.env = { ...OLD_ENV };
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('returns default when missing', () => {
|
|
121
|
+
const res = getDomListLimit({});
|
|
122
|
+
expect(res.value).toBe(3);
|
|
123
|
+
expect(res.mode).toBe('number');
|
|
124
|
+
expect(res.source).toBe('default');
|
|
125
|
+
expect(res.clamped).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('uses config value (nested) and clamps to range', () => {
|
|
129
|
+
const res = getDomListLimit({ dom: { listItemLimit: 250 } });
|
|
130
|
+
expect(res.value).toBe(100);
|
|
131
|
+
expect(res.mode).toBe('number');
|
|
132
|
+
expect(res.source).toBe('config');
|
|
133
|
+
expect(res.clamped).toBe(true);
|
|
134
|
+
|
|
135
|
+
const nestedAlt = getDomListLimit({ dom: { listLimit: '9' } });
|
|
136
|
+
expect(nestedAlt.value).toBe(9);
|
|
137
|
+
expect(nestedAlt.source).toBe('config');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('prefers env override and accepts keywords', () => {
|
|
141
|
+
process.env.TESTRONAUT_DOM_LIST_LIMIT = 'all';
|
|
142
|
+
const allRes = getDomListLimit({ domListLimit: 2 });
|
|
143
|
+
expect(allRes.value).toBe(Infinity);
|
|
144
|
+
expect(allRes.mode).toBe('all');
|
|
145
|
+
expect(allRes.source).toBe('env');
|
|
146
|
+
|
|
147
|
+
process.env.TESTRONAUT_DOM_LIST_LIMIT = 'none';
|
|
148
|
+
const noneRes = getDomListLimit({ domListLimit: 5 });
|
|
149
|
+
expect(noneRes.value).toBe(0);
|
|
150
|
+
expect(noneRes.mode).toBe('none');
|
|
151
|
+
expect(noneRes.source).toBe('env');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('accepts numeric strings', () => {
|
|
155
|
+
const res = getDomListLimit({ domListLimit: '7' });
|
|
156
|
+
expect(res.value).toBe(7);
|
|
157
|
+
expect(res.mode).toBe('number');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('getResourceGuardConfig', () => {
|
|
162
|
+
const OLD_ENV = { ...process.env };
|
|
163
|
+
beforeEach(() => {
|
|
164
|
+
process.env = { ...OLD_ENV };
|
|
165
|
+
delete process.env.TESTRONAUT_RESOURCE_GUARD;
|
|
166
|
+
delete process.env.TESTRONAUT_RESOURCE_HREF_PATTERNS;
|
|
167
|
+
delete process.env.TESTRONAUT_RESOURCE_DATA_TYPES;
|
|
168
|
+
});
|
|
169
|
+
afterEach(() => {
|
|
170
|
+
process.env = { ...OLD_ENV };
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('returns defaults when nothing is set', () => {
|
|
174
|
+
const res = getResourceGuardConfig({});
|
|
175
|
+
expect(res.enabled).toBe(true);
|
|
176
|
+
expect(res.hrefIncludes.length).toBeGreaterThan(0);
|
|
177
|
+
expect(res.dataTypes).toContain('document');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('respects env toggles and lists', () => {
|
|
181
|
+
process.env.TESTRONAUT_RESOURCE_GUARD = 'false';
|
|
182
|
+
process.env.TESTRONAUT_RESOURCE_HREF_PATTERNS = '/a,/b';
|
|
183
|
+
process.env.TESTRONAUT_RESOURCE_DATA_TYPES = 'doc,file';
|
|
184
|
+
const res = getResourceGuardConfig({ resourceGuard: { enabled: true } });
|
|
185
|
+
expect(res.enabled).toBe(false);
|
|
186
|
+
expect(res.hrefIncludes).toEqual(['/a', '/b']);
|
|
187
|
+
expect(res.dataTypes).toEqual(['doc', 'file']);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('falls back to config arrays when env missing', () => {
|
|
191
|
+
const res = getResourceGuardConfig({
|
|
192
|
+
resourceGuard: {
|
|
193
|
+
enabled: 'yes',
|
|
194
|
+
hrefIncludes: ['/x', '/y'],
|
|
195
|
+
dataTypes: ['row'],
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
expect(res.enabled).toBe(true);
|
|
199
|
+
expect(res.hrefIncludes).toEqual(['/x', '/y']);
|
|
200
|
+
expect(res.dataTypes).toEqual(['row']);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
108
204
|
describe('enforceTurnBudget (lenient)', () => {
|
|
109
205
|
it('clamps > hardMaxTurns', () => {
|
|
110
206
|
const cfg = { maxTurns: 1000 };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { discoverMissionFiles } from '../../core/missionDiscovery.js';
|
|
6
|
+
|
|
7
|
+
function makeTempDir() {
|
|
8
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'testronaut-missions-'));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function writeJson(p, obj) {
|
|
12
|
+
fs.writeFileSync(p, JSON.stringify(obj, null, 2));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const temps = [];
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
while (temps.length) {
|
|
19
|
+
const dir = temps.pop();
|
|
20
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('discoverMissionFiles', () => {
|
|
25
|
+
it('falls back to legacy discovery when missions block is missing', async () => {
|
|
26
|
+
const tmp = makeTempDir();
|
|
27
|
+
temps.push(tmp);
|
|
28
|
+
const missionsDir = path.join(tmp, 'missions');
|
|
29
|
+
fs.mkdirSync(missionsDir, { recursive: true });
|
|
30
|
+
fs.writeFileSync(path.join(missionsDir, 'alpha.mission.js'), '// ok');
|
|
31
|
+
fs.writeFileSync(path.join(missionsDir, 'beta.txt'), '// ignore');
|
|
32
|
+
fs.mkdirSync(path.join(missionsDir, 'nested'), { recursive: true });
|
|
33
|
+
fs.writeFileSync(path.join(missionsDir, 'nested', 'gamma.mission.js'), '// nested');
|
|
34
|
+
fs.writeFileSync(path.join(missionsDir, 'delta.mission.ts'), '// ts not included by default');
|
|
35
|
+
|
|
36
|
+
const res = await discoverMissionFiles({ cwd: tmp });
|
|
37
|
+
expect(res.root).toBe(path.resolve(tmp, 'missions'));
|
|
38
|
+
expect(res.files).toEqual(['alpha.mission.js']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('respects missions.root with include patterns', async () => {
|
|
42
|
+
const tmp = makeTempDir();
|
|
43
|
+
temps.push(tmp);
|
|
44
|
+
const missionsDir = path.join(tmp, 'custom');
|
|
45
|
+
fs.mkdirSync(path.join(missionsDir, 'nested'), { recursive: true });
|
|
46
|
+
fs.writeFileSync(path.join(missionsDir, 'root.mission.js'), '// root');
|
|
47
|
+
fs.writeFileSync(path.join(missionsDir, 'nested', 'deep.mission.ts'), '// nested ts');
|
|
48
|
+
|
|
49
|
+
writeJson(path.join(tmp, 'testronaut-config.json'), {
|
|
50
|
+
missions: {
|
|
51
|
+
root: './custom',
|
|
52
|
+
include: ['*.mission.js', 'nested/**/*.mission.ts'],
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const res = await discoverMissionFiles({ cwd: tmp });
|
|
57
|
+
expect(res.root).toBe(path.resolve(tmp, 'custom'));
|
|
58
|
+
expect(res.files).toEqual(['nested/deep.mission.ts', 'root.mission.js']);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('applies missions.exclude after includes', async () => {
|
|
62
|
+
const tmp = makeTempDir();
|
|
63
|
+
temps.push(tmp);
|
|
64
|
+
const missionsDir = path.join(tmp, 'custom');
|
|
65
|
+
fs.mkdirSync(path.join(missionsDir, 'ignore'), { recursive: true });
|
|
66
|
+
fs.writeFileSync(path.join(missionsDir, 'keep.mission.js'), '// keep');
|
|
67
|
+
fs.writeFileSync(path.join(missionsDir, 'ignore', 'skip.mission.js'), '// skip');
|
|
68
|
+
fs.writeFileSync(path.join(missionsDir, 'keep.wip.mission.js'), '// skip by pattern');
|
|
69
|
+
|
|
70
|
+
writeJson(path.join(tmp, 'testronaut-config.json'), {
|
|
71
|
+
missions: {
|
|
72
|
+
root: './custom',
|
|
73
|
+
include: ['**/*.mission.js'],
|
|
74
|
+
exclude: ['ignore/**', '**/*.wip.mission.js'],
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const res = await discoverMissionFiles({ cwd: tmp });
|
|
79
|
+
expect(res.files).toEqual(['keep.mission.js']);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -22,6 +22,7 @@ const { shared } = vi.hoisted(() => {
|
|
|
22
22
|
mode: 'save',
|
|
23
23
|
})
|
|
24
24
|
),
|
|
25
|
+
list_local_files: vi.fn(async () => JSON.stringify({ files: ['a.pdf', 'b.pdf'] })),
|
|
25
26
|
},
|
|
26
27
|
},
|
|
27
28
|
};
|
|
@@ -63,6 +64,7 @@ vi.mock('../../tools/toolSchema.js', () => ({
|
|
|
63
64
|
{ type: 'function', function: { name: 'click_text', description: 'click by text', parameters: {} } },
|
|
64
65
|
{ type: 'function', function: { name: 'get_dom', description: 'get DOM html', parameters: {} } },
|
|
65
66
|
{ type: 'function', function: { name: 'screenshot', description: 'take a screenshot', parameters: {} } },
|
|
67
|
+
{ type: 'function', function: { name: 'list_local_files', description: 'list files', parameters: {} } },
|
|
66
68
|
],
|
|
67
69
|
}));
|
|
68
70
|
|
|
@@ -96,7 +98,7 @@ vi.mock('../../core/redaction.js', () => ({
|
|
|
96
98
|
}));
|
|
97
99
|
|
|
98
100
|
// Import SUT after mocks
|
|
99
|
-
import { turnLoop } from '../../core/turnLoop.js';
|
|
101
|
+
import { turnLoop, __docProgressInternals } from '../../core/turnLoop.js';
|
|
100
102
|
|
|
101
103
|
// Helper
|
|
102
104
|
function baseMessages() {
|
|
@@ -226,4 +228,47 @@ describe('turnLoop', () => {
|
|
|
226
228
|
expect(res.steps[1].result).toBe('✅ Passed');
|
|
227
229
|
expect(res.steps[2].result).toMatch(/Success/);
|
|
228
230
|
});
|
|
231
|
+
|
|
232
|
+
describe('__docProgressInternals helpers', () => {
|
|
233
|
+
const { parseDocListFromDom, extractDocIdFromUrl, ensureDocProgress } = __docProgressInternals;
|
|
234
|
+
|
|
235
|
+
it('parses injected doc list summary', () => {
|
|
236
|
+
const html = `<pre data-testronaut-doc-list>
|
|
237
|
+
List
|
|
238
|
+
- [123] File A /document/123
|
|
239
|
+
- [456] File B /document/456
|
|
240
|
+
</pre>`;
|
|
241
|
+
const { items, scriptDocs } = parseDocListFromDom(html);
|
|
242
|
+
expect(items).toHaveLength(2);
|
|
243
|
+
expect(items[0].id).toBe('123');
|
|
244
|
+
expect(items[1].title.length).toBeGreaterThan(0);
|
|
245
|
+
expect(scriptDocs).toBe(2);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('extracts doc id from url and tracks progress', () => {
|
|
249
|
+
expect(extractDocIdFromUrl('https://x.com/document/999/foo')).toBe('999');
|
|
250
|
+
expect(extractDocIdFromUrl('nope')).toBe('');
|
|
251
|
+
|
|
252
|
+
const mem = {};
|
|
253
|
+
const cfg = { enabled: true, hrefIncludes: ['/doc'], dataTypes: ['doc'] };
|
|
254
|
+
const prog = ensureDocProgress(mem, cfg);
|
|
255
|
+
expect(prog.patterns).toBe(cfg);
|
|
256
|
+
prog.items = [{ id: '1', title: 'Doc 1' }];
|
|
257
|
+
prog.downloaded.add('1');
|
|
258
|
+
expect(prog.downloaded.has('1')).toBe(true);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('updates progress from list_local_files results', () => {
|
|
262
|
+
const mem = {};
|
|
263
|
+
const cfg = { enabled: true, hrefIncludes: ['/doc'], dataTypes: ['doc'] };
|
|
264
|
+
const prog = ensureDocProgress(mem, cfg);
|
|
265
|
+
prog.items = [];
|
|
266
|
+
// simulate list_local_files JSON result
|
|
267
|
+
const parsed = JSON.parse(JSON.stringify({ files: ['x.pdf', 'y.pdf'] }));
|
|
268
|
+
prog.items = parsed.files.map(f => ({ id: f, title: f, href: f }));
|
|
269
|
+
prog.downloaded.add('x.pdf');
|
|
270
|
+
expect(prog.items).toHaveLength(2);
|
|
271
|
+
expect(prog.downloaded.has('x.pdf')).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
229
274
|
});
|
|
@@ -10,19 +10,25 @@ vi.mock('../../core/config.js', () => ({
|
|
|
10
10
|
loadConfig: vi.fn(),
|
|
11
11
|
enforceTurnBudget: vi.fn(),
|
|
12
12
|
getRetryLimit: vi.fn(),
|
|
13
|
+
getDomListLimit: vi.fn(),
|
|
14
|
+
getResourceGuardConfig: vi.fn(),
|
|
13
15
|
}));
|
|
14
16
|
|
|
15
17
|
import { runAgent } from '../../core/agent.js';
|
|
16
|
-
import { loadConfig, enforceTurnBudget, getRetryLimit } from '../../core/config.js';
|
|
18
|
+
import { loadConfig, enforceTurnBudget, getRetryLimit, getDomListLimit, getResourceGuardConfig } from '../../core/config.js';
|
|
17
19
|
|
|
18
20
|
// Adjust the import path if your file lives elsewhere
|
|
19
|
-
import { runMissions } from '../../runner/testronaut.js';
|
|
21
|
+
import { runMissions, __test__ as testronautInternals } from '../../runner/testronaut.js';
|
|
20
22
|
|
|
21
23
|
describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
|
|
24
|
+
const OLD_ENV = { ...process.env };
|
|
22
25
|
beforeEach(() => {
|
|
26
|
+
process.env = { ...OLD_ENV };
|
|
23
27
|
vi.clearAllMocks();
|
|
24
28
|
// Default retry limit unless overridden in a test
|
|
25
29
|
getRetryLimit.mockReturnValue({ value: 2, source: 'default', clamped: false });
|
|
30
|
+
getDomListLimit.mockReturnValue({ value: 3, mode: 'number', source: 'default', clamped: false });
|
|
31
|
+
getResourceGuardConfig.mockReturnValue({ enabled: true, hrefIncludes: ['/document/'], dataTypes: ['document'] });
|
|
26
32
|
});
|
|
27
33
|
|
|
28
34
|
it('passes effectiveMax to runAgent and logs any notes', async () => {
|
|
@@ -50,7 +56,7 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
|
|
|
50
56
|
expect.stringContaining('Clamping to 200')
|
|
51
57
|
);
|
|
52
58
|
expect(runAgent).toHaveBeenCalledWith(
|
|
53
|
-
expect.any(Array), 'Budgeted Run', 200, 2
|
|
59
|
+
expect.any(Array), 'Budgeted Run', 200, 2, { domListLimit: 3, debug: false, resourceGuard: { enabled: true, hrefIncludes: ['/document/'], dataTypes: ['document'] } }
|
|
54
60
|
);
|
|
55
61
|
|
|
56
62
|
warn.mockRestore();
|
|
@@ -77,7 +83,33 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
|
|
|
77
83
|
await runMissions({ mission: 'No warnings' }, 'Clean');
|
|
78
84
|
|
|
79
85
|
expect(warn).not.toHaveBeenCalled();
|
|
80
|
-
expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'Clean', 20, 3);
|
|
86
|
+
expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'Clean', 20, 3, { domListLimit: 3, debug: false, resourceGuard: { enabled: true, hrefIncludes: ['/document/'], dataTypes: ['document'] } });
|
|
87
|
+
|
|
88
|
+
warn.mockRestore();
|
|
89
|
+
log.mockRestore();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('warns when the DOM list limit is clamped', async () => {
|
|
93
|
+
loadConfig.mockResolvedValue({});
|
|
94
|
+
enforceTurnBudget.mockReturnValue({
|
|
95
|
+
effectiveMax: 20,
|
|
96
|
+
limits: {},
|
|
97
|
+
notes: [],
|
|
98
|
+
strict: false,
|
|
99
|
+
});
|
|
100
|
+
getDomListLimit.mockReturnValue({ value: 100, mode: 'number', source: 'config', clamped: true });
|
|
101
|
+
|
|
102
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
103
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
104
|
+
runAgent.mockResolvedValue([
|
|
105
|
+
{ steps: [{ result: 'SUCCESS: ok' }], status: 'passed' },
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
await runMissions({ mission: 'Clamp lists' }, 'Clamp Mission');
|
|
109
|
+
|
|
110
|
+
expect(warn).toHaveBeenCalledWith(
|
|
111
|
+
expect.stringContaining('DOM list limit clamped to 100')
|
|
112
|
+
);
|
|
81
113
|
|
|
82
114
|
warn.mockRestore();
|
|
83
115
|
log.mockRestore();
|
|
@@ -144,7 +176,7 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
|
|
|
144
176
|
expect(goals[1].submissionName).toMatch(/^My Mission/);
|
|
145
177
|
|
|
146
178
|
// Effective max turns and retry limit passed through
|
|
147
|
-
expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'My Mission', 15, 2);
|
|
179
|
+
expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'My Mission', 15, 2, { domListLimit: 3, debug: false, resourceGuard: { enabled: true, hrefIncludes: ['/document/'], dataTypes: ['document'] } });
|
|
148
180
|
|
|
149
181
|
log.mockRestore();
|
|
150
182
|
});
|
|
@@ -166,4 +198,37 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
|
|
|
166
198
|
|
|
167
199
|
log.mockRestore();
|
|
168
200
|
});
|
|
201
|
+
|
|
202
|
+
it('passes debug flag through when TESTRONAUT_DEBUG is set', async () => {
|
|
203
|
+
process.env.TESTRONAUT_DEBUG = 'true';
|
|
204
|
+
loadConfig.mockResolvedValue({});
|
|
205
|
+
enforceTurnBudget.mockReturnValue({
|
|
206
|
+
effectiveMax: 20,
|
|
207
|
+
limits: {},
|
|
208
|
+
notes: [],
|
|
209
|
+
strict: false,
|
|
210
|
+
});
|
|
211
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
212
|
+
runAgent.mockResolvedValue([
|
|
213
|
+
{ steps: [{ result: 'SUCCESS: ok' }], status: 'passed' },
|
|
214
|
+
]);
|
|
215
|
+
|
|
216
|
+
await runMissions({ mission: 'Debug' }, 'Debug Mission');
|
|
217
|
+
|
|
218
|
+
expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'Debug Mission', 20, 2, { domListLimit: 3, debug: true, resourceGuard: { enabled: true, hrefIncludes: ['/document/'], dataTypes: ['document'] } });
|
|
219
|
+
|
|
220
|
+
log.mockRestore();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('__test__ helpers', () => {
|
|
224
|
+
it('formats list limits and reads debug env', () => {
|
|
225
|
+
const { isDebugEnabled, formatListLimit } = testronautInternals;
|
|
226
|
+
process.env.TESTRONAUT_DEBUG = 'on';
|
|
227
|
+
expect(isDebugEnabled()).toBe(true);
|
|
228
|
+
process.env.TESTRONAUT_DEBUG = '0';
|
|
229
|
+
expect(isDebugEnabled()).toBe(false);
|
|
230
|
+
expect(formatListLimit(Infinity)).toBe('all');
|
|
231
|
+
expect(formatListLimit(5)).toBe(5);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
169
234
|
});
|
|
@@ -195,6 +195,41 @@ describe('domControl helpers', () => {
|
|
|
195
195
|
// Total children: 2 cards + 1 other + 1 marker
|
|
196
196
|
expect(rootChildren.length).toBe(4);
|
|
197
197
|
});
|
|
198
|
+
|
|
199
|
+
it('keeps all repeated divs when maxAllowed is Infinity', () => {
|
|
200
|
+
const html = `
|
|
201
|
+
<div id="root">
|
|
202
|
+
<div class="card">1</div>
|
|
203
|
+
<div class="card">2</div>
|
|
204
|
+
<div class="card">3</div>
|
|
205
|
+
<div class="card">4</div>
|
|
206
|
+
</div>
|
|
207
|
+
`;
|
|
208
|
+
const $ = cheerio.load(html);
|
|
209
|
+
|
|
210
|
+
reduceRepeatedDivs($, Infinity);
|
|
211
|
+
|
|
212
|
+
const cardDivs = $('#root').children('div.card');
|
|
213
|
+
expect(cardDivs.length).toBe(4);
|
|
214
|
+
expect($('#root').children('div[data-collapsed="true"]').length).toBe(0);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('removes all repeated divs when maxAllowed is 0', () => {
|
|
218
|
+
const html = `
|
|
219
|
+
<div id="root">
|
|
220
|
+
<div class="card">A</div>
|
|
221
|
+
<div class="card">B</div>
|
|
222
|
+
</div>
|
|
223
|
+
`;
|
|
224
|
+
const $ = cheerio.load(html);
|
|
225
|
+
|
|
226
|
+
reduceRepeatedDivs($, 0);
|
|
227
|
+
|
|
228
|
+
const cardDivs = $('#root').children('div.card');
|
|
229
|
+
expect(cardDivs.length).toBe(0);
|
|
230
|
+
const markers = $('#root').children('div[data-collapsed="true"]');
|
|
231
|
+
expect(markers.length).toBe(1);
|
|
232
|
+
});
|
|
198
233
|
});
|
|
199
234
|
|
|
200
235
|
describe('removeObfuscatedClassNames', () => {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import toolsSchema from '../../tools/toolSchema.js';
|
|
3
|
+
|
|
4
|
+
describe('tools/toolSchema', () => {
|
|
5
|
+
it('includes resource_progress tool with empty params', () => {
|
|
6
|
+
const entry = toolsSchema.find(t => t.function?.name === 'resource_progress');
|
|
7
|
+
expect(entry?.type).toBe('function');
|
|
8
|
+
expect(entry.function.parameters).toEqual({ type: 'object', properties: {} });
|
|
9
|
+
expect(entry.function.description).toMatch(/remaining/i);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('includes list_local_files tool', () => {
|
|
13
|
+
const entry = toolsSchema.find(t => t.function?.name === 'list_local_files');
|
|
14
|
+
expect(entry?.type).toBe('function');
|
|
15
|
+
expect(entry.function.parameters?.properties).toHaveProperty('dir');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('defines navigate and download_file with required fields', () => {
|
|
19
|
+
const nav = toolsSchema.find(t => t.function?.name === 'navigate');
|
|
20
|
+
const download = toolsSchema.find(t => t.function?.name === 'download_file');
|
|
21
|
+
expect(nav?.function?.parameters?.required).toContain('url');
|
|
22
|
+
expect(download?.function?.parameters?.properties).toHaveProperty('url');
|
|
23
|
+
});
|
|
24
|
+
});
|