testronaut 1.3.2 โ 1.3.4
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/.devcontainer/devcontainer.json +48 -0
- package/README.md +21 -0
- package/bin/cli.js +127 -28
- package/package.json +1 -1
- package/tests/binTests/cli.helpers.test.js +73 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Testronaut Multi-Repo Codespace",
|
|
3
|
+
|
|
4
|
+
"image": "mcr.microsoft.com/devcontainers/javascript-node:22",
|
|
5
|
+
|
|
6
|
+
"customizations": {
|
|
7
|
+
"codespaces": {
|
|
8
|
+
"repositories": {
|
|
9
|
+
"mission-testronaut/testronaut-cli": {
|
|
10
|
+
"permissions": "write-all"
|
|
11
|
+
},
|
|
12
|
+
"mission-testronaut/testronaut-examples": {
|
|
13
|
+
"permissions": "write-all"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"vscode": {
|
|
18
|
+
"settings": {
|
|
19
|
+
"files.autoSave": "onFocusChange",
|
|
20
|
+
"terminal.integrated.defaultProfile.linux": "bash",
|
|
21
|
+
"git.confirmSync": false,
|
|
22
|
+
"explorer.confirmDelete": false,
|
|
23
|
+
"extensions.autoCheckUpdates": true,
|
|
24
|
+
"extensions.autoUpdate": true
|
|
25
|
+
},
|
|
26
|
+
"extensions": [
|
|
27
|
+
"github.vscode-github-actions",
|
|
28
|
+
"github.codespaces",
|
|
29
|
+
"eamodio.gitlens",
|
|
30
|
+
"esbenp.prettier-vscode",
|
|
31
|
+
"dbaeumer.vscode-eslint",
|
|
32
|
+
"openai.chatgpt"
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
"workspaceFolder": "/workspaces",
|
|
38
|
+
|
|
39
|
+
"postCreateCommand": "bash -lc 'set -e; cd /workspaces; if [ ! -d \"testronaut-examples/.git\" ]; then git clone https://github.com/mission-testronaut/testronaut-examples.git; fi'",
|
|
40
|
+
|
|
41
|
+
"postStartCommand": "bash -lc 'git config --global pull.rebase false && git config --global init.defaultBranch main'",
|
|
42
|
+
|
|
43
|
+
"hostRequirements": {
|
|
44
|
+
"cpus": 4,
|
|
45
|
+
"memory": "8gb",
|
|
46
|
+
"storage": "32gb"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/README.md
CHANGED
|
@@ -154,6 +154,27 @@ await runMissions({
|
|
|
154
154
|
postMission: logoutMission,
|
|
155
155
|
}, "Contact Form Flow");
|
|
156
156
|
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## ๐งฐ Developer Mode (Staging API)
|
|
161
|
+
|
|
162
|
+
Use the staging API base URL:
|
|
163
|
+
```bash
|
|
164
|
+
npx testronaut --dev
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
If the staging deployment is protected by Vercel, pass the bypass secret:
|
|
168
|
+
```bash
|
|
169
|
+
npx testronaut --dev --vercel-bypass=YOUR_SECRET login
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
You can also set the bypass secret via environment variables:
|
|
173
|
+
```bash
|
|
174
|
+
export VERCEL_AUTOMATION_BYPASS_SECRET=YOUR_SECRET
|
|
175
|
+
# or
|
|
176
|
+
export TESTRONAUT_VERCEL_BYPASS=YOUR_SECRET
|
|
177
|
+
```
|
|
157
178
|
---
|
|
158
179
|
|
|
159
180
|
## ๐ Reports
|
package/bin/cli.js
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Key flags:
|
|
13
13
|
* --model <id> / --model=<id> โ sets TESTRONAUT_MODEL env (wins over config file)
|
|
14
|
+
* --provider <id> / --provider=<id> โ sets TESTRONAUT_PROVIDER env (wins over config file)
|
|
14
15
|
* --turns <n> / --turns=<n> โ sets TESTRONAUT_TURNS env (wins over config file)
|
|
15
16
|
* --init โ scaffolds project + optional Playwright browsers
|
|
17
|
+
* --dev โ use staging API base URL
|
|
16
18
|
* --help โ prints help
|
|
17
19
|
*
|
|
18
20
|
* Notable helpers (defined below):
|
|
@@ -42,6 +44,7 @@ import inquirer from 'inquirer';
|
|
|
42
44
|
import fetch from 'node-fetch';
|
|
43
45
|
import crypto from 'crypto';
|
|
44
46
|
import http from 'http';
|
|
47
|
+
import { resolveProviderModel } from '../llm/modelResolver.js';
|
|
45
48
|
import { exec as execCmd } from 'child_process';
|
|
46
49
|
import { promisify } from 'util';
|
|
47
50
|
const exec = promisify(execCmd);
|
|
@@ -55,11 +58,106 @@ process.env.PLAYWRIGHT_BROWSERS_PATH = process.env.PLAYWRIGHT_BROWSERS_PATH || '
|
|
|
55
58
|
const TMP_DIR = path.resolve('./missions/tmp');
|
|
56
59
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
57
60
|
|
|
58
|
-
|
|
59
|
-
const
|
|
61
|
+
const DEFAULT_API_BASE = 'http://api.testronaut.app';
|
|
62
|
+
const DEV_API_BASE = 'https://staging.api.testronaut.app';
|
|
60
63
|
|
|
64
|
+
let args = process.argv.slice(2);
|
|
61
65
|
|
|
62
|
-
|
|
66
|
+
// Look for --dev / --developer / --developer-mode / --staging
|
|
67
|
+
const devFlagIndex = args.findIndex(a =>
|
|
68
|
+
a === '--dev' ||
|
|
69
|
+
a === '--developer' ||
|
|
70
|
+
a === '--developer-mode' ||
|
|
71
|
+
a === '--staging'
|
|
72
|
+
);
|
|
73
|
+
let apiBase = process.env.TESTRONAUT_API_BASE || DEFAULT_API_BASE;
|
|
74
|
+
if (devFlagIndex >= 0) {
|
|
75
|
+
apiBase = DEV_API_BASE;
|
|
76
|
+
console.log(`๐งช Developer mode: using ${apiBase}`);
|
|
77
|
+
args.splice(devFlagIndex, 1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseVercelBypassArgs(argsList) {
|
|
81
|
+
const nextArgs = [...argsList];
|
|
82
|
+
let secret;
|
|
83
|
+
let invalid = false;
|
|
84
|
+
const idx = nextArgs.findIndex(a =>
|
|
85
|
+
a === '--vercel-bypass' ||
|
|
86
|
+
a.startsWith('--vercel-bypass=') ||
|
|
87
|
+
a === '--vercel_bypass' ||
|
|
88
|
+
a.startsWith('--vercel_bypass=')
|
|
89
|
+
);
|
|
90
|
+
if (idx >= 0) {
|
|
91
|
+
const rawArg = nextArgs[idx];
|
|
92
|
+
const hasInline = rawArg.includes('=');
|
|
93
|
+
if (hasInline) {
|
|
94
|
+
secret = rawArg.split('=')[1];
|
|
95
|
+
} else if (nextArgs[idx + 1]) {
|
|
96
|
+
secret = nextArgs[idx + 1];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!secret) invalid = true;
|
|
100
|
+
|
|
101
|
+
// Remove flag & value so they arenโt treated as filenames
|
|
102
|
+
const consume = hasInline ? 1 : (secret ? 2 : 1);
|
|
103
|
+
nextArgs.splice(idx, consume);
|
|
104
|
+
}
|
|
105
|
+
return { secret, args: nextArgs, invalid };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseProviderArgs(argsList) {
|
|
109
|
+
const nextArgs = [...argsList];
|
|
110
|
+
let provider;
|
|
111
|
+
let invalid = false;
|
|
112
|
+
const idx = nextArgs.findIndex(a => a === '--provider' || a.startsWith('--provider='));
|
|
113
|
+
if (idx >= 0) {
|
|
114
|
+
const rawArg = nextArgs[idx];
|
|
115
|
+
const hasInline = rawArg.includes('=');
|
|
116
|
+
const nextVal = !hasInline && nextArgs[idx + 1] && !nextArgs[idx + 1].startsWith('-') ? nextArgs[idx + 1] : undefined;
|
|
117
|
+
if (hasInline) {
|
|
118
|
+
provider = rawArg.split('=')[1];
|
|
119
|
+
} else if (nextVal) {
|
|
120
|
+
provider = nextVal;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (provider) {
|
|
124
|
+
provider = provider.trim();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const supportedProviders = new Set(['openai', 'gemini']);
|
|
128
|
+
const isValid = (v) => !!v && supportedProviders.has(String(v).toLowerCase());
|
|
129
|
+
if (!isValid(provider)) {
|
|
130
|
+
invalid = true;
|
|
131
|
+
provider = undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Remove flag & value so they arenโt treated as filenames
|
|
135
|
+
const consume = hasInline ? 1 : (nextVal ? 2 : 1);
|
|
136
|
+
nextArgs.splice(idx, consume);
|
|
137
|
+
}
|
|
138
|
+
return { provider, args: nextArgs, invalid };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
const vercelBypassResult = parseVercelBypassArgs(args);
|
|
143
|
+
if (vercelBypassResult.invalid) {
|
|
144
|
+
console.warn('โ ๏ธ Invalid --vercel-bypass value. Provide a non-empty secret.');
|
|
145
|
+
}
|
|
146
|
+
args = vercelBypassResult.args;
|
|
147
|
+
const vercelBypassOverride = vercelBypassResult.secret;
|
|
148
|
+
|
|
149
|
+
const vercelBypassSecret =
|
|
150
|
+
vercelBypassOverride ||
|
|
151
|
+
process.env.VERCEL_AUTOMATION_BYPASS_SECRET ||
|
|
152
|
+
process.env.TESTRONAUT_VERCEL_BYPASS;
|
|
153
|
+
function createVercelBypassHeader(secret) {
|
|
154
|
+
return secret ? { 'x-vercel-protection-bypass': secret } : {};
|
|
155
|
+
}
|
|
156
|
+
const vercelBypassHeader = createVercelBypassHeader(vercelBypassSecret);
|
|
157
|
+
|
|
158
|
+
function buildApiHeaders(extra = {}) {
|
|
159
|
+
return { ...extra, ...vercelBypassHeader };
|
|
160
|
+
}
|
|
63
161
|
|
|
64
162
|
/**
|
|
65
163
|
* Read a JSONL steps file into an array of objects.
|
|
@@ -133,6 +231,9 @@ export const __test__ = {
|
|
|
133
231
|
findLatestReportPair,
|
|
134
232
|
mergeDuplicateTurns,
|
|
135
233
|
readJsonlSteps,
|
|
234
|
+
parseVercelBypassArgs,
|
|
235
|
+
createVercelBypassHeader,
|
|
236
|
+
parseProviderArgs,
|
|
136
237
|
};
|
|
137
238
|
|
|
138
239
|
// Look for --model=<id> or --model <id>
|
|
@@ -154,6 +255,18 @@ if (modelFlagIndex >= 0) {
|
|
|
154
255
|
args.splice(modelFlagIndex, modelOverride ? 2 : 1);
|
|
155
256
|
}
|
|
156
257
|
|
|
258
|
+
// Look for --provider=<id> or --provider <id>
|
|
259
|
+
const providerResult = parseProviderArgs(args);
|
|
260
|
+
if (providerResult.invalid) {
|
|
261
|
+
console.warn('โ ๏ธ Invalid --provider value. Provide a non-empty provider id.');
|
|
262
|
+
}
|
|
263
|
+
args = providerResult.args;
|
|
264
|
+
const providerOverride = providerResult.provider;
|
|
265
|
+
if (providerOverride) {
|
|
266
|
+
process.env.TESTRONAUT_PROVIDER = providerOverride.trim();
|
|
267
|
+
console.log(`๐งฉ Provider override: ${process.env.TESTRONAUT_PROVIDER}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
157
270
|
// Look for --debug / --debug=<bool> / --no-debug
|
|
158
271
|
// Normalize CLI boolean strings into true/false/null for optional flags.
|
|
159
272
|
const parseBool = (v) => {
|
|
@@ -270,6 +383,9 @@ Options:
|
|
|
270
383
|
--init Scaffold project folders and a welcome mission
|
|
271
384
|
--turns=<n> Override max turns for this run (e.g., --turns=30)
|
|
272
385
|
--debug[=<bool>] Enable verbose debug logs (or set TESTRONAUT_DEBUG=1)
|
|
386
|
+
--provider=<id> Override LLM provider for this run (e.g., --provider=openai)
|
|
387
|
+
--dev Use the staging API base URL
|
|
388
|
+
--vercel-bypass=<secret> Send Vercel protection bypass header for protected deployments
|
|
273
389
|
--help Show this help message
|
|
274
390
|
--retry_limit=<n> Override agent turn retry limits (minimum 1, maximum 10)
|
|
275
391
|
|
|
@@ -403,25 +519,8 @@ const flatMissions = allResults.flatMap(entry => {
|
|
|
403
519
|
});
|
|
404
520
|
});
|
|
405
521
|
|
|
406
|
-
// Read provider/model from config (allow env override
|
|
407
|
-
const
|
|
408
|
-
let llmProvider = 'openai';
|
|
409
|
-
let llmModel = 'gpt-4o';
|
|
410
|
-
|
|
411
|
-
if (fs.existsSync(cfgPath)) {
|
|
412
|
-
try {
|
|
413
|
-
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
414
|
-
if (cfg?.provider) llmProvider = String(cfg.provider);
|
|
415
|
-
if (cfg?.model) llmModel = String(cfg.model);
|
|
416
|
-
} catch (e) {
|
|
417
|
-
console.warn('โ ๏ธ Could not read testronaut-config.json:', e.message);
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// Env override takes precedence for the model (matches runtime behavior)
|
|
422
|
-
if (process.env.TESTRONAUT_MODEL?.trim()) {
|
|
423
|
-
llmModel = process.env.TESTRONAUT_MODEL.trim();
|
|
424
|
-
}
|
|
522
|
+
// Read provider/model from config (allow env override)
|
|
523
|
+
const { provider: llmProvider, model: llmModel } = resolveProviderModel({ cwd: process.cwd() });
|
|
425
524
|
|
|
426
525
|
|
|
427
526
|
const report = {
|
|
@@ -592,13 +691,13 @@ async function handleLogin() {
|
|
|
592
691
|
try {
|
|
593
692
|
const response = await fetch(`${apiBase}/api/user/cli`, { // Replace with your actual URL
|
|
594
693
|
method: 'POST',
|
|
595
|
-
headers: {
|
|
694
|
+
headers: buildApiHeaders({
|
|
596
695
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
597
|
-
},
|
|
696
|
+
}),
|
|
598
697
|
body: formData.toString(),
|
|
599
698
|
});
|
|
600
699
|
|
|
601
|
-
const data = await response
|
|
700
|
+
const data = await parseJsonSafe(response, 'login');
|
|
602
701
|
|
|
603
702
|
if (data.error) {
|
|
604
703
|
console.error('โ Authentication failed:', data.error);
|
|
@@ -659,7 +758,7 @@ async function uploadReport() {
|
|
|
659
758
|
try {
|
|
660
759
|
const res = await fetch(`${apiBase}/api/user/cli/${sessionToken}`, {
|
|
661
760
|
method: 'POST',
|
|
662
|
-
headers: { 'Content-Type': 'application/json' },
|
|
761
|
+
headers: buildApiHeaders({ 'Content-Type': 'application/json' }),
|
|
663
762
|
body: reportJson,
|
|
664
763
|
});
|
|
665
764
|
const data = await res.json();
|
|
@@ -735,7 +834,7 @@ async function uploadReport() {
|
|
|
735
834
|
// console.log(` the fetch url is: ${apiBase}/api/user/cli/${sessionToken}/uploads/start`);
|
|
736
835
|
const startRes = await fetch(`${apiBase}/api/user/cli/${sessionToken}/uploads/start`, {
|
|
737
836
|
method: 'POST',
|
|
738
|
-
headers: { 'Content-Type': 'application/json' },
|
|
837
|
+
headers: buildApiHeaders({ 'Content-Type': 'application/json' }),
|
|
739
838
|
body: JSON.stringify({ reportId: savedReportId, stepIndex, fileName: path.basename(filePath), mime, size: stat.size, sha1: hash }),
|
|
740
839
|
});
|
|
741
840
|
const start = await parseJsonSafe(startRes, 'uploads/start');
|
|
@@ -757,7 +856,7 @@ async function uploadReport() {
|
|
|
757
856
|
// console.log(` finalizing uploadโฆ`);
|
|
758
857
|
const finishRes = await fetch(`${apiBase}/api/user/cli/${sessionToken}/uploads/finish`, {
|
|
759
858
|
method: 'POST',
|
|
760
|
-
headers: { 'Content-Type': 'application/json' },
|
|
859
|
+
headers: buildApiHeaders({ 'Content-Type': 'application/json' }),
|
|
761
860
|
body: JSON.stringify({
|
|
762
861
|
reportId: savedReportId,
|
|
763
862
|
stepIndex,
|
package/package.json
CHANGED
|
@@ -42,4 +42,77 @@ describe('cli helpers', () => {
|
|
|
42
42
|
expect(parseBool('')).toBeNull();
|
|
43
43
|
expect(parseBool('maybe')).toBeNull();
|
|
44
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('parses provider flag with inline value and removes it from args', () => {
|
|
71
|
+
const { parseProviderArgs } = __test__;
|
|
72
|
+
const res = parseProviderArgs(['--provider=openai', 'login']);
|
|
73
|
+
expect(res.provider).toBe('openai');
|
|
74
|
+
expect(res.args).toEqual(['login']);
|
|
75
|
+
expect(res.invalid).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('parses provider flag value from next arg', () => {
|
|
79
|
+
const { parseProviderArgs } = __test__;
|
|
80
|
+
const res = parseProviderArgs(['--provider', 'gemini', 'login']);
|
|
81
|
+
expect(res.provider).toBe('gemini');
|
|
82
|
+
expect(res.args).toEqual(['login']);
|
|
83
|
+
expect(res.invalid).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('accepts supported provider names', () => {
|
|
87
|
+
const { parseProviderArgs } = __test__;
|
|
88
|
+
const res1 = parseProviderArgs(['--provider', 'openai', 'login']);
|
|
89
|
+
expect(res1.provider).toBe('openai');
|
|
90
|
+
expect(res1.invalid).toBe(false);
|
|
91
|
+
|
|
92
|
+
const res2 = parseProviderArgs(['--provider=gemini', 'login']);
|
|
93
|
+
expect(res2.provider).toBe('gemini');
|
|
94
|
+
expect(res2.invalid).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('flags invalid provider name', () => {
|
|
98
|
+
const { parseProviderArgs } = __test__;
|
|
99
|
+
const res = parseProviderArgs(['--provider', 'bad provider', 'login']);
|
|
100
|
+
expect(res.provider).toBeUndefined();
|
|
101
|
+
expect(res.args).toEqual(['login']);
|
|
102
|
+
expect(res.invalid).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('handles missing provider value', () => {
|
|
106
|
+
const { parseProviderArgs } = __test__;
|
|
107
|
+
const res = parseProviderArgs(['--provider']);
|
|
108
|
+
expect(res.provider).toBeUndefined();
|
|
109
|
+
expect(res.args).toEqual([]);
|
|
110
|
+
expect(res.invalid).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('builds the vercel bypass header when provided', () => {
|
|
114
|
+
const { createVercelBypassHeader } = __test__;
|
|
115
|
+
expect(createVercelBypassHeader('abc')).toEqual({ 'x-vercel-protection-bypass': 'abc' });
|
|
116
|
+
expect(createVercelBypassHeader('')).toEqual({});
|
|
117
|
+
});
|
|
45
118
|
});
|