testronaut 1.3.3 → 1.3.5

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.
@@ -0,0 +1,49 @@
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
+ "anthropic.claude-code"
34
+ ]
35
+ }
36
+ },
37
+
38
+ "workspaceFolder": "/workspaces",
39
+
40
+ "postCreateCommand": "bash -lc 'set -e; cd /workspaces; if [ ! -d \"testronaut-examples/.git\" ]; then git clone https://github.com/mission-testronaut/testronaut-examples.git; fi'",
41
+
42
+ "postStartCommand": "bash -lc 'git config --global pull.rebase false && git config --global init.defaultBranch main'",
43
+
44
+ "hostRequirements": {
45
+ "cpus": 4,
46
+ "memory": "8gb",
47
+ "storage": "32gb"
48
+ }
49
+ }
@@ -0,0 +1,168 @@
1
+ name: Prepare Package Release
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ release_type:
7
+ description: Semver increment to prepare
8
+ required: true
9
+ type: choice
10
+ default: patch
11
+ options:
12
+ - patch
13
+ - minor
14
+ - major
15
+
16
+ permissions:
17
+ contents: read
18
+
19
+ concurrency:
20
+ group: prepare-package-release
21
+ cancel-in-progress: false
22
+
23
+ jobs:
24
+ authorize-release:
25
+ name: Authorize Release
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - name: Require main branch
29
+ run: |
30
+ if [ "${GITHUB_REF_NAME}" != "main" ]; then
31
+ echo "Manual release preparation is only allowed from main."
32
+ exit 1
33
+ fi
34
+
35
+ - name: Require repository admin permission
36
+ uses: actions/github-script@v7
37
+ with:
38
+ script: |
39
+ const owner = context.repo.owner;
40
+ const repo = context.repo.repo;
41
+ const actor = context.actor;
42
+
43
+ if (owner.toLowerCase() === actor.toLowerCase()) {
44
+ core.info(`Actor ${actor} matches the repository owner and is treated as admin.`);
45
+ return;
46
+ }
47
+
48
+ const response = await github.rest.repos.getCollaboratorPermissionLevel({
49
+ owner,
50
+ repo,
51
+ username: actor,
52
+ });
53
+
54
+ const { permission, role_name: roleName } = response.data;
55
+ core.info(`Actor ${actor} permission=${permission}, role_name=${roleName}`);
56
+
57
+ if (permission !== "admin" && roleName !== "admin") {
58
+ core.setFailed(`Only repository admins can prepare package releases. ${actor} has ${roleName || permission}.`);
59
+ }
60
+
61
+ prepare-release:
62
+ name: Prepare Release Pull Request
63
+ needs: authorize-release
64
+ runs-on: ubuntu-latest
65
+ permissions:
66
+ # Required because this workflow pushes a release branch and opens a PR.
67
+ contents: write
68
+ pull-requests: write
69
+ steps:
70
+ - name: Checkout
71
+ uses: actions/checkout@v4
72
+ with:
73
+ fetch-depth: 0
74
+
75
+ - name: Setup Node
76
+ uses: actions/setup-node@v4
77
+ with:
78
+ node-version: 24
79
+ cache: npm
80
+
81
+ - name: Install dependencies
82
+ run: npm ci
83
+
84
+ - name: Run automated test suite
85
+ run: npm test
86
+
87
+ - name: Bump package version
88
+ run: npm version "${{ inputs.release_type }}" --no-git-tag-version
89
+
90
+ - name: Read new version
91
+ id: version
92
+ run: |
93
+ VERSION="$(node -e 'process.stdout.write(require("./package.json").version)')"
94
+ echo "value=${VERSION}" >> "$GITHUB_OUTPUT"
95
+ echo "branch=release/v${VERSION}" >> "$GITHUB_OUTPUT"
96
+
97
+ - name: Ensure release branch is not already open
98
+ env:
99
+ RELEASE_BRANCH: ${{ steps.version.outputs.branch }}
100
+ run: |
101
+ if git ls-remote --exit-code --heads origin "${RELEASE_BRANCH}" >/dev/null 2>&1; then
102
+ echo "Release branch ${RELEASE_BRANCH} already exists on origin."
103
+ exit 1
104
+ fi
105
+
106
+ - name: Create release branch
107
+ env:
108
+ RELEASE_BRANCH: ${{ steps.version.outputs.branch }}
109
+ run: git switch -c "${RELEASE_BRANCH}"
110
+
111
+ - name: Configure git author
112
+ run: |
113
+ git config user.name "github-actions[bot]"
114
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
115
+
116
+ - name: Commit release version
117
+ env:
118
+ VERSION: ${{ steps.version.outputs.value }}
119
+ run: |
120
+ git add package.json package-lock.json
121
+ git commit -m "chore(release): v${VERSION}"
122
+
123
+ - name: Push release branch
124
+ env:
125
+ RELEASE_BRANCH: ${{ steps.version.outputs.branch }}
126
+ run: git push --set-upstream origin "${RELEASE_BRANCH}"
127
+
128
+ - name: Open release pull request
129
+ id: pull-request
130
+ uses: actions/github-script@v7
131
+ env:
132
+ RELEASE_BRANCH: ${{ steps.version.outputs.branch }}
133
+ VERSION: ${{ steps.version.outputs.value }}
134
+ with:
135
+ script: |
136
+ const owner = context.repo.owner;
137
+ const repo = context.repo.repo;
138
+ const head = process.env.RELEASE_BRANCH;
139
+ const version = process.env.VERSION;
140
+
141
+ const pr = await github.rest.pulls.create({
142
+ owner,
143
+ repo,
144
+ title: `chore(release): v${version}`,
145
+ head,
146
+ base: "main",
147
+ body: [
148
+ `Prepares \`v${version}\` for npm publication.`,
149
+ "",
150
+ "After this pull request merges into `main`, the publish workflow will release the package to npm.",
151
+ ].join("\n"),
152
+ });
153
+
154
+ core.setOutput("url", pr.data.html_url);
155
+
156
+ - name: Write job summary
157
+ env:
158
+ VERSION: ${{ steps.version.outputs.value }}
159
+ RELEASE_BRANCH: ${{ steps.version.outputs.branch }}
160
+ PR_URL: ${{ steps.pull-request.outputs.url }}
161
+ run: |
162
+ {
163
+ echo "## Release PR created"
164
+ echo
165
+ echo "- Version: \`${VERSION}\`"
166
+ echo "- Release branch: \`${RELEASE_BRANCH}\`"
167
+ echo "- Pull request: ${PR_URL}"
168
+ } >> "$GITHUB_STEP_SUMMARY"
@@ -0,0 +1,133 @@
1
+ name: Publish Package
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ paths:
8
+ - package.json
9
+ - package-lock.json
10
+ workflow_dispatch:
11
+
12
+ permissions:
13
+ contents: write
14
+ id-token: write
15
+
16
+ concurrency:
17
+ group: publish-package
18
+ cancel-in-progress: false
19
+
20
+ jobs:
21
+ publish-package:
22
+ name: Publish Package
23
+ runs-on: ubuntu-latest
24
+ environment:
25
+ # Use the same environment name in npm trusted publisher settings if you
26
+ # want npm to bind publishing to this protected environment.
27
+ name: package-publish
28
+ steps:
29
+ - name: Checkout
30
+ uses: actions/checkout@v4
31
+ with:
32
+ fetch-depth: 0
33
+
34
+ - name: Detect release version
35
+ id: release
36
+ env:
37
+ BEFORE_SHA: ${{ github.event.before }}
38
+ EVENT_NAME: ${{ github.event_name }}
39
+ REF_NAME: ${{ github.ref_name }}
40
+ run: |
41
+ VERSION="$(node -e 'process.stdout.write(require("./package.json").version)')"
42
+ PACKAGE_NAME="$(node -e 'process.stdout.write(require("./package.json").name)')"
43
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
44
+ echo "package_name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT"
45
+
46
+ if [ "${EVENT_NAME}" = "workflow_dispatch" ]; then
47
+ if [ "${REF_NAME}" != "main" ]; then
48
+ echo "Manual publishing is only allowed from main."
49
+ exit 1
50
+ fi
51
+
52
+ echo "should_publish=true" >> "$GITHUB_OUTPUT"
53
+ exit 0
54
+ fi
55
+
56
+ if [ -z "${BEFORE_SHA}" ] || [ "${BEFORE_SHA}" = "0000000000000000000000000000000000000000" ]; then
57
+ echo "should_publish=true" >> "$GITHUB_OUTPUT"
58
+ exit 0
59
+ fi
60
+
61
+ PREVIOUS_VERSION="$(git show "${BEFORE_SHA}:package.json" | node -e 'let data=""; process.stdin.on("data", chunk => data += chunk); process.stdin.on("end", () => process.stdout.write(JSON.parse(data).version));')"
62
+
63
+ if [ "${PREVIOUS_VERSION}" = "${VERSION}" ]; then
64
+ echo "Version is unchanged at ${VERSION}; skipping publish."
65
+ echo "should_publish=false" >> "$GITHUB_OUTPUT"
66
+ else
67
+ echo "Version changed from ${PREVIOUS_VERSION} to ${VERSION}."
68
+ echo "should_publish=true" >> "$GITHUB_OUTPUT"
69
+ fi
70
+
71
+ - name: Setup Node
72
+ if: steps.release.outputs.should_publish == 'true'
73
+ uses: actions/setup-node@v4
74
+ with:
75
+ node-version: 24
76
+ cache: npm
77
+ registry-url: https://registry.npmjs.org
78
+
79
+ - name: Install dependencies
80
+ if: steps.release.outputs.should_publish == 'true'
81
+ run: npm ci
82
+
83
+ - name: Run automated test suite
84
+ if: steps.release.outputs.should_publish == 'true'
85
+ run: npm test
86
+
87
+ - name: Check whether version already exists on npm
88
+ if: steps.release.outputs.should_publish == 'true'
89
+ id: npm-check
90
+ env:
91
+ PACKAGE_NAME: ${{ steps.release.outputs.package_name }}
92
+ VERSION: ${{ steps.release.outputs.version }}
93
+ run: |
94
+ if npm view "${PACKAGE_NAME}@${VERSION}" version >/dev/null 2>&1; then
95
+ echo "should_publish=false" >> "$GITHUB_OUTPUT"
96
+ echo "Version ${VERSION} is already published to npm."
97
+ else
98
+ echo "should_publish=true" >> "$GITHUB_OUTPUT"
99
+ echo "Version ${VERSION} is not yet published to npm."
100
+ fi
101
+
102
+ - name: Publish to npm with trusted publishing
103
+ if: steps.release.outputs.should_publish == 'true' && steps.npm-check.outputs.should_publish == 'true'
104
+ run: npm publish --provenance --access public
105
+
106
+ - name: Tag published version
107
+ if: steps.release.outputs.should_publish == 'true' && steps.npm-check.outputs.should_publish == 'true'
108
+ env:
109
+ VERSION: ${{ steps.release.outputs.version }}
110
+ run: |
111
+ if git rev-parse "v${VERSION}" >/dev/null 2>&1; then
112
+ echo "Tag v${VERSION} already exists locally."
113
+ exit 0
114
+ fi
115
+
116
+ git config user.name "github-actions[bot]"
117
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
118
+ git tag "v${VERSION}"
119
+ git push origin "refs/tags/v${VERSION}"
120
+
121
+ - name: Write job summary
122
+ env:
123
+ VERSION: ${{ steps.release.outputs.version }}
124
+ SHOULD_PUBLISH: ${{ steps.release.outputs.should_publish }}
125
+ NPM_PUBLISH: ${{ steps.npm-check.outputs.should_publish }}
126
+ run: |
127
+ {
128
+ echo "## Publish summary"
129
+ echo
130
+ echo "- Version: \`${VERSION}\`"
131
+ echo "- Trigger requested publish: \`${SHOULD_PUBLISH:-false}\`"
132
+ echo "- npm publish executed: \`${NPM_PUBLISH:-false}\`"
133
+ } >> "$GITHUB_STEP_SUMMARY"
package/bin/cli.js CHANGED
@@ -11,6 +11,7 @@
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
16
17
  * --dev → use staging API base URL
@@ -43,6 +44,7 @@ import inquirer from 'inquirer';
43
44
  import fetch from 'node-fetch';
44
45
  import crypto from 'crypto';
45
46
  import http from 'http';
47
+ import { resolveProviderModel } from '../llm/modelResolver.js';
46
48
  import { exec as execCmd } from 'child_process';
47
49
  import { promisify } from 'util';
48
50
  const exec = promisify(execCmd);
@@ -103,6 +105,40 @@ function parseVercelBypassArgs(argsList) {
103
105
  return { secret, args: nextArgs, invalid };
104
106
  }
105
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
+
106
142
  const vercelBypassResult = parseVercelBypassArgs(args);
107
143
  if (vercelBypassResult.invalid) {
108
144
  console.warn('⚠️ Invalid --vercel-bypass value. Provide a non-empty secret.');
@@ -197,6 +233,7 @@ export const __test__ = {
197
233
  readJsonlSteps,
198
234
  parseVercelBypassArgs,
199
235
  createVercelBypassHeader,
236
+ parseProviderArgs,
200
237
  };
201
238
 
202
239
  // Look for --model=<id> or --model <id>
@@ -218,6 +255,18 @@ if (modelFlagIndex >= 0) {
218
255
  args.splice(modelFlagIndex, modelOverride ? 2 : 1);
219
256
  }
220
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
+
221
270
  // Look for --debug / --debug=<bool> / --no-debug
222
271
  // Normalize CLI boolean strings into true/false/null for optional flags.
223
272
  const parseBool = (v) => {
@@ -334,6 +383,7 @@ Options:
334
383
  --init Scaffold project folders and a welcome mission
335
384
  --turns=<n> Override max turns for this run (e.g., --turns=30)
336
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)
337
387
  --dev Use the staging API base URL
338
388
  --vercel-bypass=<secret> Send Vercel protection bypass header for protected deployments
339
389
  --help Show this help message
@@ -469,25 +519,8 @@ const flatMissions = allResults.flatMap(entry => {
469
519
  });
470
520
  });
471
521
 
472
- // Read provider/model from config (allow env override for model)
473
- const cfgPath = path.resolve(process.cwd(), 'testronaut-config.json');
474
- let llmProvider = 'openai';
475
- let llmModel = 'gpt-4o';
476
-
477
- if (fs.existsSync(cfgPath)) {
478
- try {
479
- const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
480
- if (cfg?.provider) llmProvider = String(cfg.provider);
481
- if (cfg?.model) llmModel = String(cfg.model);
482
- } catch (e) {
483
- console.warn('⚠️ Could not read testronaut-config.json:', e.message);
484
- }
485
- }
486
-
487
- // Env override takes precedence for the model (matches runtime behavior)
488
- if (process.env.TESTRONAUT_MODEL?.trim()) {
489
- llmModel = process.env.TESTRONAUT_MODEL.trim();
490
- }
522
+ // Read provider/model from config (allow env override)
523
+ const { provider: llmProvider, model: llmModel } = resolveProviderModel({ cwd: process.cwd() });
491
524
 
492
525
 
493
526
  const report = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "1.3.3",
3
+ "version": "1.3.5",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "bin/cli.js"
@@ -19,6 +19,10 @@
19
19
  "author": "",
20
20
  "license": "MIT",
21
21
  "description": "",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/mission-testronaut/testronaut-cli"
25
+ },
22
26
  "dependencies": {
23
27
  "@dqbd/tiktoken": "^1.0.21",
24
28
  "@google/generative-ai": "^0.24.1",
@@ -67,6 +67,49 @@ describe('cli helpers', () => {
67
67
  expect(res.invalid).toBe(true);
68
68
  });
69
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
+
70
113
  it('builds the vercel bypass header when provided', () => {
71
114
  const { createVercelBypassHeader } = __test__;
72
115
  expect(createVercelBypassHeader('abc')).toEqual({ 'x-vercel-protection-bypass': 'abc' });
@@ -35,8 +35,8 @@ export function generateHtmlReport(report, outputPath) {
35
35
  // Escaping helpers for text and HTML attributes
36
36
  const esc = (s) => String(s ?? '')
37
37
  .replace(/&/g, '&amp;').replace(/</g, '&lt;')
38
- .replace(/>/g, '&gt;');
39
- const escAttr = (s) => esc(s).replace(/"/g, '&quot;'); // for HTML attributes
38
+ .replace(/>/g, '&gt;')
39
+ .replace(/"/g, '&quot;');
40
40
  const badge = (status) =>
41
41
  status === 'passed' ? '✅ Passed' :
42
42
  status === 'failed' ? '❌ Failed' : (status || '—');
@@ -70,7 +70,7 @@ export function generateHtmlReport(report, outputPath) {
70
70
  ? step.summary.trim()
71
71
  : '';
72
72
  const planSpan = plan
73
- ? `<span class="plan" title="${escAttr(plan)}">${esc(plan)}</span>`
73
+ ? `<span class="plan" title="${esc(plan)}">${esc(plan)}</span>`
74
74
  : '';
75
75
 
76
76
  return `
@@ -78,7 +78,7 @@ export function generateHtmlReport(report, outputPath) {
78
78
  <summary>
79
79
  <span class="turn">Turn ${esc((step.turn ?? idx) + 1)}${esc(attemptLabel)}</span>
80
80
  ${planSpan}
81
- <span class="step-result ${ok ? 'ok' : 'bad'}" ${resultTooltip ? `title="${escAttr(resultTooltip)}"` : ''}>${esc(resultRaw)}</span>
81
+ <span class="step-result ${ok ? 'ok' : 'bad'}" ${resultTooltip ? `title="${esc(resultTooltip)}"` : ''}>${esc(resultRaw)}</span>
82
82
  <span class="tokens">tokens: ${esc(step.tokensUsed ?? '—')} / total: ${esc(step.totalTokensUsed ?? '—')}</span>
83
83
  </summary>
84
84
  <pre class="events">${esc(events.join('\n')) || '(no events)'}</pre>