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 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/cliTests/cli.helpers.test.js
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 if both exist
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 byTurn = new Map(); // turn -> index in out
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
- if (!byTurn.has(turn)) {
96
+ const key = `${turn}::${attempt}`;
97
+
98
+ if (!byKey.has(key)) {
87
99
  out.push(s);
88
- byTurn.set(turn, out.length - 1);
100
+ byKey.set(key, out.length - 1);
89
101
  } else {
90
- const idx = byTurn.get(turn);
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` ascending, then by original order via array index already preserved
105
- out.sort((a, b) => (a.turn ?? 0) - (b.turn ?? 0));
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
- const missionsDir = path.resolve(process.cwd(), 'missions');
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
- if (!fs.existsSync(missionsDir)) {
246
- console.error('❌ No `missions` directory found.');
347
+ const { root: missionsRoot, files: discoveredMissions } = await discoverMissionFiles({ cwd: process.cwd() });
348
+
349
+ if (!fs.existsSync(missionsRoot)) {
350
+ console.error(`❌ Missions directory not found: ${path.relative(process.cwd(), missionsRoot)}`);
247
351
  process.exit(1);
248
352
  }
249
353
 
250
354
  const runFile = async (filePath) => {
251
355
  try {
252
- const modulePath = path.resolve(missionsDir, filePath);
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 all *.mission.js files
275
- const files = fs.readdirSync(missionsDir).filter(f => f.endsWith('.mission.js'));
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)
@@ -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 seen = new Set();
170
- const out = [];
171
- for (const s of steps) {
172
- const first = s?.events?.[0] || '';
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
- return out;
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
+ }