testronaut 1.12.2 → 2.0.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.
- package/.github/workflows/periapsis-vulnerability-exception-request.yml +18 -3
- package/.github/workflows/periapsis-vulnerability-triage.yml +111 -0
- package/README.md +4 -0
- package/TODO.md +4 -0
- package/bin/cli.js +37 -13
- package/package.json +7 -4
- package/policy/vulnerability-policy.json +31 -1
- package/tests/binTests/cli.helpers.test.js +34 -0
- package/tests/dependencyCompatibility.test.js +31 -0
- package/tests/toolsTests/playwrightSetup.test.js +69 -0
- 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
|
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/bin/cli.js
CHANGED
|
@@ -76,6 +76,18 @@ const CLI_VERSION = JSON.parse(
|
|
|
76
76
|
|
|
77
77
|
const DEFAULT_API_BASE = 'https://api.testronaut.app';
|
|
78
78
|
const DEV_API_BASE = 'https://staging.api.testronaut.app';
|
|
79
|
+
const PLAYWRIGHT_INSTALL_PROMPT = {
|
|
80
|
+
type: 'confirm',
|
|
81
|
+
name: 'doPw',
|
|
82
|
+
message: 'Install Playwright browsers now?',
|
|
83
|
+
default: true,
|
|
84
|
+
};
|
|
85
|
+
const API_KEY_PROMPT = {
|
|
86
|
+
type: 'password',
|
|
87
|
+
name: 'apiKey',
|
|
88
|
+
message: 'Enter your API key:',
|
|
89
|
+
mask: '*',
|
|
90
|
+
};
|
|
79
91
|
|
|
80
92
|
let args = process.argv.slice(2);
|
|
81
93
|
|
|
@@ -138,6 +150,14 @@ function isDirectInvocation(argv1 = process.argv[1], moduleUrl = import.meta.url
|
|
|
138
150
|
}
|
|
139
151
|
}
|
|
140
152
|
|
|
153
|
+
function isPromptExitError(error) {
|
|
154
|
+
return error instanceof Error && error.name === 'ExitPromptError';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function formatCliError(error) {
|
|
158
|
+
return error instanceof Error ? error.message : String(error);
|
|
159
|
+
}
|
|
160
|
+
|
|
141
161
|
const cliName = detectCliName();
|
|
142
162
|
|
|
143
163
|
// Look for --dev / --developer / --developer-mode / --staging
|
|
@@ -367,6 +387,8 @@ export const __test__ = {
|
|
|
367
387
|
parseOptimizationArgs,
|
|
368
388
|
detectCliName,
|
|
369
389
|
isDirectInvocation,
|
|
390
|
+
isPromptExitError,
|
|
391
|
+
formatCliError,
|
|
370
392
|
resolveMissionPath,
|
|
371
393
|
collectReportScreenshotNames,
|
|
372
394
|
collectReportScreenshotPaths,
|
|
@@ -374,6 +396,8 @@ export const __test__ = {
|
|
|
374
396
|
closestMissionMatch,
|
|
375
397
|
resolveReportFile,
|
|
376
398
|
buildEffectiveConfig,
|
|
399
|
+
PLAYWRIGHT_INSTALL_PROMPT,
|
|
400
|
+
API_KEY_PROMPT,
|
|
377
401
|
};
|
|
378
402
|
|
|
379
403
|
function resolveReportDir(config = {}, cwd = process.cwd()) {
|
|
@@ -893,9 +917,7 @@ if (args.includes('--init')) {
|
|
|
893
917
|
await createWelcomeMission();
|
|
894
918
|
|
|
895
919
|
const { default: inquirer } = await import('inquirer');
|
|
896
|
-
const { doPw } = await inquirer.prompt([
|
|
897
|
-
{ type: 'confirm', name: 'doPw', message: 'Install Playwright browsers now?', default: true }
|
|
898
|
-
]);
|
|
920
|
+
const { doPw } = await inquirer.prompt([PLAYWRIGHT_INSTALL_PROMPT]);
|
|
899
921
|
|
|
900
922
|
if (doPw) {
|
|
901
923
|
// ensure @playwright/test exists so the installer can pin to its version
|
|
@@ -1438,14 +1460,7 @@ async function handleLogin() {
|
|
|
1438
1460
|
|
|
1439
1461
|
// If API key is not provided, prompt the user for it
|
|
1440
1462
|
if (!apiKey) {
|
|
1441
|
-
const responses = await inquirer.prompt([
|
|
1442
|
-
{
|
|
1443
|
-
type: 'password',
|
|
1444
|
-
name: 'apiKey',
|
|
1445
|
-
message: 'Enter your API key:',
|
|
1446
|
-
mask: '*'
|
|
1447
|
-
}
|
|
1448
|
-
]);
|
|
1463
|
+
const responses = await inquirer.prompt([API_KEY_PROMPT]);
|
|
1449
1464
|
apiKey = responses.apiKey;
|
|
1450
1465
|
}
|
|
1451
1466
|
|
|
@@ -1487,7 +1502,7 @@ async function handleLogin() {
|
|
|
1487
1502
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
1488
1503
|
console.log('✅ Login successful! Session token saved to testronaut-config.json.');
|
|
1489
1504
|
} catch (error) {
|
|
1490
|
-
console.error('❌ Error during login:', error);
|
|
1505
|
+
console.error('❌ Error during login:', formatCliError(error));
|
|
1491
1506
|
process.exit(1);
|
|
1492
1507
|
}
|
|
1493
1508
|
}
|
|
@@ -1845,5 +1860,14 @@ async function serveLatestReport() {
|
|
|
1845
1860
|
}
|
|
1846
1861
|
|
|
1847
1862
|
if (isDirectInvocation()) {
|
|
1848
|
-
|
|
1863
|
+
try {
|
|
1864
|
+
await main();
|
|
1865
|
+
} catch (error) {
|
|
1866
|
+
if (isPromptExitError(error)) {
|
|
1867
|
+
console.error('Prompt cancelled.');
|
|
1868
|
+
process.exitCode = 130;
|
|
1869
|
+
} else {
|
|
1870
|
+
throw error;
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1849
1873
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "testronaut",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
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.17.0"
|
|
16
|
+
},
|
|
14
17
|
"scripts": {
|
|
15
18
|
"benchmark:optimization": "node ./experiments/optimizationBenchmark.js",
|
|
16
19
|
"benchmark:cross-provider": "node ./experiments/crossProviderBenchmark.js",
|
|
@@ -34,11 +37,11 @@
|
|
|
34
37
|
"cheerio": "^1.1.0",
|
|
35
38
|
"dotenv": "^17.2.0",
|
|
36
39
|
"fast-glob": "^3.3.3",
|
|
37
|
-
"inquirer": "^
|
|
40
|
+
"inquirer": "^14.2.2",
|
|
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
|
+
}
|
|
@@ -22,6 +22,40 @@ describe('cli helpers', () => {
|
|
|
22
22
|
expect(__test__.DEFAULT_API_BASE).toBe('https://api.testronaut.app');
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
+
it('keeps interactive prompt contracts compatible with Inquirer 14', () => {
|
|
26
|
+
expect(__test__.PLAYWRIGHT_INSTALL_PROMPT).toEqual({
|
|
27
|
+
type: 'confirm',
|
|
28
|
+
name: 'doPw',
|
|
29
|
+
message: 'Install Playwright browsers now?',
|
|
30
|
+
default: true,
|
|
31
|
+
});
|
|
32
|
+
expect(__test__.API_KEY_PROMPT).toEqual({
|
|
33
|
+
type: 'password',
|
|
34
|
+
name: 'apiKey',
|
|
35
|
+
message: 'Enter your API key:',
|
|
36
|
+
mask: '*',
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('recognizes Inquirer prompt cancellation without swallowing other failures', () => {
|
|
41
|
+
const cancelled = new Error('User force closed the prompt with SIGINT');
|
|
42
|
+
cancelled.name = 'ExitPromptError';
|
|
43
|
+
|
|
44
|
+
expect(__test__.isPromptExitError(cancelled)).toBe(true);
|
|
45
|
+
expect(__test__.isPromptExitError(new Error('network failed'))).toBe(false);
|
|
46
|
+
expect(__test__.isPromptExitError({ name: 'ExitPromptError' })).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('formats CLI errors without printing stack traces', () => {
|
|
50
|
+
const error = new Error('login 401 Unauthorized: {"error":"Invalid API key"}');
|
|
51
|
+
|
|
52
|
+
expect(__test__.formatCliError(error)).toBe(
|
|
53
|
+
'login 401 Unauthorized: {"error":"Invalid API key"}',
|
|
54
|
+
);
|
|
55
|
+
expect(__test__.formatCliError(error)).not.toContain(' at ');
|
|
56
|
+
expect(__test__.formatCliError('request failed')).toBe('request failed');
|
|
57
|
+
});
|
|
58
|
+
|
|
25
59
|
it('guessMimeType returns expected types', () => {
|
|
26
60
|
const { guessMimeType } = __test__;
|
|
27
61
|
expect(guessMimeType('file.html')).toContain('text/html');
|
|
@@ -0,0 +1,31 @@
|
|
|
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('records the Node runtime required by the installed Playwright version', () => {
|
|
11
|
+
const playwright = lock.packages['node_modules/playwright'];
|
|
12
|
+
expect(playwright.engines.node).toBe('>=20');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('keeps the manifest, lockfile root, and installed Playwright versions aligned', () => {
|
|
16
|
+
expect(pkg.dependencies.playwright).toBe('^1.63.0');
|
|
17
|
+
expect(lock.packages[''].dependencies.playwright).toBe(pkg.dependencies.playwright);
|
|
18
|
+
expect(lock.packages['node_modules/playwright'].version).toBe('1.63.0');
|
|
19
|
+
expect(lock.packages['node_modules/playwright-core'].version).toBe('1.63.0');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('declares the minimum Node runtime required by Inquirer 14', () => {
|
|
23
|
+
const inquirer = lock.packages['node_modules/inquirer'];
|
|
24
|
+
expect(pkg.engines.node).toBe('>=20.17.0');
|
|
25
|
+
expect(lock.packages[''].engines.node).toBe(pkg.engines.node);
|
|
26
|
+
expect(pkg.dependencies.inquirer).toBe('^14.2.2');
|
|
27
|
+
expect(lock.packages[''].dependencies.inquirer).toBe(pkg.dependencies.inquirer);
|
|
28
|
+
expect(inquirer.version).toBe('14.2.2');
|
|
29
|
+
expect(inquirer.engines.node).toContain('^20.17.0');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -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
|
+
});
|
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
|
}
|