testronaut 1.12.1 → 2.0.0
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/.github/workflows/periapsis-vulnerability-exception-request.yml +18 -3
- package/.github/workflows/periapsis-vulnerability-triage.yml +111 -0
- package/README.md +8 -0
- package/TODO.md +4 -0
- package/package.json +6 -3
- package/policy/vulnerability-policy.json +31 -1
- package/tests/dependencyCompatibility.test.js +22 -0
- package/tests/toolsTests/generateHtmlReport.test.js +17 -0
- package/tests/toolsTests/playwrightSetup.test.js +69 -0
- package/tools/generateHtmlReport.js +112 -10
- package/tools/playwrightSetup.js +39 -27
|
@@ -24,6 +24,9 @@ on:
|
|
|
24
24
|
severities:
|
|
25
25
|
description: "Comma-separated severities"
|
|
26
26
|
required: true
|
|
27
|
+
adjustedSeverity:
|
|
28
|
+
description: "Optional re-rated severity (alert type only, must be lower than severities)"
|
|
29
|
+
required: false
|
|
27
30
|
reason:
|
|
28
31
|
description: "Reason for exception"
|
|
29
32
|
required: true
|
|
@@ -57,6 +60,7 @@ jobs:
|
|
|
57
60
|
INPUT_ECOSYSTEM: ${{ inputs.ecosystem }}
|
|
58
61
|
INPUT_SCOPE: ${{ inputs.dependencyScope }}
|
|
59
62
|
INPUT_SEVERITIES: ${{ inputs.severities }}
|
|
63
|
+
INPUT_ADJUSTED_SEVERITY: ${{ inputs.adjustedSeverity }}
|
|
60
64
|
INPUT_REASON: ${{ inputs.reason }}
|
|
61
65
|
INPUT_UNTIL: ${{ inputs.acceptedUntil }}
|
|
62
66
|
INPUT_APPROVED_BY: ${{ inputs.approvedBy }}
|
|
@@ -71,6 +75,7 @@ jobs:
|
|
|
71
75
|
${INPUT_ECOSYSTEM:+--ecosystem "$INPUT_ECOSYSTEM"} \
|
|
72
76
|
${INPUT_SCOPE:+--dependency-scope "$INPUT_SCOPE"} \
|
|
73
77
|
--severities "$INPUT_SEVERITIES" \
|
|
78
|
+
${INPUT_ADJUSTED_SEVERITY:+--adjusted-severity "$INPUT_ADJUSTED_SEVERITY"} \
|
|
74
79
|
--reason "$INPUT_REASON" \
|
|
75
80
|
--accepted-until "$INPUT_UNTIL" \
|
|
76
81
|
--approved-by "$INPUT_APPROVED_BY" \
|
|
@@ -81,6 +86,16 @@ jobs:
|
|
|
81
86
|
uses: peter-evans/create-pull-request@v6
|
|
82
87
|
with:
|
|
83
88
|
commit-message: "Add vulnerability SLA exception"
|
|
84
|
-
title: "Add vulnerability SLA exception"
|
|
85
|
-
body:
|
|
86
|
-
|
|
89
|
+
title: "Add vulnerability SLA exception (${{ inputs.exceptionType }}${{ inputs.alertNumber && format(' #{0}', inputs.alertNumber) || '' }})"
|
|
90
|
+
body: |
|
|
91
|
+
Adds a structured Periapsis vulnerability exception. Review required before merge.
|
|
92
|
+
|
|
93
|
+
- Type: ${{ inputs.exceptionType }}
|
|
94
|
+
- Alert: ${{ inputs.alertNumber || 'n/a' }}
|
|
95
|
+
- Severities: ${{ inputs.severities }}
|
|
96
|
+
- Adjusted severity: ${{ inputs.adjustedSeverity || 'n/a' }}
|
|
97
|
+
- Accepted until: ${{ inputs.acceptedUntil }}
|
|
98
|
+
- Evidence: ${{ inputs.evidenceRef }}
|
|
99
|
+
|
|
100
|
+
Reason: ${{ inputs.reason }}
|
|
101
|
+
branch: "periapsis/vulnerability-exception-${{ github.run_id }}"
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
name: Periapsis Vulnerability Triage
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
schedule:
|
|
5
|
+
- cron: "30 14 * * *"
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
inputs:
|
|
8
|
+
alertNumber:
|
|
9
|
+
description: "Dependabot alert number (leave empty to triage pending alerts per policy.triage.automation)"
|
|
10
|
+
required: false
|
|
11
|
+
mode:
|
|
12
|
+
description: "Use the policy's triage.mode, or override it for this run"
|
|
13
|
+
required: false
|
|
14
|
+
type: choice
|
|
15
|
+
default: policy
|
|
16
|
+
options: [policy, recommend, apply]
|
|
17
|
+
|
|
18
|
+
permissions:
|
|
19
|
+
contents: write # commit applied adjustments for the exception PR
|
|
20
|
+
pull-requests: write # open the exception PR
|
|
21
|
+
vulnerability-alerts: read
|
|
22
|
+
actions: write # restore the triage ledger artifact; single-alert apply dispatches the exception request
|
|
23
|
+
|
|
24
|
+
# The ledger is read at the start and written at the end of each run; never run two at once.
|
|
25
|
+
concurrency:
|
|
26
|
+
group: periapsis-vulnerability-triage
|
|
27
|
+
cancel-in-progress: false
|
|
28
|
+
|
|
29
|
+
jobs:
|
|
30
|
+
periapsis-vulnerability-triage:
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
env:
|
|
33
|
+
SMTP_CONFIGURED: ${{ secrets.PERIAPSIS_SMTP_HOST != '' }}
|
|
34
|
+
steps:
|
|
35
|
+
- uses: actions/checkout@v4
|
|
36
|
+
- name: Install periapsis
|
|
37
|
+
run: npm install -g periapsis
|
|
38
|
+
- name: Restore triage ledger
|
|
39
|
+
env:
|
|
40
|
+
GH_TOKEN: ${{ github.token }}
|
|
41
|
+
run: |
|
|
42
|
+
ID=$(gh api "repos/$GITHUB_REPOSITORY/actions/artifacts?name=periapsis-triage-ledger&per_page=1" --jq '.artifacts[0] | select(.expired == false) | .id' || true)
|
|
43
|
+
if [ -n "$ID" ]; then
|
|
44
|
+
mkdir -p policy/triage
|
|
45
|
+
gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$ID/zip" > "$RUNNER_TEMP/ledger.zip"
|
|
46
|
+
unzip -o "$RUNNER_TEMP/ledger.zip" -d policy/triage
|
|
47
|
+
echo "Restored triage ledger from artifact $ID"
|
|
48
|
+
else
|
|
49
|
+
echo "No previous triage ledger found; starting fresh"
|
|
50
|
+
fi
|
|
51
|
+
- name: Triage vulnerability alerts
|
|
52
|
+
id: triage
|
|
53
|
+
run: |
|
|
54
|
+
ARGS=()
|
|
55
|
+
if [ -n "$ALERT_NUMBER" ]; then ARGS+=(--alert "$ALERT_NUMBER"); else ARGS+=(--pending); fi
|
|
56
|
+
if [ -n "$MODE" ] && [ "$MODE" != "policy" ]; then ARGS+=(--mode "$MODE"); fi
|
|
57
|
+
periapsis vulnerability triage "${ARGS[@]}"
|
|
58
|
+
env:
|
|
59
|
+
ALERT_NUMBER: ${{ inputs.alertNumber }}
|
|
60
|
+
MODE: ${{ inputs.mode }}
|
|
61
|
+
GITHUB_TOKEN: ${{ secrets.PERIAPSIS_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
|
62
|
+
PERIAPSIS_LLM_PROVIDER: ${{ vars.PERIAPSIS_LLM_PROVIDER }}
|
|
63
|
+
PERIAPSIS_LLM_MODEL: ${{ vars.PERIAPSIS_LLM_MODEL }}
|
|
64
|
+
PERIAPSIS_LLM_API_KEY: ${{ secrets.PERIAPSIS_LLM_API_KEY }}
|
|
65
|
+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
66
|
+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
67
|
+
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
|
68
|
+
PERIAPSIS_SLACK_WEBHOOK: ${{ secrets.PERIAPSIS_SLACK_WEBHOOK }}
|
|
69
|
+
- name: Save triage ledger
|
|
70
|
+
if: always()
|
|
71
|
+
uses: actions/upload-artifact@v4
|
|
72
|
+
with:
|
|
73
|
+
name: periapsis-triage-ledger
|
|
74
|
+
path: policy/triage/ledger.json
|
|
75
|
+
if-no-files-found: ignore
|
|
76
|
+
retention-days: 90
|
|
77
|
+
- name: Upload triage reports
|
|
78
|
+
if: always()
|
|
79
|
+
uses: actions/upload-artifact@v4
|
|
80
|
+
with:
|
|
81
|
+
name: vulnerability-triage-${{ github.run_id }}
|
|
82
|
+
path: |
|
|
83
|
+
policy/triage/
|
|
84
|
+
!policy/triage/ledger.json
|
|
85
|
+
if-no-files-found: ignore
|
|
86
|
+
retention-days: 90
|
|
87
|
+
- name: Validate applied adjustments
|
|
88
|
+
if: steps.triage.outputs.applied != '' && steps.triage.outputs.applied != '0'
|
|
89
|
+
run: periapsis vulnerability validate
|
|
90
|
+
- name: Open exception PR for applied adjustments
|
|
91
|
+
if: steps.triage.outputs.applied != '' && steps.triage.outputs.applied != '0'
|
|
92
|
+
uses: peter-evans/create-pull-request@v6
|
|
93
|
+
with:
|
|
94
|
+
add-paths: policy/vulnerability-exceptions.json
|
|
95
|
+
commit-message: "Add Periapsis triage severity adjustments"
|
|
96
|
+
title: "Periapsis triage: ${{ steps.triage.outputs.applied }} severity adjustment(s)"
|
|
97
|
+
body-path: policy/triage/digest.md
|
|
98
|
+
branch: "periapsis/triage-adjustments-${{ github.run_id }}"
|
|
99
|
+
- name: Send triage email
|
|
100
|
+
if: always() && steps.triage.outputs.email == 'true' && env.SMTP_CONFIGURED == 'true'
|
|
101
|
+
uses: dawidd6/action-send-mail@v3
|
|
102
|
+
with:
|
|
103
|
+
server_address: ${{ secrets.PERIAPSIS_SMTP_HOST }}
|
|
104
|
+
server_port: ${{ secrets.PERIAPSIS_SMTP_PORT || 587 }}
|
|
105
|
+
username: ${{ secrets.PERIAPSIS_SMTP_USER }}
|
|
106
|
+
password: ${{ secrets.PERIAPSIS_SMTP_PASSWORD }}
|
|
107
|
+
subject: ${{ steps.triage.outputs.subject }}
|
|
108
|
+
to: ${{ secrets.PERIAPSIS_EMAIL_TO }}
|
|
109
|
+
from: ${{ secrets.PERIAPSIS_EMAIL_FROM || secrets.PERIAPSIS_SMTP_USER }}
|
|
110
|
+
body: file://${{ steps.triage.outputs.report }}
|
|
111
|
+
convert_markdown: true
|
package/README.md
CHANGED
|
@@ -49,6 +49,10 @@ Includes:
|
|
|
49
49
|
|
|
50
50
|
## 📦 Installation
|
|
51
51
|
|
|
52
|
+
Testronaut 2.x requires **Node.js 20 or newer**. This is a breaking change from
|
|
53
|
+
Testronaut 1.x because the bundled Playwright runtime no longer supports Node 18.
|
|
54
|
+
Check your runtime with `node --version` before upgrading.
|
|
55
|
+
|
|
52
56
|
**Global install** (recommended — use `testronaut` directly):
|
|
53
57
|
```bash
|
|
54
58
|
npm install -g testronaut
|
|
@@ -230,6 +234,10 @@ testronaut upload run_1788745106444
|
|
|
230
234
|
testronaut upload run_1788745106444.json --no-upload-screenshots
|
|
231
235
|
```
|
|
232
236
|
|
|
237
|
+
The local HTML report stays focused on inspecting one run without a service
|
|
238
|
+
dependency. Upload a report to Mission Control for filterable per-mission and
|
|
239
|
+
per-turn token, estimated-cost, and duration charts, including cumulative views.
|
|
240
|
+
|
|
233
241
|
Chain missions together:
|
|
234
242
|
```js
|
|
235
243
|
await runMissions({
|
package/TODO.md
CHANGED
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
- [ ] Treat `--json` output as a stable automation contract with documented schemas and tests for every supported command.
|
|
10
10
|
- [ ] If authentication ever moves outside project config, continue reading existing `sessionToken` values and provide an opt-in migration path before deprecating legacy storage.
|
|
11
11
|
|
|
12
|
+
## Browser Automation Follow-Ups
|
|
13
|
+
|
|
14
|
+
- [ ] Fix `click_text` so it prioritizes exact-text actionable elements (`button`, `a`, and elements with interactive roles) instead of clicking the first container whose descendant text includes the target. Define an explicit fallback for partial matches and add regression tests covering nested containers, duplicate labels, hidden elements, and the Add, Logout, and Report failures observed in the examples run.
|
|
15
|
+
|
|
12
16
|
## Session and Reporting Follow-Ups
|
|
13
17
|
|
|
14
18
|
- [ ] Evaluate a true shared browser-context mode for suites that require session storage or in-memory application state; define page reset, crash recovery, and concurrency behavior first.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "testronaut",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"main": "./runner/testronaut.js",
|
|
5
5
|
"bin": {
|
|
6
6
|
"testronaut": "bin/cli.js"
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
"./runner/testronaut": "./runner/testronaut.js"
|
|
12
12
|
},
|
|
13
13
|
"type": "module",
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
14
17
|
"scripts": {
|
|
15
18
|
"benchmark:optimization": "node ./experiments/optimizationBenchmark.js",
|
|
16
19
|
"benchmark:cross-provider": "node ./experiments/crossProviderBenchmark.js",
|
|
@@ -38,7 +41,7 @@
|
|
|
38
41
|
"jiti": "^2.7.0",
|
|
39
42
|
"node-fetch": "^3.3.2",
|
|
40
43
|
"openai": "^6.39.0",
|
|
41
|
-
"playwright": "^1.
|
|
44
|
+
"playwright": "^1.63.0",
|
|
42
45
|
"prompts": "^2.4.2"
|
|
43
46
|
},
|
|
44
47
|
"keywords": [
|
|
@@ -56,7 +59,7 @@
|
|
|
56
59
|
],
|
|
57
60
|
"devDependencies": {
|
|
58
61
|
"@types/node": "^26.5.0",
|
|
59
|
-
"periapsis": "^2.
|
|
62
|
+
"periapsis": "^2.1.2",
|
|
60
63
|
"vitest": "^4.1.11"
|
|
61
64
|
},
|
|
62
65
|
"homepage": "https://testronaut.app"
|
|
@@ -29,5 +29,35 @@
|
|
|
29
29
|
"allowPackageScope": true,
|
|
30
30
|
"allowRepoWideScope": true,
|
|
31
31
|
"allowOrgWideScope": false
|
|
32
|
+
},
|
|
33
|
+
"triage": {
|
|
34
|
+
"mode": "recommend",
|
|
35
|
+
"notify": [
|
|
36
|
+
"slack",
|
|
37
|
+
"email"
|
|
38
|
+
],
|
|
39
|
+
"apply": {
|
|
40
|
+
"minimumConfidence": "high",
|
|
41
|
+
"minimumConfidenceSingleStep": "medium",
|
|
42
|
+
"allowedReachability": [
|
|
43
|
+
"not_reachable",
|
|
44
|
+
"potentially_reachable"
|
|
45
|
+
],
|
|
46
|
+
"approvedBy": [
|
|
47
|
+
"periapsis-triage"
|
|
48
|
+
],
|
|
49
|
+
"workflow": "periapsis-vulnerability-exception-request.yml",
|
|
50
|
+
"ref": null
|
|
51
|
+
},
|
|
52
|
+
"automation": {
|
|
53
|
+
"minimumSeverity": "high",
|
|
54
|
+
"scopes": [
|
|
55
|
+
"runtime",
|
|
56
|
+
"development"
|
|
57
|
+
],
|
|
58
|
+
"maxTriagesPerRun": 10,
|
|
59
|
+
"retriageAfterDays": 30,
|
|
60
|
+
"skipAlerts": []
|
|
61
|
+
}
|
|
32
62
|
}
|
|
33
|
-
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
const root = path.resolve(import.meta.dirname, '..');
|
|
6
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
7
|
+
const lock = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf8'));
|
|
8
|
+
|
|
9
|
+
describe('runtime dependency compatibility', () => {
|
|
10
|
+
it('declares the Node runtime required by the installed Playwright version', () => {
|
|
11
|
+
const playwright = lock.packages['node_modules/playwright'];
|
|
12
|
+
expect(pkg.engines.node).toBe('>=20');
|
|
13
|
+
expect(playwright.engines.node).toBe('>=20');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('keeps the manifest, lockfile root, and installed Playwright versions aligned', () => {
|
|
17
|
+
expect(pkg.dependencies.playwright).toBe('^1.63.0');
|
|
18
|
+
expect(lock.packages[''].dependencies.playwright).toBe(pkg.dependencies.playwright);
|
|
19
|
+
expect(lock.packages['node_modules/playwright'].version).toBe('1.63.0');
|
|
20
|
+
expect(lock.packages['node_modules/playwright-core'].version).toBe('1.63.0');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -18,6 +18,23 @@ afterEach(() => {
|
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
describe('generateHtmlReport', () => {
|
|
21
|
+
it('points local reports to hosted analytics for the same run', () => {
|
|
22
|
+
const report = { runId: 'run_analytics_123', missions: [] };
|
|
23
|
+
const outPath = path.join(tmpDir, 'analytics-cta.html');
|
|
24
|
+
generateHtmlReport(report, outPath);
|
|
25
|
+
const html = fs.readFileSync(outPath, 'utf8');
|
|
26
|
+
expect(html).toContain('Need deeper analytics?');
|
|
27
|
+
expect(html).toContain('filtered and cumulative token, estimated-cost, and duration charts');
|
|
28
|
+
expect(html).toContain('testronaut upload run_analytics_123');
|
|
29
|
+
expect(html).toContain('aria-label="Copy upload command"');
|
|
30
|
+
expect(html).toContain("navigator.clipboard.writeText(command)");
|
|
31
|
+
expect(html).toContain('id="analytics-toggle" aria-expanded="true"');
|
|
32
|
+
expect(html).toContain("card.classList.toggle('collapsed', expanded)");
|
|
33
|
+
expect(html).toContain('href="https://mission.testronaut.app/"');
|
|
34
|
+
expect(html).toContain('href="https://docs.testronaut.app/docs/category/reports-and-mission-control"');
|
|
35
|
+
expect(html.indexOf('<aside class="analytics-cta">')).toBeLessThan(html.indexOf('<div class="summary">'));
|
|
36
|
+
});
|
|
37
|
+
|
|
21
38
|
it('renders explicit suite, mission, and turn durations', () => {
|
|
22
39
|
const report = {
|
|
23
40
|
runId: 'timed_run',
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { ensureBrowsers } from '../../tools/playwrightSetup.js';
|
|
6
|
+
|
|
7
|
+
const tempDirs = [];
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
delete process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD;
|
|
11
|
+
while (tempDirs.length) fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
function tempProject(lockfile) {
|
|
15
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'testronaut-playwright-'));
|
|
16
|
+
tempDirs.push(cwd);
|
|
17
|
+
if (lockfile) fs.writeFileSync(path.join(cwd, lockfile), '');
|
|
18
|
+
return cwd;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('Playwright browser setup', () => {
|
|
22
|
+
it('installs the browser matching the locally resolved Playwright version', async () => {
|
|
23
|
+
const runCommand = vi.fn().mockResolvedValue(undefined);
|
|
24
|
+
const cliPath = '/project/node_modules/playwright/cli.js';
|
|
25
|
+
|
|
26
|
+
await ensureBrowsers(
|
|
27
|
+
{ browser: 'chromium', withDeps: true },
|
|
28
|
+
{ getInstallation: () => ({ version: '1.63.0', cliPath }), runCommand, cwd: tempProject('package-lock.json') },
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
expect(runCommand).toHaveBeenCalledOnce();
|
|
32
|
+
expect(runCommand).toHaveBeenCalledWith(
|
|
33
|
+
process.execPath,
|
|
34
|
+
[cliPath, 'install', 'chromium', '--with-deps'],
|
|
35
|
+
{ PLAYWRIGHT_BROWSERS_PATH: '0' },
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it.each([
|
|
40
|
+
['pnpm-lock.yaml', 'pnpm', ['exec', 'playwright', 'install', 'chromium']],
|
|
41
|
+
['yarn.lock', 'yarn', ['playwright', 'install', 'chromium']],
|
|
42
|
+
['bun.lockb', 'bunx', ['playwright', 'install', 'chromium']],
|
|
43
|
+
['package-lock.json', 'npm', ['exec', '--yes', '--', 'playwright', 'install', 'chromium']],
|
|
44
|
+
])('uses the project package manager fallback for %s', async (lockfile, command, args) => {
|
|
45
|
+
const runCommand = vi.fn()
|
|
46
|
+
.mockRejectedValueOnce(new Error('npx unavailable'))
|
|
47
|
+
.mockResolvedValueOnce(undefined);
|
|
48
|
+
|
|
49
|
+
await ensureBrowsers(
|
|
50
|
+
{ browser: 'chromium', withDeps: false },
|
|
51
|
+
{
|
|
52
|
+
getInstallation: () => ({ version: '1.63.0', cliPath: '/project/node_modules/playwright/cli.js' }),
|
|
53
|
+
runCommand,
|
|
54
|
+
cwd: tempProject(lockfile),
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(runCommand).toHaveBeenNthCalledWith(2, command, args, { PLAYWRIGHT_BROWSERS_PATH: '0' });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('does not invoke an installer when browser downloads are disabled', async () => {
|
|
62
|
+
process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = '1';
|
|
63
|
+
const runCommand = vi.fn();
|
|
64
|
+
|
|
65
|
+
await ensureBrowsers({}, { getInstallation: vi.fn(), runCommand, cwd: tempProject() });
|
|
66
|
+
|
|
67
|
+
expect(runCommand).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -308,6 +308,7 @@ export function generateHtmlReport(report, outputPath) {
|
|
|
308
308
|
/* ===== Header ===== */
|
|
309
309
|
.header{ max-width:960px; margin:0 auto 16px; padding:22px 24px; position:relative; overflow:hidden; }
|
|
310
310
|
.header::after{content:"";position:absolute;right:0;top:0;width:92px;height:4px;background:linear-gradient(90deg,var(--orange),var(--amber));}
|
|
311
|
+
.header-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,340px);gap:22px;align-items:start;}
|
|
311
312
|
.eyebrow{margin-bottom:7px;color:var(--cyan);font-size:11px;font-weight:800;letter-spacing:.18em;text-transform:uppercase;}
|
|
312
313
|
h1{ margin:0 0 12px; font-size:27px; font-weight:850; color:#fff; letter-spacing:-.02em; }
|
|
313
314
|
.run-meta{ color:var(--text-muted); display:grid; gap:4px; }
|
|
@@ -341,7 +342,7 @@ export function generateHtmlReport(report, outputPath) {
|
|
|
341
342
|
.filter-empty{max-width:928px;margin:12px auto;padding:28px 16px;text-align:center;border:1px dashed var(--hairline-strong);border-radius:16px;color:var(--text-muted);}
|
|
342
343
|
.filter-empty strong{display:block;color:var(--text);font-size:18px;margin-bottom:6px;}
|
|
343
344
|
.mission-group.nonmatching{opacity:.45;}
|
|
344
|
-
@media(max-width:
|
|
345
|
+
@media(max-width:760px){.header-layout{grid-template-columns:1fr}.match-count{width:100%;margin-left:0}.tag-filter{padding:14px 12px}.meta{display:none}}
|
|
345
346
|
|
|
346
347
|
/* ===== Disclosure blocks ===== */
|
|
347
348
|
details{ background: var(--panel); border:1px solid var(--hairline); border-radius:16px; margin:10px 0; overflow:hidden; }
|
|
@@ -420,19 +421,76 @@ export function generateHtmlReport(report, outputPath) {
|
|
|
420
421
|
/* container width */
|
|
421
422
|
.container{ max-width:960px; margin:0 auto; }
|
|
422
423
|
.empty{ color: var(--text-muted); padding:12px; }
|
|
424
|
+
.analytics-cta{
|
|
425
|
+
padding:16px; background:rgba(2,6,23,.34); border:1px solid var(--hairline-strong);
|
|
426
|
+
border-radius:13px;
|
|
427
|
+
color:var(--text-muted); font-size:13px;
|
|
428
|
+
}
|
|
429
|
+
.analytics-toggle{
|
|
430
|
+
width:100%;padding:0;display:flex;align-items:center;justify-content:space-between;gap:12px;
|
|
431
|
+
color:var(--text);background:transparent;border:0;cursor:pointer;text-align:left;font:inherit;font-weight:800;
|
|
432
|
+
}
|
|
433
|
+
.analytics-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;transition:transform .18s ease;}
|
|
434
|
+
.analytics-cta.collapsed .analytics-toggle svg{transform:rotate(-90deg);}
|
|
435
|
+
.analytics-body{display:grid;gap:11px;margin-top:11px;}
|
|
436
|
+
.analytics-cta.collapsed .analytics-body{display:none;}
|
|
437
|
+
.analytics-cta code{
|
|
438
|
+
color:#bfdbfe; background:rgba(59,130,246,.12); border:1px solid rgba(96,165,250,.25);
|
|
439
|
+
border-radius:8px; padding:6px 9px; white-space:nowrap;
|
|
440
|
+
}
|
|
441
|
+
.upload-command{display:flex;align-items:stretch;min-width:0;}
|
|
442
|
+
.upload-command code{flex:1;min-width:0;overflow:auto;border-radius:8px 0 0 8px;}
|
|
443
|
+
.copy-command{
|
|
444
|
+
display:grid;place-items:center;width:36px;flex:none;cursor:pointer;color:#bfdbfe;
|
|
445
|
+
background:rgba(59,130,246,.18);border:1px solid rgba(96,165,250,.32);border-left:0;
|
|
446
|
+
border-radius:0 8px 8px 0;
|
|
447
|
+
}
|
|
448
|
+
.copy-command:hover,.copy-command.copied{color:#fff;background:rgba(59,130,246,.3);}
|
|
449
|
+
.copy-command svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.8;}
|
|
450
|
+
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;}
|
|
451
|
+
.analytics-links{display:flex;gap:8px;flex-wrap:wrap;}
|
|
452
|
+
.analytics-links a{
|
|
453
|
+
color:#bfdbfe;text-decoration:none;border:1px solid rgba(96,165,250,.32);
|
|
454
|
+
background:rgba(59,130,246,.12);border-radius:999px;padding:6px 10px;font-size:12px;font-weight:750;
|
|
455
|
+
}
|
|
456
|
+
.analytics-links a:hover{background:rgba(59,130,246,.22);}
|
|
423
457
|
</style>
|
|
424
458
|
</head>
|
|
425
459
|
<body>
|
|
426
460
|
<div class="header glass">
|
|
427
|
-
<div class="
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
461
|
+
<div class="header-layout">
|
|
462
|
+
<div>
|
|
463
|
+
<div class="eyebrow">Mission Control · Flight Report</div>
|
|
464
|
+
<h1>🧑🚀 <span class="gradient-text">Testronaut Report</span></h1>
|
|
465
|
+
<div class="run-meta">
|
|
466
|
+
<div class="provider-meta"><span class="provider-logo" title="${esc(provider.name)}" aria-label="${esc(provider.name)}">${provider.logo}</span><strong>${esc((reportModels.length ? reportModels : [llm.model ?? '—']).join(' + '))}</strong>${reportJevUsage.evaluations ? ` <span aria-hidden="true">+</span> <span class="provider-logo" title="Jev" aria-label="Jev">${jevLogo}</span><strong>Jev</strong>` : ''}</div>
|
|
467
|
+
<div><strong>Run ID:</strong> ${esc(runId ?? '—')}</div>
|
|
468
|
+
<div><strong>Start:</strong> ${esc(startTime ?? '—')}</div>
|
|
469
|
+
<div><strong>End:</strong> ${esc(endTime ?? '—')}</div>
|
|
470
|
+
<div><strong>Duration:</strong> ${durationSec}s</div>
|
|
471
|
+
<div><strong>CLI version:</strong> ${esc(cli.version ?? '—')}</div>
|
|
472
|
+
</div>
|
|
473
|
+
</div>
|
|
474
|
+
<aside class="analytics-cta">
|
|
475
|
+
<button type="button" class="analytics-toggle" id="analytics-toggle" aria-expanded="true" aria-controls="analytics-body">
|
|
476
|
+
<span>Need deeper analytics?</span>
|
|
477
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
|
|
478
|
+
</button>
|
|
479
|
+
<div class="analytics-body" id="analytics-body">
|
|
480
|
+
<span>Upload this run for filtered and cumulative token, estimated-cost, and duration charts.</span>
|
|
481
|
+
<div class="upload-command">
|
|
482
|
+
<code id="analytics-upload-command">testronaut upload ${esc(runId ?? '')}</code>
|
|
483
|
+
<button type="button" class="copy-command" id="copy-upload-command" aria-label="Copy upload command" title="Copy upload command">
|
|
484
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M15 9V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h3"/></svg>
|
|
485
|
+
</button>
|
|
486
|
+
<span class="sr-only" id="copy-upload-status" aria-live="polite"></span>
|
|
487
|
+
</div>
|
|
488
|
+
<div class="analytics-links">
|
|
489
|
+
<a href="https://mission.testronaut.app/">Open Mission Control</a>
|
|
490
|
+
<a href="https://docs.testronaut.app/docs/category/reports-and-mission-control">Upload guide</a>
|
|
491
|
+
</div>
|
|
492
|
+
</div>
|
|
493
|
+
</aside>
|
|
436
494
|
</div>
|
|
437
495
|
</div>
|
|
438
496
|
|
|
@@ -475,6 +533,50 @@ export function generateHtmlReport(report, outputPath) {
|
|
|
475
533
|
</div>
|
|
476
534
|
<script>
|
|
477
535
|
(function () {
|
|
536
|
+
var analyticsToggle = document.getElementById('analytics-toggle');
|
|
537
|
+
var analyticsBody = document.getElementById('analytics-body');
|
|
538
|
+
if (analyticsToggle && analyticsBody) {
|
|
539
|
+
analyticsToggle.addEventListener('click', function () {
|
|
540
|
+
var card = analyticsToggle.closest('.analytics-cta');
|
|
541
|
+
var expanded = analyticsToggle.getAttribute('aria-expanded') === 'true';
|
|
542
|
+
analyticsToggle.setAttribute('aria-expanded', expanded ? 'false' : 'true');
|
|
543
|
+
analyticsBody.hidden = expanded;
|
|
544
|
+
if (card) card.classList.toggle('collapsed', expanded);
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
var copyUploadButton = document.getElementById('copy-upload-command');
|
|
548
|
+
var uploadCommand = document.getElementById('analytics-upload-command');
|
|
549
|
+
var copyUploadStatus = document.getElementById('copy-upload-status');
|
|
550
|
+
if (copyUploadButton && uploadCommand) {
|
|
551
|
+
copyUploadButton.addEventListener('click', function () {
|
|
552
|
+
var command = uploadCommand.textContent || '';
|
|
553
|
+
var copy = navigator.clipboard && window.isSecureContext
|
|
554
|
+
? navigator.clipboard.writeText(command)
|
|
555
|
+
: new Promise(function (resolve, reject) {
|
|
556
|
+
var input = document.createElement('textarea');
|
|
557
|
+
input.value = command;
|
|
558
|
+
input.setAttribute('readonly', '');
|
|
559
|
+
input.style.position = 'fixed';
|
|
560
|
+
input.style.opacity = '0';
|
|
561
|
+
document.body.appendChild(input);
|
|
562
|
+
input.select();
|
|
563
|
+
try { document.execCommand('copy') ? resolve() : reject(new Error('Copy failed')); }
|
|
564
|
+
catch (error) { reject(error); }
|
|
565
|
+
finally { document.body.removeChild(input); }
|
|
566
|
+
});
|
|
567
|
+
copy.then(function () {
|
|
568
|
+
copyUploadButton.classList.add('copied');
|
|
569
|
+
copyUploadButton.title = 'Copied!';
|
|
570
|
+
if (copyUploadStatus) copyUploadStatus.textContent = 'Upload command copied';
|
|
571
|
+
window.setTimeout(function () {
|
|
572
|
+
copyUploadButton.classList.remove('copied');
|
|
573
|
+
copyUploadButton.title = 'Copy upload command';
|
|
574
|
+
}, 1600);
|
|
575
|
+
}).catch(function () {
|
|
576
|
+
if (copyUploadStatus) copyUploadStatus.textContent = 'Could not copy upload command';
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
}
|
|
478
580
|
var selectedTags = [];
|
|
479
581
|
function applyTagFilter() {
|
|
480
582
|
var match = document.getElementById('tag-match');
|
package/tools/playwrightSetup.js
CHANGED
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
import { createRequire } from 'module';
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import {
|
|
5
|
-
import { exec as execCb } from 'child_process';
|
|
6
|
-
|
|
7
|
-
const exec = promisify(execCb);
|
|
4
|
+
import { spawn } from 'child_process';
|
|
8
5
|
|
|
9
6
|
// Resolve from the **user project** (cwd)
|
|
10
7
|
const req = createRequire(path.join(process.cwd(), 'noop.js'));
|
|
11
8
|
|
|
12
9
|
// Prefer 'playwright' → '@playwright/test' → 'playwright-core' from the USER PROJECT (cwd)
|
|
13
|
-
function
|
|
10
|
+
function getPlaywrightInstallation() {
|
|
14
11
|
console.log('🔍 Detecting installed Playwright version (from project)…');
|
|
15
12
|
const tryPkg = (name) => {
|
|
16
13
|
try {
|
|
17
14
|
const p = req.resolve(`${name}/package.json`);
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
const pkg = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
16
|
+
const bin = typeof pkg.bin === 'string' ? pkg.bin : Object.values(pkg.bin || {})[0];
|
|
17
|
+
const cliPath = bin ? path.resolve(path.dirname(p), bin) : null;
|
|
18
|
+
console.log(` • found ${name}@${pkg.version} → ${p}`);
|
|
19
|
+
return { version: pkg.version, cliPath };
|
|
21
20
|
} catch {
|
|
22
21
|
return null;
|
|
23
22
|
}
|
|
@@ -26,7 +25,7 @@ function getPlaywrightVersion() {
|
|
|
26
25
|
tryPkg('playwright') ||
|
|
27
26
|
tryPkg('@playwright/test') ||
|
|
28
27
|
tryPkg('playwright-core') ||
|
|
29
|
-
'latest'
|
|
28
|
+
{ version: 'latest', cliPath: null }
|
|
30
29
|
);
|
|
31
30
|
}
|
|
32
31
|
|
|
@@ -38,33 +37,46 @@ function pkgManagerForCwd(cwd = process.cwd()) {
|
|
|
38
37
|
return 'npm';
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
async function run(
|
|
42
|
-
console.log(`\n> ${
|
|
43
|
-
await
|
|
40
|
+
async function run(command, args, extraEnv = {}) {
|
|
41
|
+
console.log(`\n> ${[command, ...args].join(' ')}`);
|
|
42
|
+
await new Promise((resolve, reject) => {
|
|
43
|
+
const child = spawn(command, args, {
|
|
44
|
+
stdio: 'inherit',
|
|
45
|
+
env: { ...process.env, ...extraEnv },
|
|
46
|
+
});
|
|
47
|
+
child.once('error', reject);
|
|
48
|
+
child.once('exit', (code, signal) => {
|
|
49
|
+
if (code === 0) resolve();
|
|
50
|
+
else reject(new Error(`${command} exited with ${signal || `code ${code}`}`));
|
|
51
|
+
});
|
|
52
|
+
});
|
|
44
53
|
}
|
|
45
54
|
|
|
46
|
-
export async function ensureBrowsers(
|
|
55
|
+
export async function ensureBrowsers(
|
|
56
|
+
{ browser = 'chromium', withDeps = true } = {},
|
|
57
|
+
{ getInstallation = getPlaywrightInstallation, runCommand = run, cwd = process.cwd() } = {},
|
|
58
|
+
) {
|
|
47
59
|
if (process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD === '1') return;
|
|
48
60
|
|
|
49
|
-
const
|
|
50
|
-
const
|
|
61
|
+
const installation = getInstallation();
|
|
62
|
+
const installArgs = ['install', browser, ...(withDeps ? ['--with-deps'] : [])];
|
|
51
63
|
const localCacheEnv = { PLAYWRIGHT_BROWSERS_PATH: '0' }; // project-local cache
|
|
52
64
|
|
|
53
|
-
const npxCmd = `npx -y playwright@${version} install ${browser}${withDepsFlag}`;
|
|
54
65
|
try {
|
|
55
|
-
|
|
66
|
+
if (!installation.cliPath) throw new Error('No local Playwright CLI was resolved');
|
|
67
|
+
// Execute the CLI beside the package that will launch the browser. With
|
|
68
|
+
// PLAYWRIGHT_BROWSERS_PATH=0, running an npx-temporary CLI downloads into
|
|
69
|
+
// the temporary package instead of this project and leaves launch broken.
|
|
70
|
+
await runCommand(process.execPath, [installation.cliPath, ...installArgs], localCacheEnv);
|
|
56
71
|
} catch {
|
|
57
|
-
const pm =
|
|
58
|
-
fs.existsSync(path.join(process.cwd(), 'pnpm-lock.yaml')) ? 'pnpm' :
|
|
59
|
-
fs.existsSync(path.join(process.cwd(), 'yarn.lock')) ? 'yarn' :
|
|
60
|
-
fs.existsSync(path.join(process.cwd(), 'bun.lockb')) ? 'bun' : 'npm';
|
|
72
|
+
const pm = pkgManagerForCwd(cwd);
|
|
61
73
|
|
|
62
|
-
const
|
|
63
|
-
pm === 'pnpm' ?
|
|
64
|
-
pm === 'yarn' ?
|
|
65
|
-
pm === 'bun' ?
|
|
66
|
-
|
|
74
|
+
const [command, args] =
|
|
75
|
+
pm === 'pnpm' ? ['pnpm', ['exec', 'playwright', ...installArgs]] :
|
|
76
|
+
pm === 'yarn' ? ['yarn', ['playwright', ...installArgs]] :
|
|
77
|
+
pm === 'bun' ? ['bunx', ['playwright', ...installArgs]] :
|
|
78
|
+
['npm', ['exec', '--yes', '--', 'playwright', ...installArgs]];
|
|
67
79
|
|
|
68
|
-
await
|
|
80
|
+
await runCommand(command, args, localCacheEnv);
|
|
69
81
|
}
|
|
70
82
|
}
|