testronaut 1.3.0 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/bin/cli.js +124 -16
- package/bin/init.js +2 -0
- package/bin/initHelpers.js +8 -0
- package/core/agent.js +21 -13
- package/core/config.js +121 -0
- package/core/missionDiscovery.js +55 -0
- package/core/turnLoop.js +169 -8
- package/package.json +3 -2
- package/runner/testronaut.js +71 -2
- package/tests/binTests/cli.helpers.test.js +45 -0
- package/tests/binTests/init.test.js +6 -0
- package/tests/binTests/initHelpersTests/defaultConfig.test.js +3 -0
- package/tests/coreTests/config.test.js +137 -0
- package/tests/coreTests/missionDiscovery.test.js +81 -0
- package/tests/coreTests/turnLoop.test.js +100 -3
- package/tests/runnerTests/testronaut.test.js +76 -6
- package/tests/toolsTests/domControl.test.js +35 -0
- package/tests/toolsTests/generateHtmlReport.test.js +90 -0
- package/tests/toolsTests/toolSchema.test.js +24 -0
- package/tools/chromeBrowser.js +211 -4
- package/tools/domControl.js +10 -3
- package/tools/generateHtmlReport.js +38 -4
- package/tools/toolSchema.js +43 -1
package/README.md
CHANGED
|
@@ -178,6 +178,8 @@ Each includes:
|
|
|
178
178
|
- **LLMs** for reasoning, DOM parsing, and tool use
|
|
179
179
|
- **Token throttling** + adaptive cooldowns
|
|
180
180
|
- **Extensible architecture** for custom tools and workflows
|
|
181
|
+
- **DOM trimming controls** to cap list sizes (env `TESTRONAUT_DOM_LIST_LIMIT` or config `dom.listItemLimit`; use `all` cautiously—it can spike token use)
|
|
182
|
+
- **Resource guard** to ensure full list/table downloads (config `resourceGuard` or env `TESTRONAUT_RESOURCE_*`)
|
|
181
183
|
|
|
182
184
|
---
|
|
183
185
|
|
package/bin/cli.js
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* Test strategy:
|
|
30
30
|
* To test pure helpers without running the whole CLI, we export a tiny test bundle:
|
|
31
31
|
* export const __test__ = { guessMimeType, safeJoin, findLatestReportPair, pkgManagerForCwd }
|
|
32
|
-
* See tests in tests/
|
|
32
|
+
* See tests in tests/toolsTests/cli.helpers.test.js
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import fs from 'fs';
|
|
@@ -47,6 +47,7 @@ import { promisify } from 'util';
|
|
|
47
47
|
const exec = promisify(execCmd);
|
|
48
48
|
import url from 'url';
|
|
49
49
|
import { ensureBrowsers } from '../tools/playwrightSetup.js';
|
|
50
|
+
import { discoverMissionFiles } from '../core/missionDiscovery.js';
|
|
50
51
|
|
|
51
52
|
// Keep PW browsers inside the project to avoid global cache skew
|
|
52
53
|
process.env.PLAYWRIGHT_BROWSERS_PATH = process.env.PLAYWRIGHT_BROWSERS_PATH || '0';
|
|
@@ -60,6 +61,11 @@ const apiBase = 'http://api.testronaut.app'; // Replace with your actual API bas
|
|
|
60
61
|
|
|
61
62
|
const args = process.argv.slice(2);
|
|
62
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Read a JSONL steps file into an array of objects.
|
|
66
|
+
* @param {string} p
|
|
67
|
+
* @returns {object[]|null}
|
|
68
|
+
*/
|
|
63
69
|
function readJsonlSteps(p) {
|
|
64
70
|
if (!p || !fs.existsSync(p)) return null;
|
|
65
71
|
const lines = fs.readFileSync(p, 'utf8').split(/\r?\n/).filter(Boolean);
|
|
@@ -73,21 +79,27 @@ function readJsonlSteps(p) {
|
|
|
73
79
|
/**
|
|
74
80
|
* Merge duplicate turns without losing information.
|
|
75
81
|
* Strategy:
|
|
76
|
-
* - Keep the latest **non-empty** version for a given turn
|
|
82
|
+
* - Keep the latest **non-empty** version for a given (turn,retry) pair
|
|
77
83
|
* - If both are non-empty, keep the later one (last write wins)
|
|
78
84
|
* - Preserve original order by line index as a tiebreaker
|
|
85
|
+
*
|
|
86
|
+
* @param {Array} steps
|
|
87
|
+
* @returns {Array}
|
|
79
88
|
*/
|
|
80
89
|
function mergeDuplicateTurns(steps) {
|
|
81
90
|
const out = [];
|
|
82
|
-
const
|
|
91
|
+
const byKey = new Map(); // composite key -> index in out
|
|
83
92
|
for (const s of steps) {
|
|
84
93
|
const turn = Number.isFinite(s.turn) ? s.turn : out.length;
|
|
94
|
+
const attempt = Number.isFinite(s.retryAttempt) ? s.retryAttempt : 1;
|
|
85
95
|
const hasEvents = Array.isArray(s.events) && s.events.length > 0;
|
|
86
|
-
|
|
96
|
+
const key = `${turn}::${attempt}`;
|
|
97
|
+
|
|
98
|
+
if (!byKey.has(key)) {
|
|
87
99
|
out.push(s);
|
|
88
|
-
|
|
100
|
+
byKey.set(key, out.length - 1);
|
|
89
101
|
} else {
|
|
90
|
-
const idx =
|
|
102
|
+
const idx = byKey.get(key);
|
|
91
103
|
const existing = out[idx];
|
|
92
104
|
const existingHasEvents = Array.isArray(existing.events) && existing.events.length > 0;
|
|
93
105
|
// Prefer the one with events; if both have events, prefer the newer (s)
|
|
@@ -101,11 +113,28 @@ function mergeDuplicateTurns(steps) {
|
|
|
101
113
|
}
|
|
102
114
|
}
|
|
103
115
|
}
|
|
104
|
-
// stable sort by `turn`
|
|
105
|
-
out.sort((a, b) =>
|
|
116
|
+
// stable sort by `turn` then `retryAttempt`, then by original order
|
|
117
|
+
out.sort((a, b) => {
|
|
118
|
+
const ta = a.turn ?? 0;
|
|
119
|
+
const tb = b.turn ?? 0;
|
|
120
|
+
if (ta !== tb) return ta - tb;
|
|
121
|
+
const ra = Number.isFinite(a.retryAttempt) ? a.retryAttempt : 1;
|
|
122
|
+
const rb = Number.isFinite(b.retryAttempt) ? b.retryAttempt : 1;
|
|
123
|
+
if (ra !== rb) return ra - rb;
|
|
124
|
+
return 0;
|
|
125
|
+
});
|
|
106
126
|
return out;
|
|
107
127
|
}
|
|
108
128
|
|
|
129
|
+
// Expose a small bundle for unit tests (helper-only; not the CLI flow)
|
|
130
|
+
export const __test__ = {
|
|
131
|
+
guessMimeType,
|
|
132
|
+
safeJoin,
|
|
133
|
+
findLatestReportPair,
|
|
134
|
+
mergeDuplicateTurns,
|
|
135
|
+
readJsonlSteps,
|
|
136
|
+
};
|
|
137
|
+
|
|
109
138
|
// Look for --model=<id> or --model <id>
|
|
110
139
|
let modelOverride;
|
|
111
140
|
const modelFlagIndex = args.findIndex(a => a === '--model' || a.startsWith('--model='));
|
|
@@ -125,6 +154,48 @@ if (modelFlagIndex >= 0) {
|
|
|
125
154
|
args.splice(modelFlagIndex, modelOverride ? 2 : 1);
|
|
126
155
|
}
|
|
127
156
|
|
|
157
|
+
// Look for --debug / --debug=<bool> / --no-debug
|
|
158
|
+
// Normalize CLI boolean strings into true/false/null for optional flags.
|
|
159
|
+
const parseBool = (v) => {
|
|
160
|
+
const lower = String(v ?? '').trim().toLowerCase();
|
|
161
|
+
if (!lower) return null;
|
|
162
|
+
if (['1', 'true', 'yes', 'on'].includes(lower)) return true;
|
|
163
|
+
if (['0', 'false', 'no', 'off'].includes(lower)) return false;
|
|
164
|
+
return null;
|
|
165
|
+
};
|
|
166
|
+
__test__.parseBool = parseBool;
|
|
167
|
+
|
|
168
|
+
let debugOverride;
|
|
169
|
+
let debugConsumesNext = false;
|
|
170
|
+
const debugFlagIndex = args.findIndex(a =>
|
|
171
|
+
a === '--debug' ||
|
|
172
|
+
a.startsWith('--debug=') ||
|
|
173
|
+
a === '--no-debug'
|
|
174
|
+
);
|
|
175
|
+
if (debugFlagIndex >= 0) {
|
|
176
|
+
const rawArg = args[debugFlagIndex];
|
|
177
|
+
if (rawArg.includes('=')) {
|
|
178
|
+
debugOverride = parseBool(rawArg.split('=')[1]);
|
|
179
|
+
} else if (rawArg === '--no-debug') {
|
|
180
|
+
debugOverride = false;
|
|
181
|
+
} else if (args[debugFlagIndex + 1] && !args[debugFlagIndex + 1].startsWith('-')) {
|
|
182
|
+
debugOverride = parseBool(args[debugFlagIndex + 1]);
|
|
183
|
+
debugConsumesNext = true;
|
|
184
|
+
} else {
|
|
185
|
+
debugOverride = true; // bare --debug enables it
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (debugOverride !== null) {
|
|
189
|
+
process.env.TESTRONAUT_DEBUG = debugOverride ? '1' : '0';
|
|
190
|
+
console.log(`🛠️ Debug mode ${debugOverride ? 'enabled' : 'disabled'} (TESTRONAUT_DEBUG=${process.env.TESTRONAUT_DEBUG})`);
|
|
191
|
+
} else {
|
|
192
|
+
console.warn('⚠️ Invalid --debug value. Use true/false, 1/0, yes/no.');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const consume = 1 + (debugConsumesNext && debugOverride !== null ? 1 : 0);
|
|
196
|
+
args.splice(debugFlagIndex, consume);
|
|
197
|
+
}
|
|
198
|
+
|
|
128
199
|
// Look for --turns=<n> or --turns <n>
|
|
129
200
|
let turnsOverride;
|
|
130
201
|
const turnsFlagIndex = args.findIndex(a => a === '--turns' || a.startsWith('--turns='));
|
|
@@ -149,7 +220,36 @@ if (turnsFlagIndex >= 0) {
|
|
|
149
220
|
args.splice(turnsFlagIndex, turnsOverride ? 2 : 1);
|
|
150
221
|
}
|
|
151
222
|
|
|
152
|
-
|
|
223
|
+
// Look for --retry_limit / --retry-limit
|
|
224
|
+
let retryOverride;
|
|
225
|
+
const retryFlagIndex = args.findIndex(a =>
|
|
226
|
+
a === '--retry_limit' ||
|
|
227
|
+
a.startsWith('--retry_limit=') ||
|
|
228
|
+
a === '--retry-limit' ||
|
|
229
|
+
a.startsWith('--retry-limit=')
|
|
230
|
+
);
|
|
231
|
+
if (retryFlagIndex >= 0) {
|
|
232
|
+
const rawArg = args[retryFlagIndex];
|
|
233
|
+
if (rawArg.includes('=')) {
|
|
234
|
+
retryOverride = rawArg.split('=')[1];
|
|
235
|
+
} else if (args[retryFlagIndex + 1]) {
|
|
236
|
+
retryOverride = args[retryFlagIndex + 1];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (retryOverride) {
|
|
240
|
+
const n = Number(retryOverride.trim());
|
|
241
|
+
if (Number.isFinite(n)) {
|
|
242
|
+
const clamped = Math.min(10, Math.max(1, n));
|
|
243
|
+
process.env.TESTRONAUT_RETRY_LIMIT = String(clamped);
|
|
244
|
+
console.log(`🔁 Retry limit override: ${process.env.TESTRONAUT_RETRY_LIMIT} (allowed 1-10)`);
|
|
245
|
+
} else {
|
|
246
|
+
console.warn(`⚠️ Invalid --retry_limit value "${retryOverride}". Ignoring.`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Remove flag & value so they aren’t treated as filenames
|
|
251
|
+
args.splice(retryFlagIndex, retryOverride ? 2 : 1);
|
|
252
|
+
}
|
|
153
253
|
|
|
154
254
|
const allResults = [];
|
|
155
255
|
const runId = `run_${Date.now()}`;
|
|
@@ -169,7 +269,9 @@ Usage:
|
|
|
169
269
|
Options:
|
|
170
270
|
--init Scaffold project folders and a welcome mission
|
|
171
271
|
--turns=<n> Override max turns for this run (e.g., --turns=30)
|
|
272
|
+
--debug[=<bool>] Enable verbose debug logs (or set TESTRONAUT_DEBUG=1)
|
|
172
273
|
--help Show this help message
|
|
274
|
+
--retry_limit=<n> Override agent turn retry limits (minimum 1, maximum 10)
|
|
173
275
|
|
|
174
276
|
Examples:
|
|
175
277
|
npx testronaut
|
|
@@ -242,14 +344,16 @@ if (args.includes('serve') || args.includes('view')) {
|
|
|
242
344
|
await new Promise(() => {}); // ✅ never resolves; Ctrl+C will terminate
|
|
243
345
|
}
|
|
244
346
|
|
|
245
|
-
|
|
246
|
-
|
|
347
|
+
const { root: missionsRoot, files: discoveredMissions } = await discoverMissionFiles({ cwd: process.cwd() });
|
|
348
|
+
|
|
349
|
+
if (!fs.existsSync(missionsRoot)) {
|
|
350
|
+
console.error(`❌ Missions directory not found: ${path.relative(process.cwd(), missionsRoot)}`);
|
|
247
351
|
process.exit(1);
|
|
248
352
|
}
|
|
249
353
|
|
|
250
354
|
const runFile = async (filePath) => {
|
|
251
355
|
try {
|
|
252
|
-
const modulePath = path.resolve(
|
|
356
|
+
const modulePath = path.resolve(missionsRoot, filePath);
|
|
253
357
|
const missionsModule = await import(`file://${modulePath}`);
|
|
254
358
|
|
|
255
359
|
if (typeof missionsModule.executeMission === 'function') {
|
|
@@ -271,9 +375,8 @@ if (args.length > 0) {
|
|
|
271
375
|
await runFile(file);
|
|
272
376
|
}
|
|
273
377
|
} else {
|
|
274
|
-
// Run
|
|
275
|
-
const
|
|
276
|
-
for (const file of files) {
|
|
378
|
+
// Run missions discovered from config (or default behavior)
|
|
379
|
+
for (const file of discoveredMissions) {
|
|
277
380
|
await runFile(file);
|
|
278
381
|
}
|
|
279
382
|
}
|
|
@@ -720,6 +823,11 @@ function safeJoin(root, relUrlPath) {
|
|
|
720
823
|
return resolved;
|
|
721
824
|
}
|
|
722
825
|
|
|
826
|
+
/**
|
|
827
|
+
* Best-effort cross-platform URL opener.
|
|
828
|
+
* @param {string} targetUrl
|
|
829
|
+
* @returns {Promise<void>}
|
|
830
|
+
*/
|
|
723
831
|
async function openInBrowser(targetUrl) {
|
|
724
832
|
const platform = process.platform;
|
|
725
833
|
const quoted = `"${targetUrl}"`;
|
|
@@ -848,4 +956,4 @@ try {
|
|
|
848
956
|
}
|
|
849
957
|
} catch (err) {
|
|
850
958
|
console.warn(`⚠️ Could not remove tmp folder: ${err.message}`);
|
|
851
|
-
}
|
|
959
|
+
}
|
package/bin/init.js
CHANGED
|
@@ -139,6 +139,8 @@ export async function initializeTestronautProject() {
|
|
|
139
139
|
config.outputDir = config.outputDir || defaults.outputDir;
|
|
140
140
|
config.projectName = config.projectName || defaults.projectName;
|
|
141
141
|
config.maxTurns = config.maxTurns ?? defaults.maxTurns;
|
|
142
|
+
config.dom = { ...defaults.dom, ...(config.dom || {}) };
|
|
143
|
+
config.resourceGuard = { ...defaults.resourceGuard, ...(config.resourceGuard || {}) };
|
|
142
144
|
|
|
143
145
|
// ─────────────────────────────────────────────
|
|
144
146
|
// STEP 4: Persist config and scaffold .env (first run only)
|
package/bin/initHelpers.js
CHANGED
|
@@ -52,6 +52,14 @@ export function defaultConfig(rootBasename) {
|
|
|
52
52
|
outputDir: 'missions/mission_reports',
|
|
53
53
|
projectName: rootBasename,
|
|
54
54
|
maxTurns: 20,
|
|
55
|
+
dom: {
|
|
56
|
+
listItemLimit: 3,
|
|
57
|
+
},
|
|
58
|
+
resourceGuard: {
|
|
59
|
+
enabled: true,
|
|
60
|
+
hrefIncludes: ['/document/', '/file/', '/download', '/attachment/'],
|
|
61
|
+
dataTypes: ['document', 'file', 'item', 'row'],
|
|
62
|
+
},
|
|
55
63
|
};
|
|
56
64
|
}
|
|
57
65
|
|
package/core/agent.js
CHANGED
|
@@ -35,10 +35,16 @@ import path from 'path';
|
|
|
35
35
|
* @param {Array<{goal:any, submissionType?:string, submissionName?:string, label?:string}>} goals
|
|
36
36
|
* @param {string} missionName
|
|
37
37
|
* @param {number} [maxTurns=20] - upper bound for turnLoop per goal
|
|
38
|
+
* @param {number} [retryLimit]
|
|
39
|
+
* @param {{ domListLimit?: number|typeof Infinity, debug?: boolean, resourceGuard?: { enabled:boolean, hrefIncludes:string[], dataTypes:string[] } }} [opts]
|
|
38
40
|
* @returns {Promise<Array<{missionName:string, submissionType:string, submissionName:string|null, status:'passed'|'failed', steps:any[], startTime:number, endTime:number}>>}
|
|
39
41
|
*/
|
|
40
|
-
export async function runAgent(goals, missionName, maxTurns = 20) {
|
|
41
|
-
const browser = new ChromeBrowser(
|
|
42
|
+
export async function runAgent(goals, missionName, maxTurns = 20, retryLimit, opts = {}) {
|
|
43
|
+
const browser = new ChromeBrowser({
|
|
44
|
+
domListLimit: opts.domListLimit,
|
|
45
|
+
debug: opts.debug,
|
|
46
|
+
resourceGuard: opts.resourceGuard,
|
|
47
|
+
});
|
|
42
48
|
await browser.start();
|
|
43
49
|
let result;
|
|
44
50
|
|
|
@@ -52,6 +58,7 @@ export async function runAgent(goals, missionName, maxTurns = 20) {
|
|
|
52
58
|
|
|
53
59
|
for (const goal of goals) {
|
|
54
60
|
const steps = [];
|
|
61
|
+
const stepsArchive = [];
|
|
55
62
|
// Unique-ish JSONL file for this mission’s steps
|
|
56
63
|
const stepFile = path.join(
|
|
57
64
|
tmpDir,
|
|
@@ -152,34 +159,35 @@ Use Ground Control as your persistent mission memory about:
|
|
|
152
159
|
null, // currentStep
|
|
153
160
|
{
|
|
154
161
|
steps,
|
|
162
|
+
stepsArchive,
|
|
155
163
|
missionName,
|
|
164
|
+
retryLimit,
|
|
156
165
|
groundControl,
|
|
166
|
+
resourceGuard: opts.resourceGuard,
|
|
157
167
|
onStep: (s) => {
|
|
158
168
|
// Append each step as a JSON line
|
|
159
169
|
try {
|
|
160
170
|
fs.appendFileSync(stepFile, JSON.stringify(s) + '\n');
|
|
161
171
|
// Keep memory in check: retain only the last ~20 steps in RAM
|
|
162
172
|
if (steps.length > 20) steps.splice(0, steps.length - 20);
|
|
173
|
+
stepsArchive.push(s);
|
|
163
174
|
} catch {} // best-effort; don’t crash the agent
|
|
164
175
|
}
|
|
165
176
|
}
|
|
166
177
|
);
|
|
167
178
|
|
|
168
179
|
function dedupeSteps(steps) {
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const key = `${s.turn}::${s.summary}::${first}`;
|
|
174
|
-
if (!seen.has(key)) {
|
|
175
|
-
seen.add(key);
|
|
176
|
-
out.push(s);
|
|
180
|
+
const copy = [...steps];
|
|
181
|
+
copy.sort((a, b) => {
|
|
182
|
+
if (Number.isFinite(a?._seq) && Number.isFinite(b?._seq)) {
|
|
183
|
+
return a._seq - b._seq;
|
|
177
184
|
}
|
|
178
|
-
|
|
179
|
-
|
|
185
|
+
return (a?.turn ?? 0) - (b?.turn ?? 0);
|
|
186
|
+
});
|
|
187
|
+
return copy;
|
|
180
188
|
}
|
|
181
189
|
|
|
182
|
-
const compact = dedupeSteps(steps);
|
|
190
|
+
const compact = dedupeSteps(stepsArchive.length ? stepsArchive : steps);
|
|
183
191
|
|
|
184
192
|
// Snapshot steps (avoid retaining references to mutable arrays)
|
|
185
193
|
missionResults.push({
|
package/core/config.js
CHANGED
|
@@ -52,6 +52,127 @@ export function getMaxTurns(cfg, fallback = 20) {
|
|
|
52
52
|
return n;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Resolve retry limit per turn (attempts - 1) with clamping.
|
|
57
|
+
* Priority: env TESTRONAUT_RETRY_LIMIT → config.retryLimit → fallback
|
|
58
|
+
*
|
|
59
|
+
* @param {object} cfg
|
|
60
|
+
* @param {number} [fallback=2]
|
|
61
|
+
* @returns {{ value:number, source:'env'|'config'|'default', clamped:boolean }}
|
|
62
|
+
*/
|
|
63
|
+
export function getRetryLimit(cfg, fallback = 2) {
|
|
64
|
+
const clampRetry = (n) => {
|
|
65
|
+
const clamped = Math.min(10, Math.max(1, n));
|
|
66
|
+
return { value: clamped, clamped: clamped !== n };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const envVal = Number(process.env.TESTRONAUT_RETRY_LIMIT);
|
|
70
|
+
if (Number.isFinite(envVal)) {
|
|
71
|
+
const { value, clamped } = clampRetry(envVal);
|
|
72
|
+
return { value, source: 'env', clamped };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const cfgVal = Number(cfg?.retryLimit);
|
|
76
|
+
if (Number.isFinite(cfgVal)) {
|
|
77
|
+
const { value, clamped } = clampRetry(cfgVal);
|
|
78
|
+
return { value, source: 'config', clamped };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { value, clamped } = clampRetry(fallback);
|
|
82
|
+
return { value, source: 'default', clamped };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolve how many list-like items to keep in DOM snapshots.
|
|
87
|
+
* - Accepts numbers (clamped 0-100), or the strings "all"/"none".
|
|
88
|
+
* - Priority: env TESTRONAUT_DOM_LIST_LIMIT → config.dom.listItemLimit/listLimit/domListLimit → fallback.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} cfg
|
|
91
|
+
* @param {number} [fallback=3]
|
|
92
|
+
* @returns {{ value:number|typeof Infinity, mode:'number'|'all'|'none', source:'env'|'config'|'default', clamped:boolean }}
|
|
93
|
+
*/
|
|
94
|
+
export function getDomListLimit(cfg, fallback = 3) {
|
|
95
|
+
const clampList = (n) => {
|
|
96
|
+
const clamped = Math.min(100, Math.max(0, n));
|
|
97
|
+
return { value: clamped, clamped: clamped !== n };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const parseRaw = (raw) => {
|
|
101
|
+
if (raw === undefined || raw === null) return null;
|
|
102
|
+
|
|
103
|
+
if (typeof raw === 'string') {
|
|
104
|
+
const lower = raw.trim().toLowerCase();
|
|
105
|
+
if (!lower) return null;
|
|
106
|
+
if (lower === 'all') return { value: Infinity, mode: 'all', clamped: false };
|
|
107
|
+
if (lower === 'none') return { value: 0, mode: 'none', clamped: false };
|
|
108
|
+
const num = Number(lower);
|
|
109
|
+
if (!Number.isFinite(num)) return null;
|
|
110
|
+
const { value, clamped } = clampList(num);
|
|
111
|
+
return { value, mode: value === 0 ? 'none' : 'number', clamped };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
|
115
|
+
const { value, clamped } = clampList(raw);
|
|
116
|
+
return { value, mode: value === 0 ? 'none' : 'number', clamped };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return null;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const envParsed = parseRaw(process.env.TESTRONAUT_DOM_LIST_LIMIT);
|
|
123
|
+
if (envParsed) return { ...envParsed, source: 'env' };
|
|
124
|
+
|
|
125
|
+
const cfgRaw = cfg?.dom?.listItemLimit ?? cfg?.dom?.listLimit ?? cfg?.domListLimit;
|
|
126
|
+
const cfgParsed = parseRaw(cfgRaw);
|
|
127
|
+
if (cfgParsed) return { ...cfgParsed, source: 'config' };
|
|
128
|
+
|
|
129
|
+
const fbParsed = parseRaw(fallback);
|
|
130
|
+
return { ...fbParsed, source: 'default' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve resource guard settings for generic list/table harvesting.
|
|
135
|
+
* - Patterns are used to detect resource anchors/data attributes.
|
|
136
|
+
* - Enabled can be toggled via config.resourceGuard.enabled or TESTRONAUT_RESOURCE_GUARD.
|
|
137
|
+
*/
|
|
138
|
+
export function getResourceGuardConfig(cfg) {
|
|
139
|
+
const parseBool = (raw) => {
|
|
140
|
+
if (raw === undefined || raw === null) return null;
|
|
141
|
+
const lower = String(raw).trim().toLowerCase();
|
|
142
|
+
if (['1', 'true', 'yes', 'on'].includes(lower)) return true;
|
|
143
|
+
if (['0', 'false', 'no', 'off'].includes(lower)) return false;
|
|
144
|
+
return null;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const defaultHrefPatterns = ['/document/', '/file/', '/download', '/attachment/'];
|
|
148
|
+
const defaultDataTypes = ['document', 'file', 'item', 'row'];
|
|
149
|
+
|
|
150
|
+
const envEnabled = parseBool(process.env.TESTRONAUT_RESOURCE_GUARD);
|
|
151
|
+
const cfgEnabled = parseBool(cfg?.resourceGuard?.enabled);
|
|
152
|
+
const enabled = envEnabled ?? cfgEnabled ?? true;
|
|
153
|
+
|
|
154
|
+
const parseList = (raw) => {
|
|
155
|
+
if (Array.isArray(raw)) return raw.map(String).map(s => s.trim()).filter(Boolean);
|
|
156
|
+
if (typeof raw === 'string') {
|
|
157
|
+
return raw.split(',').map(s => s.trim()).filter(Boolean);
|
|
158
|
+
}
|
|
159
|
+
return [];
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const hrefIncludes = parseList(process.env.TESTRONAUT_RESOURCE_HREF_PATTERNS)
|
|
163
|
+
|| [];
|
|
164
|
+
const dataTypes = parseList(process.env.TESTRONAUT_RESOURCE_DATA_TYPES)
|
|
165
|
+
|| [];
|
|
166
|
+
|
|
167
|
+
const cfgHref = parseList(cfg?.resourceGuard?.hrefIncludes);
|
|
168
|
+
const cfgTypes = parseList(cfg?.resourceGuard?.dataTypes);
|
|
169
|
+
|
|
170
|
+
const finalHref = (hrefIncludes.length ? hrefIncludes : cfgHref.length ? cfgHref : defaultHrefPatterns).map(s => s.toLowerCase());
|
|
171
|
+
const finalTypes = (dataTypes.length ? dataTypes : cfgTypes.length ? cfgTypes : defaultDataTypes).map(s => s.toLowerCase());
|
|
172
|
+
|
|
173
|
+
return { enabled, hrefIncludes: finalHref, dataTypes: finalTypes };
|
|
174
|
+
}
|
|
175
|
+
|
|
55
176
|
/**
|
|
56
177
|
* Baseline limits for mission runs. Adjust here for global defaults.
|
|
57
178
|
*
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fg from 'fast-glob';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_ROOT = 'missions';
|
|
7
|
+
const DEFAULT_INCLUDE = ['**/*.mission.js', '**/*.mission.ts'];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Discover mission files based on testronaut-config.json.
|
|
11
|
+
* - When no missions block exists, preserve legacy behavior:
|
|
12
|
+
* read the ./missions folder (non-recursive) for *.mission.js files.
|
|
13
|
+
* - When missions is defined, apply root/include/exclude globs relative to root.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} opts
|
|
16
|
+
* @param {string} [opts.cwd=process.cwd()] - project root for config + resolution
|
|
17
|
+
* @returns {Promise<{ root:string, files:string[] }>} root is absolute; files are relative to root
|
|
18
|
+
*/
|
|
19
|
+
export async function discoverMissionFiles({ cwd = process.cwd() } = {}) {
|
|
20
|
+
const defaultRoot = path.resolve(cwd, DEFAULT_ROOT);
|
|
21
|
+
const cfg = await loadConfig(cwd);
|
|
22
|
+
const missionsCfg = cfg?.missions;
|
|
23
|
+
|
|
24
|
+
// Legacy behavior: no missions block → non-recursive *.mission.js in ./missions
|
|
25
|
+
if (!missionsCfg) {
|
|
26
|
+
if (!fs.existsSync(defaultRoot)) {
|
|
27
|
+
return { root: defaultRoot, files: [] };
|
|
28
|
+
}
|
|
29
|
+
const files = fs.readdirSync(defaultRoot)
|
|
30
|
+
.filter(f => f.endsWith('.mission.js'))
|
|
31
|
+
.sort();
|
|
32
|
+
return { root: defaultRoot, files };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const root = path.resolve(cwd, missionsCfg.root || DEFAULT_ROOT);
|
|
36
|
+
if (!fs.existsSync(root)) {
|
|
37
|
+
return { root, files: [] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const include = Array.isArray(missionsCfg.include) && missionsCfg.include.length
|
|
41
|
+
? missionsCfg.include
|
|
42
|
+
: DEFAULT_INCLUDE;
|
|
43
|
+
const exclude = Array.isArray(missionsCfg.exclude) ? missionsCfg.exclude : [];
|
|
44
|
+
|
|
45
|
+
const matches = await fg(include, {
|
|
46
|
+
cwd: root,
|
|
47
|
+
ignore: exclude,
|
|
48
|
+
onlyFiles: true,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const unique = Array.from(new Set(matches));
|
|
52
|
+
unique.sort();
|
|
53
|
+
|
|
54
|
+
return { root, files: unique };
|
|
55
|
+
}
|