testronaut 1.11.0 → 1.12.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.
Files changed (37) hide show
  1. package/README.md +226 -0
  2. package/TODO.md +5 -0
  3. package/bin/cli.js +232 -5
  4. package/bin/terminalUi.js +9 -1
  5. package/core/agent.js +7 -2
  6. package/core/browserEvidence.js +184 -0
  7. package/core/jevRouting.js +77 -0
  8. package/core/jevShadow.js +325 -0
  9. package/core/jevSuiteSelection.js +39 -0
  10. package/core/jevUsage.js +62 -0
  11. package/core/missionDiscovery.js +56 -6
  12. package/core/optimization.js +98 -0
  13. package/core/turnLoop.js +232 -11
  14. package/experiments/CROSS_PROVIDER.md +19 -0
  15. package/experiments/README.md +62 -0
  16. package/experiments/buildExperimentPublicationPackage.js +219 -0
  17. package/experiments/buildOptimizationDataPackage.js +390 -0
  18. package/experiments/crossProviderBenchmark.js +227 -0
  19. package/experiments/optimizationBenchmark.js +339 -0
  20. package/package.json +5 -1
  21. package/tests/binTests/cli.helpers.test.js +16 -0
  22. package/tests/binTests/cli.integration.test.js +18 -1
  23. package/tests/binTests/terminalUi.test.js +2 -1
  24. package/tests/coreTests/agent.test.js +2 -0
  25. package/tests/coreTests/browserEvidence.test.js +70 -0
  26. package/tests/coreTests/jevRouting.test.js +57 -0
  27. package/tests/coreTests/jevShadow.test.js +158 -0
  28. package/tests/coreTests/jevSuiteSelection.test.js +24 -0
  29. package/tests/coreTests/jevUsage.test.js +58 -0
  30. package/tests/coreTests/missionDiscovery.test.js +44 -0
  31. package/tests/coreTests/optimization.test.js +80 -0
  32. package/tests/coreTests/turnLoop.test.js +351 -1
  33. package/tests/experimentTests/buildOptimizationDataPackage.test.js +25 -0
  34. package/tests/experimentTests/crossProviderBenchmark.test.js +37 -0
  35. package/tests/experimentTests/optimizationBenchmark.test.js +49 -0
  36. package/tests/toolsTests/generateHtmlReport.test.js +100 -0
  37. package/tools/generateHtmlReport.js +67 -10
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
9
+ const DEFAULT_CLI = path.resolve(HERE, '../bin/cli.js');
10
+ const DEFAULT_REPOS = {
11
+ openai: '/home/shane/workspace/testronaut-examples',
12
+ gemini: '/home/shane/workspace/e2e-testing-collegium',
13
+ };
14
+
15
+ export const BENCHMARK_TARGETS = {
16
+ openai: {
17
+ provider: 'openai',
18
+ repo: DEFAULT_REPOS.openai,
19
+ missions: [
20
+ 'missions/login.mission.js',
21
+ 'missions/removeTask.mission.js',
22
+ 'missions/fileTransfer.mission.js',
23
+ ],
24
+ conditions: [
25
+ { id: 'l1_terra_control', level: 1, primary: 'gpt-5.6-terra', objective: 'off', mode: 'off' },
26
+ { id: 'l2_terra_guarded', level: 2, primary: 'gpt-5.6-terra', objective: 'tokens', mode: 'live' },
27
+ { id: 'l2_luna_guarded', level: 2, primary: 'gpt-5.6-luna', objective: 'tokens', mode: 'live' },
28
+ { id: 'l2_gpt55_guarded', level: 2, primary: 'gpt-5.5', objective: 'tokens', mode: 'live' },
29
+ { id: 'l3_terra_luna', level: 3, primary: 'gpt-5.6-terra', secondary: 'gpt-5.6-luna', objective: 'cost', mode: 'live' },
30
+ { id: 'l4_terra_gpt55', level: 4, primary: 'gpt-5.6-terra', secondary: 'gpt-5.5', objective: 'cost', mode: 'live' },
31
+ { id: 'l4_terra_gpt54', level: 4, primary: 'gpt-5.6-terra', secondary: 'gpt-5.4', objective: 'cost', mode: 'live' },
32
+ ],
33
+ },
34
+ gemini: {
35
+ provider: 'gemini',
36
+ repo: DEFAULT_REPOS.gemini,
37
+ missions: [
38
+ 'missions/profileNavigation.mission.js',
39
+ 'missions/projectsDashboard.mission.js',
40
+ 'missions/projectWorkspaceNavigation.mission.js',
41
+ ],
42
+ conditions: [
43
+ { id: 'l1_gemini38_control', level: 1, primary: 'gemini-3.8-flash', objective: 'off', mode: 'off' },
44
+ { id: 'l2_gemini38_guarded', level: 2, primary: 'gemini-3.8-flash', objective: 'tokens', mode: 'live' },
45
+ { id: 'l2_gemini36_guarded', level: 2, primary: 'gemini-3.6-flash', objective: 'tokens', mode: 'live' },
46
+ { id: 'l2_gemini35lite_guarded', level: 2, primary: 'gemini-3.5-flash-lite', objective: 'tokens', mode: 'live' },
47
+ { id: 'l3_gemini38_gemini37', level: 3, primary: 'gemini-3.8-flash', secondary: 'gemini-3.7-flash', objective: 'cost', mode: 'live' },
48
+ { id: 'l4_gemini38_gemini36', level: 4, primary: 'gemini-3.8-flash', secondary: 'gemini-3.6-flash', objective: 'cost', mode: 'live' },
49
+ { id: 'l4_gemini38_gemini35lite', level: 4, primary: 'gemini-3.8-flash', secondary: 'gemini-3.5-flash-lite', objective: 'cost', mode: 'live' },
50
+ ],
51
+ },
52
+ };
53
+
54
+ function hashSeed(value) {
55
+ let hash = 2166136261;
56
+ for (const char of String(value)) {
57
+ hash ^= char.charCodeAt(0);
58
+ hash = Math.imul(hash, 16777619);
59
+ }
60
+ return hash >>> 0;
61
+ }
62
+
63
+ function seededRandom(seed) {
64
+ let value = seed || 1;
65
+ return () => {
66
+ value += 0x6D2B79F5;
67
+ let next = value;
68
+ next = Math.imul(next ^ (next >>> 15), next | 1);
69
+ next ^= next + Math.imul(next ^ (next >>> 7), next | 61);
70
+ return ((next ^ (next >>> 14)) >>> 0) / 4294967296;
71
+ };
72
+ }
73
+
74
+ function shuffled(values, seed) {
75
+ const result = [...values];
76
+ const random = seededRandom(hashSeed(seed));
77
+ for (let index = result.length - 1; index > 0; index -= 1) {
78
+ const swap = Math.floor(random() * (index + 1));
79
+ [result[index], result[swap]] = [result[swap], result[index]];
80
+ }
81
+ return result;
82
+ }
83
+
84
+ export function buildBenchmarkSchedule({ repetitions = 5, targets = ['openai', 'gemini'], seed = 'optimization-pilot-v1' } = {}) {
85
+ const schedule = [];
86
+ for (let block = 1; block <= repetitions; block += 1) {
87
+ for (const targetName of targets) {
88
+ const target = BENCHMARK_TARGETS[targetName];
89
+ if (!target) throw new Error(`Unknown benchmark target: ${targetName}`);
90
+ for (const condition of shuffled(target.conditions, `${seed}:${targetName}:${block}`)) {
91
+ schedule.push({
92
+ key: `${targetName}:b${block}:${condition.id}`,
93
+ target: targetName,
94
+ block,
95
+ provider: target.provider,
96
+ repo: target.repo,
97
+ missions: target.missions,
98
+ ...condition,
99
+ });
100
+ }
101
+ }
102
+ }
103
+ return schedule;
104
+ }
105
+
106
+ export function commandForRun(run, cliPath = DEFAULT_CLI) {
107
+ return {
108
+ command: process.execPath,
109
+ args: [
110
+ cliPath,
111
+ `--provider=${run.provider}`,
112
+ `--model=${run.primary}`,
113
+ `--optimize=${run.objective}`,
114
+ `--optimization-mode=${run.mode}`,
115
+ ...run.missions,
116
+ ],
117
+ env: {
118
+ TESTRONAUT_JEV_ROUTING_THRESHOLD: '0.4',
119
+ ...(run.secondary ? { TESTRONAUT_BETA_MODEL_ROUTING: '1' } : {}),
120
+ ...(run.secondary ? { TESTRONAUT_JEV_FAST_MODEL: run.secondary } : {}),
121
+ },
122
+ };
123
+ }
124
+
125
+ function parseArgs(argv) {
126
+ const read = (name, fallback) => argv.find(arg => arg.startsWith(`--${name}=`))?.slice(name.length + 3) ?? fallback;
127
+ const target = read('target', 'both');
128
+ return {
129
+ dryRun: argv.includes('--dry-run'),
130
+ repetitions: Math.max(1, Number.parseInt(read('repetitions', '5'), 10) || 5),
131
+ targets: target === 'both' ? ['openai', 'gemini'] : target.split(',').filter(Boolean),
132
+ seed: read('seed', 'optimization-pilot-v1'),
133
+ experimentId: read('experiment-id', 'optimization-pilot-v1'),
134
+ cliPath: path.resolve(read('cli', DEFAULT_CLI)),
135
+ };
136
+ }
137
+
138
+ function reportFiles(repo) {
139
+ const directory = path.join(repo, 'missions/mission_reports');
140
+ if (!fs.existsSync(directory)) return new Set();
141
+ return new Set(fs.readdirSync(directory).filter(file => /^run_\d+\.json$/.test(file)));
142
+ }
143
+
144
+ function findNewReport(repo, before) {
145
+ const directory = path.join(repo, 'missions/mission_reports');
146
+ if (!fs.existsSync(directory)) return null;
147
+ const candidates = fs.readdirSync(directory)
148
+ .filter(file => /^run_\d+\.json$/.test(file) && !before.has(file))
149
+ .map(file => ({ file, time: fs.statSync(path.join(directory, file)).mtimeMs }))
150
+ .sort((a, b) => b.time - a.time);
151
+ return candidates[0] ? path.join(directory, candidates[0].file) : null;
152
+ }
153
+
154
+ function existingExperimentReports(repo, experimentId) {
155
+ const directory = path.join(repo, 'missions/mission_reports');
156
+ const reports = new Map();
157
+ if (!fs.existsSync(directory)) return reports;
158
+ for (const file of fs.readdirSync(directory).filter(name => /^run_\d+\.json$/.test(name))) {
159
+ const reportPath = path.join(directory, file);
160
+ try {
161
+ const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
162
+ if (report.experiment?.id !== experimentId) continue;
163
+ reports.set(`${report.experiment.condition}:${report.experiment.block}`, reportPath);
164
+ } catch { /* ignore incomplete or unrelated reports */ }
165
+ }
166
+ return reports;
167
+ }
168
+
169
+ function loadState(file, metadata) {
170
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return { ...metadata, runs: {} }; }
171
+ }
172
+
173
+ function writeJson(file, value) {
174
+ fs.mkdirSync(path.dirname(file), { recursive: true });
175
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
176
+ }
177
+
178
+ function writeSummaryCsv(file, state) {
179
+ const columns = [
180
+ 'key', 'target', 'block', 'level', 'condition', 'primary', 'secondary', 'objective',
181
+ 'status', 'runId', 'passed', 'failed', 'turns', 'tokens', 'routedTurns',
182
+ 'forcedPrimaryRetries', 'completionGates', 'jevInputTokens', 'jevOutputTokens',
183
+ ];
184
+ const escape = value => {
185
+ const text = String(value ?? '');
186
+ return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
187
+ };
188
+ const rows = Object.entries(state.runs).map(([key, run]) => {
189
+ const metrics = run.metrics || {};
190
+ return [
191
+ key, run.target, run.block, run.level, run.id, run.primary, run.secondary, run.objective,
192
+ run.status, metrics.runId, metrics.passed, metrics.failed, metrics.turns, metrics.tokens,
193
+ metrics.routedTurns, metrics.forcedPrimaryRetries, metrics.completionGates,
194
+ metrics.jevInputTokens, metrics.jevOutputTokens,
195
+ ].map(escape).join(',');
196
+ });
197
+ fs.writeFileSync(file, `${columns.join(',')}\n${rows.join('\n')}\n`);
198
+ }
199
+
200
+ function summarizeReport(reportPath) {
201
+ if (!reportPath) return null;
202
+ try {
203
+ const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
204
+ const steps = report.missions?.flatMap(mission => mission.steps || []) || [];
205
+ const models = {};
206
+ for (const step of steps) {
207
+ const model = step.model || report.llm?.model || 'unknown';
208
+ models[model] ||= { turns: 0, tokens: 0 };
209
+ models[model].turns += 1;
210
+ models[model].tokens += Number(step.tokensUsed || 0);
211
+ }
212
+ return {
213
+ runId: report.runId,
214
+ passed: report.summary?.passed || 0,
215
+ failed: report.summary?.failed || 0,
216
+ turns: steps.length,
217
+ tokens: steps.reduce((sum, step) => sum + Number(step.tokensUsed || 0), 0),
218
+ routedTurns: steps.filter(step => step.modelRouting?.applied).length,
219
+ forcedPrimaryRetries: steps.filter(step => step.modelRouting?.forcedPrimaryRetry).length,
220
+ jevInputTokens: Number(report.jevShadow?.inputTokens || 0),
221
+ jevOutputTokens: Number(report.jevShadow?.outputTokens || 0),
222
+ completionGates: Number(report.jevShadow?.triggeredCompletions || 0),
223
+ models,
224
+ };
225
+ } catch {
226
+ return null;
227
+ }
228
+ }
229
+
230
+ export function runBenchmark(options) {
231
+ const schedule = buildBenchmarkSchedule(options);
232
+ if (options.dryRun) return { schedule, completed: 0, skipped: 0 };
233
+
234
+ const stateRoot = path.resolve(HERE, 'results', options.experimentId);
235
+ const legacyStateFile = path.join(stateRoot, 'state.json');
236
+ const legacyState = loadState(legacyStateFile, { runs: {} });
237
+ const states = {};
238
+ const stateFiles = {};
239
+ for (const target of options.targets) {
240
+ const stateFile = path.join(stateRoot, `state-${target}.json`);
241
+ const legacyRuns = Object.fromEntries(
242
+ Object.entries(legacyState.runs || {}).filter(([, run]) => run.target === target),
243
+ );
244
+ const state = loadState(stateFile, {
245
+ experimentId: options.experimentId,
246
+ seed: options.seed,
247
+ repetitions: options.repetitions,
248
+ targets: [target],
249
+ createdAt: new Date().toISOString(),
250
+ runs: legacyRuns,
251
+ });
252
+ const targetSchedule = schedule.filter(run => run.target === target);
253
+ const existing = existingExperimentReports(targetSchedule[0]?.repo, options.experimentId);
254
+ for (const run of targetSchedule) {
255
+ if (state.runs[run.key]?.status === 'completed') continue;
256
+ const reportPath = existing.get(`${run.id}:${run.block}`);
257
+ if (!reportPath) continue;
258
+ state.runs[run.key] = {
259
+ ...run,
260
+ status: 'completed',
261
+ exitCode: null,
262
+ recoveredFromReport: true,
263
+ reportPath,
264
+ metrics: summarizeReport(reportPath),
265
+ };
266
+ }
267
+ states[target] = state;
268
+ stateFiles[target] = stateFile;
269
+ writeJson(stateFile, state);
270
+ writeSummaryCsv(path.join(stateRoot, `runs-${target}.csv`), state);
271
+ }
272
+ let completed = 0;
273
+ let skipped = 0;
274
+
275
+ for (const run of schedule) {
276
+ const state = states[run.target];
277
+ const stateFile = stateFiles[run.target];
278
+ if (state.runs[run.key]?.status === 'completed') {
279
+ skipped += 1;
280
+ continue;
281
+ }
282
+ const invocation = commandForRun(run, options.cliPath);
283
+ console.log(`\n[${run.key}] ${run.primary}${run.secondary ? ` -> ${run.secondary}` : ''} (${run.objective}/${run.mode})`);
284
+ const before = reportFiles(run.repo);
285
+ const logFile = path.join(stateRoot, 'logs', `${run.key.replaceAll(':', '_')}.log`);
286
+ fs.mkdirSync(path.dirname(logFile), { recursive: true });
287
+ const startedAt = new Date().toISOString();
288
+ const env = { ...process.env, ...invocation.env };
289
+ if (!run.secondary) delete env.TESTRONAUT_JEV_FAST_MODEL;
290
+ Object.assign(env, {
291
+ TESTRONAUT_EXPERIMENT_ID: options.experimentId,
292
+ TESTRONAUT_EXPERIMENT_CONDITION: run.id,
293
+ TESTRONAUT_EXPERIMENT_BLOCK: String(run.block),
294
+ });
295
+ const result = spawnSync(invocation.command, invocation.args, {
296
+ cwd: run.repo,
297
+ env,
298
+ encoding: 'utf8',
299
+ stdio: ['inherit', 'pipe', 'pipe'],
300
+ maxBuffer: 50 * 1024 * 1024,
301
+ });
302
+ fs.writeFileSync(logFile, `${result.stdout || ''}${result.stderr || ''}`);
303
+ const reportPath = findNewReport(run.repo, before);
304
+ const status = reportPath ? 'completed' : 'no_report';
305
+ state.runs[run.key] = {
306
+ ...run,
307
+ status,
308
+ exitCode: result.status,
309
+ signal: result.signal,
310
+ startedAt,
311
+ endedAt: new Date().toISOString(),
312
+ reportPath,
313
+ logFile,
314
+ metrics: summarizeReport(reportPath),
315
+ };
316
+ writeJson(stateFile, state);
317
+ writeSummaryCsv(path.join(stateRoot, `runs-${run.target}.csv`), state);
318
+ console.log(`[${run.key}] ${status}; exit=${result.status}; report=${reportPath || 'none'}`);
319
+ if (status === 'completed') completed += 1;
320
+ }
321
+ return { schedule, completed, skipped, stateFiles };
322
+ }
323
+
324
+ function printSchedule(schedule) {
325
+ console.log(`Optimization benchmark schedule: ${schedule.length} runs`);
326
+ for (const run of schedule) {
327
+ const secondary = run.secondary ? ` -> ${run.secondary}` : '';
328
+ console.log(`${run.key} | L${run.level} | ${run.primary}${secondary} | ${run.objective}/${run.mode} | ${run.missions.length} missions`);
329
+ }
330
+ }
331
+
332
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
333
+ const options = parseArgs(process.argv.slice(2));
334
+ const result = runBenchmark(options);
335
+ if (options.dryRun) printSchedule(result.schedule);
336
+ else console.log(`Benchmark progress saved to ${Object.values(result.stateFiles).join(', ')}. Completed ${result.completed}; resumed/skipped ${result.skipped}.`);
337
+ }
338
+
339
+ export const __benchmarkInternals = { hashSeed, seededRandom, shuffled, parseArgs, summarizeReport };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "1.11.0",
3
+ "version": "1.12.1",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "bin/cli.js"
@@ -12,6 +12,10 @@
12
12
  },
13
13
  "type": "module",
14
14
  "scripts": {
15
+ "benchmark:optimization": "node ./experiments/optimizationBenchmark.js",
16
+ "benchmark:cross-provider": "node ./experiments/crossProviderBenchmark.js",
17
+ "benchmark:package": "node ./experiments/buildOptimizationDataPackage.js",
18
+ "benchmark:publish-package": "node ./experiments/buildExperimentPublicationPackage.js",
15
19
  "test": "vitest run",
16
20
  "test:watch": "vitest",
17
21
  "start": "node ./bin/cli.js"
@@ -108,6 +108,7 @@ describe('cli helpers', () => {
108
108
  }, '/tmp/project');
109
109
  expect(effective.outputDir).toEqual({ value: '/tmp/project/artifacts', source: 'config' });
110
110
  expect(effective.authenticated).toBe(true);
111
+ expect(effective.guardrails).toMatchObject({ enabled: false, failOpen: true, credentialConfigured: false });
111
112
  expect(JSON.stringify(effective)).not.toContain('secret');
112
113
  });
113
114
 
@@ -128,6 +129,21 @@ describe('cli helpers', () => {
128
129
  expect(res.invalid).toBe(false);
129
130
  });
130
131
 
132
+ it('parses optimization objective and execution mode flags', () => {
133
+ expect(__test__.parseOptimizationArgs([
134
+ '--optimize=balanced', '--optimization-mode', 'live', '--beta-model-routing', 'login.mission.js',
135
+ ])).toMatchObject({
136
+ objective: 'balanced', mode: 'live', betaModelRouting: true, args: ['login.mission.js'], invalid: false,
137
+ });
138
+ expect(__test__.parseOptimizationArgs(['--optimize=unknown'])).toMatchObject({ invalid: true });
139
+ expect(__test__.parseOptimizationArgs(['--guardrails', 'login.mission.js'])).toMatchObject({
140
+ objective: 'tokens', mode: 'live', guardrailsMode: 'live', args: ['login.mission.js'], invalid: false,
141
+ });
142
+ expect(__test__.parseOptimizationArgs(['--guardrails=shadow', 'login.mission.js'])).toMatchObject({
143
+ objective: 'tokens', mode: 'shadow', args: ['login.mission.js'], invalid: false,
144
+ });
145
+ });
146
+
131
147
  it('parses vercel bypass value from next arg', () => {
132
148
  const { parseVercelBypassArgs } = __test__;
133
149
  const res = parseVercelBypassArgs(['--vercel_bypass', 'abc', 'login']);
@@ -62,6 +62,20 @@ describe('CLI inspection commands', () => {
62
62
  expect(result.stdout).not.toContain('private');
63
63
  });
64
64
 
65
+ it('reports opt-in guardrail readiness without exposing the Jev key', () => {
66
+ const cwd = makeProject();
67
+ fs.writeFileSync(path.join(cwd, 'testronaut-config.json'), JSON.stringify({
68
+ provider: 'openai', model: 'gpt-5.6', guardrails: { enabled: true, completionProbability: 0.85 },
69
+ }));
70
+ const result = spawnSync(process.execPath, [cliPath, 'config', '--json'], {
71
+ cwd, encoding: 'utf8', env: { ...process.env, TESTRONAUT_JEV_API_KEY: 'private-jev-key' },
72
+ });
73
+ expect(result.status).toBe(0);
74
+ const output = JSON.parse(result.stdout);
75
+ expect(output.guardrails).toMatchObject({ enabled: true, mode: 'live', completionProbability: 0.85, credentialConfigured: true, credentialSource: 'TESTRONAUT_JEV_API_KEY' });
76
+ expect(result.stdout).not.toContain('private-jev-key');
77
+ });
78
+
65
79
  it('suggests a close mission name and exits nonzero during dry-run', () => {
66
80
  const result = run(makeProject(), ['logn.mission.js', '--dry-run', '--json']);
67
81
  expect(result.status).toBe(1);
@@ -84,7 +98,10 @@ describe('CLI inspection commands', () => {
84
98
  const result = run(cwd, ['--tags', 'authentication', '--json', '--quiet']);
85
99
 
86
100
  expect(result.status).toBe(0);
87
- expect(JSON.parse(result.stdout).missions[0].missionName).toBe('https://example.test');
101
+ const report = JSON.parse(result.stdout);
102
+ expect(report.missions[0].missionName).toBe('https://example.test');
103
+ expect(report.durationMs).toEqual(expect.any(Number));
104
+ expect(report.missions[0].durationMs).toEqual(expect.any(Number));
88
105
  });
89
106
 
90
107
  it('records shared-auth session mode in the report', () => {
@@ -17,12 +17,13 @@ describe('terminal UI', () => {
17
17
  ui.banner({ version: '1.2.3', provider: 'openai', model: 'gpt-test', session: 'isolated' });
18
18
  ui.phase('launch', 'login.mission.js');
19
19
  ui.mission(true, 'login.mission.js', ['smoke']);
20
- ui.summary({ totalMissions: 1, passed: 1, failed: 0, reportPath: 'reports/run.html' });
20
+ ui.summary({ totalMissions: 1, passed: 1, failed: 0, durationMs: 1234, reportPath: 'reports/run.html' });
21
21
 
22
22
  expect(target.read()).toContain('🧑‍🚀 TESTRONAUT / MISSION RUNNER');
23
23
  expect(target.read()).toContain('LAUNCH login.mission.js');
24
24
  expect(target.read()).toContain('#smoke');
25
25
  expect(target.read()).toContain('MISSION COMPLETE');
26
+ expect(target.read()).toContain('Duration: 1.23s');
26
27
  expect(target.read()).not.toContain('\u001b[');
27
28
  });
28
29
 
@@ -61,6 +61,8 @@ describe('core/agent.runAgent', () => {
61
61
  expect(res).toHaveLength(2);
62
62
  expect(res[0].status).toBe('passed');
63
63
  expect(res[1].status).toBe('passed');
64
+ expect(res[0].durationMs).toEqual(expect.any(Number));
65
+ expect(res[0].endTime).toBeGreaterThanOrEqual(res[0].startTime);
64
66
  });
65
67
 
66
68
  it('stops early on first failure and returns partial results', async () => {
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildBrowserEvidence, mergeBrowserControlState } from '../../core/browserEvidence.js';
3
+
4
+ describe('browser evidence', () => {
5
+ it('extracts bounded structure without field values or URL secrets', () => {
6
+ const evidence = buildBrowserEvidence(`
7
+ <html><head><title>Mission Dashboard</title></head><body>
8
+ <main><h1>Mission Tasks</h1><form><label for="access">Access code</label>
9
+ <input id="access" name="access" type="password" value="super-secret" />
10
+ <button>Sign in</button>
11
+ </form></main>
12
+ <a href="https://example.test/tasks?token=secret#private">Tasks</a>
13
+ <p>private body content</p>
14
+ </body></html>
15
+ `, { url: 'https://example.test/dashboard?token=secret#private' });
16
+
17
+ expect(evidence).toMatchObject({
18
+ url: 'https://example.test/dashboard',
19
+ title: 'Mission Dashboard',
20
+ headings: ['Mission Tasks'],
21
+ counts: { headings: 1, forms: 1, buttons: 1, links: 1, inputs: 1 },
22
+ });
23
+ expect(JSON.stringify(evidence)).not.toContain('super-secret');
24
+ expect(JSON.stringify(evidence)).not.toContain('private body content');
25
+ expect(JSON.stringify(evidence)).not.toContain('token=secret');
26
+ expect(evidence.regions).toEqual(expect.arrayContaining([
27
+ expect.objectContaining({ kind: 'main', headings: ['Mission Tasks'] }),
28
+ expect.objectContaining({ kind: 'form', controls: ['Access code', 'Sign in'] }),
29
+ ]));
30
+ });
31
+
32
+ it('tracks route changes and clears stale page structure', () => {
33
+ const previous = buildBrowserEvidence('<title>Login</title><h1>Sign in</h1>', {
34
+ url: 'https://example.test/login',
35
+ });
36
+ const evidence = buildBrowserEvidence(null, {
37
+ url: 'https://example.test/dashboard',
38
+ previous,
39
+ });
40
+
41
+ expect(evidence).toEqual({
42
+ url: 'https://example.test/dashboard',
43
+ previousUrl: 'https://example.test/login',
44
+ routeChanged: true,
45
+ });
46
+ });
47
+
48
+ it('merges only boolean live state without exposing field contents', () => {
49
+ const evidence = { controls: [{ tag: 'input', id: 'password' }] };
50
+ const merged = mergeBrowserControlState(evidence, [{
51
+ hasValue: true,
52
+ checked: false,
53
+ disabled: false,
54
+ readOnly: true,
55
+ visible: true,
56
+ value: 'must-not-copy',
57
+ }]);
58
+
59
+ expect(merged.controls[0]).toEqual({
60
+ tag: 'input',
61
+ id: 'password',
62
+ hasValue: true,
63
+ checked: false,
64
+ disabled: false,
65
+ readOnly: true,
66
+ visible: true,
67
+ });
68
+ expect(JSON.stringify(merged)).not.toContain('must-not-copy');
69
+ });
70
+ });
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { chooseJevTurnModel, resolveJevRoutingConfig } from '../../core/jevRouting.js';
3
+
4
+ const safeFastRoute = {
5
+ modelRoute: { choice: 'fast_model', confidence: 0.22, probabilities: { fast_model: 0.52 } },
6
+ missionStage: { choice: 'interact' },
7
+ recovery: { choice: 'continue' },
8
+ };
9
+
10
+ describe('Jev dynamic routing', () => {
11
+ it('defaults OpenAI experiments to Luna without enabling routing', () => {
12
+ expect(resolveJevRoutingConfig({}, {
13
+ provider: 'openai', primaryModel: 'gpt-5.6-terra',
14
+ })).toMatchObject({ mode: 'off', fastModel: 'gpt-5.6-luna', confidenceThreshold: 0.4 });
15
+ });
16
+
17
+ it.each([
18
+ ['gemini', 'gemini-3.8-flash', 'gemini-3.5-flash-lite'],
19
+ ['anthropic', 'claude-sonnet-5', 'claude-haiku-4-5'],
20
+ ])('keeps the default fast model within the %s family', (provider, primaryModel, fastModel) => {
21
+ expect(resolveJevRoutingConfig({}, { provider, primaryModel })).toMatchObject({ fastModel });
22
+ });
23
+
24
+ it('records an eligible route without applying it in shadow mode', () => {
25
+ const result = chooseJevTurnModel(safeFastRoute, {
26
+ mode: 'shadow', fastModel: 'gpt-5.6-luna', confidenceThreshold: 0.4,
27
+ }, { provider: 'openai', primaryModel: 'gpt-5.6-terra' });
28
+ expect(result).toMatchObject({ eligible: true, applied: false, selectedModel: 'gpt-5.6-terra', reason: 'shadow_only' });
29
+ });
30
+
31
+ it('applies a high-confidence fast route for one live turn', () => {
32
+ const result = chooseJevTurnModel(safeFastRoute, {
33
+ mode: 'live', fastModel: 'gpt-5.6-luna', confidenceThreshold: 0.4,
34
+ }, { provider: 'openai', primaryModel: 'gpt-5.6-terra' });
35
+ expect(result).toMatchObject({ eligible: true, applied: true, selectedModel: 'gpt-5.6-luna', reason: 'jev_fast_model', score: 0.52, confidence: 0.22 });
36
+ });
37
+
38
+ it.each([
39
+ [{ ...safeFastRoute, missionStage: { choice: 'authenticate' } }, 'stage_authenticate'],
40
+ [{ ...safeFastRoute, recovery: { choice: 'reobserve' } }, 'recovery_reobserve'],
41
+ [{ ...safeFastRoute, modelRoute: { choice: 'fast_model', confidence: 0.5, probabilities: { fast_model: 0.39 } } }, 'low_probability'],
42
+ ])('keeps sensitive or uncertain turns on the primary model', (strategy, reason) => {
43
+ expect(chooseJevTurnModel(strategy, {
44
+ mode: 'live', fastModel: 'gpt-5.6-luna', confidenceThreshold: 0.4,
45
+ }, { provider: 'openai', primaryModel: 'gpt-5.6-terra' })).toMatchObject({ applied: false, reason });
46
+ });
47
+
48
+ it('rejects a configured model from another provider family', () => {
49
+ expect(chooseJevTurnModel(safeFastRoute, {
50
+ mode: 'live', fastModel: 'gpt-5.6-luna', confidenceThreshold: 0.4,
51
+ }, { provider: 'gemini', primaryModel: 'gemini-3.8-flash' })).toMatchObject({
52
+ applied: false,
53
+ reason: 'fast_model_provider_mismatch',
54
+ selectedModel: 'gemini-3.8-flash',
55
+ });
56
+ });
57
+ });