@beryl-so/cli 0.21.4 → 0.24.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/README.md +7 -5
- package/dist/beryl-test-skill.js +17 -10
- package/dist/commands/auth.js +80 -24
- package/dist/commands/init.js +25 -8
- package/dist/commands/runs.js +111 -39
- package/dist/commands/tests.js +23 -3
- package/dist/device-login.js +132 -0
- package/dist/local-exec.js +44 -7
- package/dist/local-run.js +21 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,10 +12,10 @@ that exposes every command to coding agents.
|
|
|
12
12
|
npx @beryl-so/cli@latest init
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
Signs you in
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
Safe to re-run.
|
|
15
|
+
Signs you in (browser approval by default, emailed one-time code as fallback — new
|
|
16
|
+
emails are signed up on the spot), wires the beryl + playwright MCP servers into Claude
|
|
17
|
+
Code or Cursor, installs the beryl-test authoring skill, and installs Playwright for
|
|
18
|
+
local runs. Safe to re-run.
|
|
19
19
|
|
|
20
20
|
## Install
|
|
21
21
|
|
|
@@ -33,7 +33,8 @@ latest npm release — a stale MCP server silently exposes fewer tools. Set
|
|
|
33
33
|
## Authenticate
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
-
beryl login #
|
|
36
|
+
beryl login # opens the browser to approve — press Enter for an emailed code instead
|
|
37
|
+
beryl login --otp # skip the browser; emailed one-time code (signs up new emails too)
|
|
37
38
|
export BERYL_API_KEY=beryl_pat_… # CI: use a token from Account → API tokens
|
|
38
39
|
```
|
|
39
40
|
|
|
@@ -120,6 +121,7 @@ Manage the personal access tokens that authenticate the CLI and CI.
|
|
|
120
121
|
| `beryl tokens list` | List your personal access tokens | `tokens_list` |
|
|
121
122
|
| `beryl tokens create <name>` | Mint a new personal access token (shown once) | `tokens_create` |
|
|
122
123
|
| `beryl tokens revoke <token-id>` | Revoke a personal access token | `tokens_revoke` |
|
|
124
|
+
| `beryl tokens dismiss <token-id>` | Remove an already-revoked token from your list | `tokens_dismiss` |
|
|
123
125
|
|
|
124
126
|
### workspaces
|
|
125
127
|
|
package/dist/beryl-test-skill.js
CHANGED
|
@@ -11,7 +11,7 @@ export const BERYL_TEST_SKILL_DIR = "beryl-test";
|
|
|
11
11
|
// lintPlan — the skill's documented shape must pass `beryl tests lint` on the first try.
|
|
12
12
|
export const BERYL_TEST_SKILL_EXAMPLE_PLAN = {
|
|
13
13
|
steps: [
|
|
14
|
-
{ action: "goto", url: "
|
|
14
|
+
{ action: "goto", url: "/pricing" },
|
|
15
15
|
{
|
|
16
16
|
action: "expect",
|
|
17
17
|
expect_kind: "have_text",
|
|
@@ -26,7 +26,7 @@ export const BERYL_TEST_SKILL_EXAMPLE_PLAN = {
|
|
|
26
26
|
// pass `beryl tests lint` on the first try.
|
|
27
27
|
export const BERYL_TEST_SKILL_OTP_EXAMPLE_PLAN = {
|
|
28
28
|
steps: [
|
|
29
|
-
{ action: "goto", url: "
|
|
29
|
+
{ action: "goto", url: "/signup" },
|
|
30
30
|
{ action: "fill", selector: "input[name=email]", value: "{{inbox_address}}" },
|
|
31
31
|
{ action: "click", selector: "button[type=submit]" },
|
|
32
32
|
{
|
|
@@ -70,6 +70,8 @@ Before the first plan:
|
|
|
70
70
|
|
|
71
71
|
\`\`\`
|
|
72
72
|
npm i -D @playwright/test && npx playwright install chromium # once, per project
|
|
73
|
+
beryl envs list # the root URL gotos resolve against (§2)
|
|
74
|
+
beryl envs update <id> --url https://app.example.com # set it if root_url is empty — required
|
|
73
75
|
beryl accounts list # who do authenticated tests sign in as? (§1)
|
|
74
76
|
beryl accounts set-login <id> --file … --probe … # store its sign-in — required (§1)
|
|
75
77
|
beryl accounts check <id> # does that stored sign-in still work? (§1)
|
|
@@ -197,11 +199,10 @@ beryl accounts check <id> # signs in NOW and proves it — do not skip
|
|
|
197
199
|
Then write the tests with \`requires_auth: true\` and \`auth_mode: "session"\`, and NO
|
|
198
200
|
sign-in steps.
|
|
199
201
|
|
|
200
|
-
**
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
shares one sign-in.
|
|
202
|
+
**Creates share the sign-in.** \`tests create\` replays a session-mode plan against the
|
|
203
|
+
account's established session — reused from the last proven sign-in when it is still
|
|
204
|
+
live — so a batch of creates costs at most one sign-in, same as a run. Still create one
|
|
205
|
+
at a time: two racing creates would race the same mailbox for mail.
|
|
205
206
|
|
|
206
207
|
Reading before writing matters here: the login plan self-heals, so
|
|
207
208
|
\`beryl accounts get-login <id>\` first and pass its \`login_plan_hash\` back as
|
|
@@ -220,8 +221,8 @@ your bug, but it IS your move: the account is marked unsupported, and its sessio
|
|
|
220
221
|
tests fail at setup with \`SESSION_UNSUPPORTED\` on every run until you re-author them
|
|
221
222
|
with \`auth_mode: "inline"\` and their own sign-in steps. Inline tests are unaffected.
|
|
222
223
|
|
|
223
|
-
\`beryl runs local\` works on session-mode tests too —
|
|
224
|
-
|
|
224
|
+
\`beryl runs local\` works on session-mode tests too — same shape as the cloud: one
|
|
225
|
+
sign-in per invocation, shared by every session-mode test.
|
|
225
226
|
|
|
226
227
|
### Two identities in one test
|
|
227
228
|
|
|
@@ -242,6 +243,11 @@ SSO-only sites (no email+password form at all) remain webapp territory.
|
|
|
242
243
|
\`{action, selector, url, value, ...}\`. Two structural rules the plan must satisfy:
|
|
243
244
|
- the **first executed step is a \`goto\`** (the flow has to start by navigating somewhere), and
|
|
244
245
|
- **at least one step is an \`expect\`** (a test that asserts nothing is not a test).
|
|
246
|
+
**A \`goto\` at your own app is a PATH, never a full URL** — \`/pricing\`, not
|
|
247
|
+
\`https://app.example.com/pricing\`. The origin comes from the environment's root URL, so
|
|
248
|
+
one plan runs against prod, staging and a preview. Bake the origin in and \`--env\` is
|
|
249
|
+
silently ignored: the test keeps hitting whatever host you typed. Absolute URLs stay
|
|
250
|
+
legal for OTHER origins (an OAuth handoff, a magic link on another domain).
|
|
245
251
|
Optional \`before\` / \`after\` arrays hold setup and teardown; \`after\` runs even when a
|
|
246
252
|
main step fails, so a create/update/delete flow can clean up the record it made.
|
|
247
253
|
|
|
@@ -379,7 +385,8 @@ beryl runs local # the whole suite, results recorded in Beryl
|
|
|
379
385
|
- **Results sync to Beryl by default** — the finished run is imported as a first-class
|
|
380
386
|
run (history, replay, report; trigger source \`local\`). While ITERATING on a draft,
|
|
381
387
|
pass \`--no-sync\` so every fix-loop attempt doesn't land in the project's run history.
|
|
382
|
-
- \`--url-override\`
|
|
388
|
+
- \`--url-override\` swaps the root URL for THIS run — for a throwaway host (a dev server, a
|
|
389
|
+
per-PR preview). A standing environment is \`--env <id>\` instead, not an override.
|
|
383
390
|
- \`--dir\` keeps the **spec, artifacts, and a JSON \`report.json\`** on disk so you (or your
|
|
384
391
|
coding agent) can read exactly what happened and iterate: read the report, see which step
|
|
385
392
|
or assertion failed and why, fix the plan, \`beryl tests set-plan\`, run again.
|
package/dist/commands/auth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import { saveGlobalConfig } from "../config.js";
|
|
4
|
+
import { deviceLogin, SWITCH_TO_OTP } from "../device-login.js";
|
|
4
5
|
import { CliError, UsageError } from "../errors.js";
|
|
5
6
|
import { ApiClient } from "../http.js";
|
|
6
7
|
import { dim, green } from "../output.js";
|
|
@@ -48,18 +49,67 @@ export async function promptForOtpCode(ctx) {
|
|
|
48
49
|
throw new UsageError("No valid 6-digit code entered");
|
|
49
50
|
return code;
|
|
50
51
|
}
|
|
52
|
+
// The emailed-code flow, returning a raw PAT. With a --code the email already has a
|
|
53
|
+
// code, so nothing is sent (and check-email is skipped entirely).
|
|
54
|
+
async function otpLogin(ctx, input, codeFlag) {
|
|
55
|
+
const apiUrl = ctx.client.baseUrl;
|
|
56
|
+
const email = flagStr(input, "email") ?? (await promptForEmail(ctx));
|
|
57
|
+
if (!email.includes("@"))
|
|
58
|
+
throw new UsageError(`"${email}" is not an email address`);
|
|
59
|
+
const anon = new ApiClient(apiUrl);
|
|
60
|
+
let code = codeFlag;
|
|
61
|
+
if (!code) {
|
|
62
|
+
const check = (await anon.post("/auth/check-email", { email }));
|
|
63
|
+
if (!check.exists) {
|
|
64
|
+
await anon.post("/auth/signup", { email });
|
|
65
|
+
ctx.err(dim(`No Beryl account for ${email} yet — creating one. Sent a 6-digit code to ${email}`));
|
|
66
|
+
}
|
|
67
|
+
else if ((check.auth_methods ?? []).includes("otp")) {
|
|
68
|
+
await anon.post("/auth/request-login-otp", { email });
|
|
69
|
+
ctx.err(dim(`Sent a 6-digit code to ${email}`));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
const why = `${email} signs in with Google, so there is no emailed code`;
|
|
73
|
+
if (!ctx.interactive) {
|
|
74
|
+
throw new UsageError(`${why} — run \`beryl login\` in a terminal to sign in via the browser, ` +
|
|
75
|
+
"or pass --token with a token from beryl.so → Account → API tokens");
|
|
76
|
+
}
|
|
77
|
+
ctx.err(`${why} — confirm the sign-in in your browser instead.`);
|
|
78
|
+
const fromBrowser = await deviceLogin(ctx, process.stdin, flagStr(input, "token-name"));
|
|
79
|
+
if (fromBrowser === SWITCH_TO_OTP)
|
|
80
|
+
throw new UsageError(why);
|
|
81
|
+
return fromBrowser;
|
|
82
|
+
}
|
|
83
|
+
code = await promptForOtpCode(ctx);
|
|
84
|
+
}
|
|
85
|
+
const login = (await anon.post("/auth/verify-otp", { email, code }));
|
|
86
|
+
if (!login.access_token)
|
|
87
|
+
throw new CliError("Login did not return an access token");
|
|
88
|
+
const session = new ApiClient(apiUrl, login.access_token);
|
|
89
|
+
const minted = (await session.post("/account/tokens", {
|
|
90
|
+
name: flagStr(input, "token-name") ?? `CLI on ${os.hostname()}`,
|
|
91
|
+
}));
|
|
92
|
+
return minted.token;
|
|
93
|
+
}
|
|
51
94
|
export const authCommands = [
|
|
52
95
|
{
|
|
53
96
|
name: "login",
|
|
54
97
|
summary: "Authenticate the CLI with your Beryl account",
|
|
55
|
-
description: "Signs in
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
98
|
+
description: "Signs in via your browser (a code you confirm at beryl.so) and stores a personal " +
|
|
99
|
+
"access token in the CLI config. Pass --otp (or --email) to skip the browser and " +
|
|
100
|
+
"sign in with an emailed one-time code instead — a new email gets an account " +
|
|
101
|
+
"created automatically. Pass --token to use an existing token from Account → API " +
|
|
102
|
+
"tokens. Pass --email plus --code (the 6 digits from the email, e.g. read from a " +
|
|
103
|
+
"`beryl inbox`) to complete the OTP flow without a prompt. In CI, prefer the " +
|
|
104
|
+
"BERYL_API_KEY environment variable.",
|
|
60
105
|
interactive: true,
|
|
61
106
|
flags: [
|
|
62
107
|
{ name: "token", type: "string", description: "Use an existing personal access token" },
|
|
108
|
+
{
|
|
109
|
+
name: "otp",
|
|
110
|
+
type: "boolean",
|
|
111
|
+
description: "Skip the browser — sign in with an emailed code",
|
|
112
|
+
},
|
|
63
113
|
{
|
|
64
114
|
name: "email",
|
|
65
115
|
type: "string",
|
|
@@ -81,6 +131,7 @@ export const authCommands = [
|
|
|
81
131
|
],
|
|
82
132
|
examples: [
|
|
83
133
|
"beryl login",
|
|
134
|
+
"beryl login --otp",
|
|
84
135
|
"beryl login --token beryl_pat_…",
|
|
85
136
|
"beryl login --email you@example.com",
|
|
86
137
|
"beryl login --email agent@example.com --code 123456 --json",
|
|
@@ -96,26 +147,22 @@ export const authCommands = [
|
|
|
96
147
|
if (!flagStr(input, "email"))
|
|
97
148
|
throw new UsageError("--code requires --email");
|
|
98
149
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
150
|
+
// Guard before any network call — the OTP flow's check-email/signup would create
|
|
151
|
+
// a real account and send an email before dying on the interactive code prompt.
|
|
152
|
+
if (!ctx.interactive && codeFlag === undefined) {
|
|
153
|
+
throw new UsageError("Cannot prompt for a code without a terminal — pass --email with --code, " +
|
|
154
|
+
"--token, or set BERYL_API_KEY");
|
|
155
|
+
}
|
|
156
|
+
const wantsOtp = flagBool(input, "otp") ||
|
|
157
|
+
flagStr(input, "email") !== undefined ||
|
|
158
|
+
codeFlag !== undefined;
|
|
159
|
+
if (!wantsOtp && ctx.interactive) {
|
|
160
|
+
const fromBrowser = await deviceLogin(ctx, process.stdin, flagStr(input, "token-name"));
|
|
161
|
+
token = fromBrowser === SWITCH_TO_OTP ? await otpLogin(ctx, input) : fromBrowser;
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
token = await otpLogin(ctx, input, codeFlag);
|
|
110
165
|
}
|
|
111
|
-
const login = (await anon.post("/auth/verify-otp", { email, code }));
|
|
112
|
-
if (!login.access_token)
|
|
113
|
-
throw new CliError("Login did not return an access token");
|
|
114
|
-
const session = new ApiClient(apiUrl, login.access_token);
|
|
115
|
-
const minted = (await session.post("/account/tokens", {
|
|
116
|
-
name: flagStr(input, "token-name") ?? `CLI on ${os.hostname()}`,
|
|
117
|
-
}));
|
|
118
|
-
token = minted.token;
|
|
119
166
|
}
|
|
120
167
|
const authed = new ApiClient(apiUrl, token);
|
|
121
168
|
const me = (await authed.get("/account/"));
|
|
@@ -240,4 +287,13 @@ export const authCommands = [
|
|
|
240
287
|
return { human: "Revoked." };
|
|
241
288
|
},
|
|
242
289
|
},
|
|
290
|
+
{
|
|
291
|
+
name: "tokens dismiss",
|
|
292
|
+
summary: "Remove an already-revoked token from your list",
|
|
293
|
+
args: [{ name: "token-id", description: "Token id from `beryl tokens list`", required: true }],
|
|
294
|
+
async run(ctx, input) {
|
|
295
|
+
await ctx.client.post(`/account/tokens/${arg(input, "token-id")}/dismiss`, {});
|
|
296
|
+
return { human: "Dismissed." };
|
|
297
|
+
},
|
|
298
|
+
},
|
|
243
299
|
];
|
package/dist/commands/init.js
CHANGED
|
@@ -4,7 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { BERYL_TEST_SKILL, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME, } from "../beryl-test-skill.js";
|
|
6
6
|
import { loadConfig } from "../config.js";
|
|
7
|
-
import { CliError } from "../errors.js";
|
|
7
|
+
import { AuthError, CliError } from "../errors.js";
|
|
8
8
|
import { ApiClient } from "../http.js";
|
|
9
9
|
import { bold, cyan, dim, green, red, yellow } from "../output.js";
|
|
10
10
|
import { anyGap, describeGaps, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
|
|
@@ -148,7 +148,8 @@ export const initCommands = [
|
|
|
148
148
|
{
|
|
149
149
|
name: "init",
|
|
150
150
|
summary: "Set up Beryl in this repo — sign in and wire up your coding agent",
|
|
151
|
-
description: "One-command onboarding: signs you in (emailed one-time code)
|
|
151
|
+
description: "One-command onboarding: signs you in (browser confirm, or an emailed one-time code) " +
|
|
152
|
+
"and wires up your coding " +
|
|
152
153
|
"agent. Nothing is detected and nothing is conditional — every run wires the beryl AND " +
|
|
153
154
|
"playwright MCP servers, writes the authoring skill (user scope: your home " +
|
|
154
155
|
".agents/skills/ + .claude/skills/, so it follows you into every session; --scope " +
|
|
@@ -186,17 +187,33 @@ export const initCommands = [
|
|
|
186
187
|
async run(ctx, input) {
|
|
187
188
|
const cwd = process.cwd();
|
|
188
189
|
let { client, config } = ctx;
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
190
|
+
const signIn = async () => {
|
|
191
|
+
// Non-interactive skips the picker and lets `login` fail with its own
|
|
192
|
+
// "interactive input required" message, exactly as before.
|
|
193
|
+
const answer = ctx.interactive
|
|
194
|
+
? await ctx.prompt("Sign in: [1] Browser (opens beryl.so) [2] Email me a code — [1]: ")
|
|
195
|
+
: "";
|
|
194
196
|
const login = authCommands.find((c) => c.name === "login");
|
|
195
|
-
await login.run(ctx, { args: {}, flags: {} });
|
|
197
|
+
await login.run(ctx, { args: {}, flags: answer.trim() === "2" ? { otp: true } : {} });
|
|
196
198
|
config = loadConfig();
|
|
197
199
|
if (!config.token)
|
|
198
200
|
throw new CliError("Login did not persist a token");
|
|
199
201
|
client = new ApiClient(config.apiUrl, config.token);
|
|
202
|
+
};
|
|
203
|
+
if (config.token) {
|
|
204
|
+
try {
|
|
205
|
+
const me = (await client.get("/account/"));
|
|
206
|
+
ctx.err(`${green("✓")} Signed in as ${me.email}`);
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
if (!(err instanceof AuthError))
|
|
210
|
+
throw err;
|
|
211
|
+
ctx.err(yellow("Your session token is invalid or expired."));
|
|
212
|
+
await signIn();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
await signIn();
|
|
200
217
|
}
|
|
201
218
|
const scope = (flagStr(input, "scope") ?? "user");
|
|
202
219
|
const report = (label, fresh, where) => ctx.err(`${green("✓")} ${label} ${fresh ? "configured" : "already configured"} ${dim(where)}`);
|
package/dist/commands/runs.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { downloadRunArtifacts, failureImages, isFailing, resultsOf, } from "../artifacts.js";
|
|
4
4
|
import { CliError, UsageError } from "../errors.js";
|
|
5
|
-
import { buildImportForm, executeLocalSpec, toRunEntry, } from "../local-exec.js";
|
|
5
|
+
import { buildImportForm, establishAccountSession, executeLocalSpec, IMPORT_MAX_ERROR_LEN, toRunEntry, } from "../local-exec.js";
|
|
6
6
|
import { countPlannedFrames, countWrittenFrames, PlaywrightMissingError, } from "../local-run.js";
|
|
7
7
|
import { dim, green, red, yellow } from "../output.js";
|
|
8
8
|
import { anyGap, confirmInstall, describeGaps, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
|
|
@@ -108,11 +108,11 @@ export const runCommands = [
|
|
|
108
108
|
"does, and scrubbed from any error text or DOM snapshot before results upload. When the " +
|
|
109
109
|
"run finishes, the results and replay artifacts are imported into Beryl as a normal run " +
|
|
110
110
|
"(trigger source `local`) — history, replay, and reports all work; pass --no-sync to " +
|
|
111
|
-
"keep a run entirely off the record while iterating. Session-mode tests
|
|
112
|
-
"in the cloud their account signs in once per
|
|
113
|
-
"
|
|
114
|
-
"
|
|
115
|
-
"
|
|
111
|
+
"keep a run entirely off the record while iterating. Session-mode tests behave as " +
|
|
112
|
+
"in the cloud: their account signs in once per invocation and every session-mode " +
|
|
113
|
+
"test rides that session; a failed sign-in fails those tests with the same " +
|
|
114
|
+
"SESSION_* reason a cloud run reports. Only a test that depends on a session Beryl " +
|
|
115
|
+
"captured server-side is skipped, with a note. Point " +
|
|
116
116
|
"--url-override at a local dev server or preview, and --dir to keep specs, artifacts, " +
|
|
117
117
|
"and reports on disk. Exits 0 only if every executed test passed.",
|
|
118
118
|
scope: "project",
|
|
@@ -176,19 +176,33 @@ export const runCommands = [
|
|
|
176
176
|
const ids = all ? listed.filter((t) => t.is_active !== false).map((t) => t.id) : explicitIds;
|
|
177
177
|
if (ids.length === 0)
|
|
178
178
|
throw new CliError("This project has no tests to run.");
|
|
179
|
+
const fetchScript = (id) => ctx.client.get(`${projectPath(workspaceId, projectId)}/tests/${id}/script`, {
|
|
180
|
+
// frames only matter when the run will be imported: they become the replay.
|
|
181
|
+
// environment_id keeps the baked login_email on the same environment the
|
|
182
|
+
// LOGIN_PASSWORD reveal below is scoped to.
|
|
183
|
+
base_url: urlOverride,
|
|
184
|
+
frames: sync ? true : undefined,
|
|
185
|
+
environment_id: flagStr(input, "env"),
|
|
186
|
+
});
|
|
187
|
+
const toLocalSpec = (id, script) => ({
|
|
188
|
+
id,
|
|
189
|
+
title: titles.get(id) ?? id.slice(0, 8),
|
|
190
|
+
content: script.content,
|
|
191
|
+
requiresAuth: Boolean(script.requires_auth),
|
|
192
|
+
usesEmail: /__vmInbox\(|__vmAwaitEmail\(/.test(script.content),
|
|
193
|
+
usesLoginPassword: Boolean(script.uses_login_password),
|
|
194
|
+
inlinedLoginSteps: script.inlined_login_steps ?? 0,
|
|
195
|
+
...(script.session_mode && script.account_id
|
|
196
|
+
? { sessionMode: true, accountId: script.account_id }
|
|
197
|
+
: {}),
|
|
198
|
+
...(script.email ? { email: script.email } : {}),
|
|
199
|
+
...(script.login_config_error ? { loginConfigError: script.login_config_error } : {}),
|
|
200
|
+
});
|
|
179
201
|
const specs = [];
|
|
180
202
|
for (const id of ids) {
|
|
181
203
|
let script;
|
|
182
204
|
try {
|
|
183
|
-
script =
|
|
184
|
-
// frames only matter when the run will be imported: they become the replay.
|
|
185
|
-
// environment_id keeps the baked login_email on the same environment the
|
|
186
|
-
// LOGIN_PASSWORD reveal below is scoped to.
|
|
187
|
-
{
|
|
188
|
-
base_url: urlOverride,
|
|
189
|
-
frames: sync ? true : undefined,
|
|
190
|
-
environment_id: flagStr(input, "env"),
|
|
191
|
-
}));
|
|
205
|
+
script = await fetchScript(id);
|
|
192
206
|
}
|
|
193
207
|
catch (err) {
|
|
194
208
|
// With --all an unrenderable test (no plan yet) is a skip, not an abort;
|
|
@@ -200,36 +214,66 @@ export const runCommands = [
|
|
|
200
214
|
}
|
|
201
215
|
throw err;
|
|
202
216
|
}
|
|
203
|
-
specs.push(
|
|
204
|
-
id,
|
|
205
|
-
title: titles.get(id) ?? id.slice(0, 8),
|
|
206
|
-
content: script.content,
|
|
207
|
-
requiresAuth: Boolean(script.requires_auth),
|
|
208
|
-
usesEmail: /__vmInbox\(|__vmAwaitEmail\(/.test(script.content),
|
|
209
|
-
usesLoginPassword: Boolean(script.uses_login_password),
|
|
210
|
-
inlinedLoginSteps: script.inlined_login_steps ?? 0,
|
|
211
|
-
...(script.email ? { email: script.email } : {}),
|
|
212
|
-
...(script.login_config_error ? { loginConfigError: script.login_config_error } : {}),
|
|
213
|
-
});
|
|
217
|
+
specs.push(toLocalSpec(id, script));
|
|
214
218
|
}
|
|
215
219
|
if (specs.length === 0)
|
|
216
220
|
throw new CliError("No runnable tests were found.");
|
|
217
|
-
// A test that leans on a session
|
|
218
|
-
// skip it rather than run a spec doomed at the login wall.
|
|
219
|
-
// NOT in that set:
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
const skipped = specs.filter((s) => s.requiresAuth || s.loginConfigError);
|
|
224
|
-
const runnable = specs.filter((s) => !
|
|
221
|
+
// A test that leans on a session this CLI cannot establish gets no session here,
|
|
222
|
+
// so skip it rather than run a spec doomed at the login wall. A session-mode test
|
|
223
|
+
// is NOT in that set: its account's session is established below, once, exactly as
|
|
224
|
+
// a cloud run's setup phase does. A test whose account is missing its password
|
|
225
|
+
// would type a literal placeholder into the page: skip, with the server's fix-it
|
|
226
|
+
// message.
|
|
227
|
+
const skipped = specs.filter((s) => (s.requiresAuth && !(s.sessionMode && s.accountId)) || s.loginConfigError);
|
|
228
|
+
const runnable = specs.filter((s) => !skipped.includes(s));
|
|
225
229
|
for (const s of skipped) {
|
|
226
|
-
ctx.err(yellow(s.
|
|
227
|
-
? `! ${s.title}:
|
|
228
|
-
|
|
229
|
-
|
|
230
|
+
ctx.err(yellow(s.loginConfigError
|
|
231
|
+
? `! ${s.title}: skipped — ${s.loginConfigError}`
|
|
232
|
+
: `! ${s.title}: needs a session only Beryl's cloud holds — skipped ` +
|
|
233
|
+
`(use \`beryl runs trigger\`).`));
|
|
230
234
|
}
|
|
231
235
|
if (runnable.length === 0)
|
|
232
236
|
throw new CliError("None of the selected tests can run locally.");
|
|
237
|
+
// Once per account, before any test runs — the local counterpart of the cloud
|
|
238
|
+
// worker's run-setup phase, produced by the same server-side establish (stored-
|
|
239
|
+
// session cache, login replay, proof). A failure here fails only that account's
|
|
240
|
+
// tests, with the same stable-prefixed reason a cloud run reports; an account
|
|
241
|
+
// proven unable to carry a session gets its tests re-rendered to sign themselves
|
|
242
|
+
// in, the same degraded mode a cloud run applies.
|
|
243
|
+
const sessions = new Map();
|
|
244
|
+
const sessionErrors = new Map();
|
|
245
|
+
const accountIds = [
|
|
246
|
+
...new Set(runnable.filter((s) => s.sessionMode && s.accountId).map((s) => s.accountId)),
|
|
247
|
+
];
|
|
248
|
+
for (const accountId of accountIds) {
|
|
249
|
+
ctx.err(dim("Establishing the test account's session (once per run)…"));
|
|
250
|
+
const established = await establishAccountSession(ctx.client, projectPath(workspaceId, projectId), accountId);
|
|
251
|
+
if (established.session) {
|
|
252
|
+
sessions.set(accountId, established.session);
|
|
253
|
+
}
|
|
254
|
+
else if (established.unsupported) {
|
|
255
|
+
// The server just latched UNSUPPORTED, so a re-fetch renders these specs with
|
|
256
|
+
// their account's sign-in steps inlined — they run self-signing like any other.
|
|
257
|
+
// Whole-object replacement, not a merge: the inlined render carries no
|
|
258
|
+
// session_mode, and a merge would leave the stale sessionMode/accountId behind.
|
|
259
|
+
ctx.err(yellow(`! ${established.error} — signing in per test.`));
|
|
260
|
+
try {
|
|
261
|
+
for (const [i, s] of runnable.entries()) {
|
|
262
|
+
if (s.accountId !== accountId)
|
|
263
|
+
continue;
|
|
264
|
+
runnable[i] = toLocalSpec(s.id, await fetchScript(s.id));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
// A failed re-fetch fails only this account's tests, like any other
|
|
269
|
+
// establish failure — not the whole invocation.
|
|
270
|
+
sessionErrors.set(accountId, err instanceof Error ? err.message : String(err));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
sessionErrors.set(accountId, established.error ?? "SESSION_UNAVAILABLE");
|
|
275
|
+
}
|
|
276
|
+
}
|
|
233
277
|
// Revealed once for the batch over the member-gated, logged route — the same
|
|
234
278
|
// secret the cloud runner banks into run-config.json; never in the spec source.
|
|
235
279
|
let loginPassword;
|
|
@@ -264,6 +308,30 @@ export const runCommands = [
|
|
|
264
308
|
if (!bar.active)
|
|
265
309
|
ctx.err(dim(`Running ${spec.title}…`));
|
|
266
310
|
const testStarted = new Date().toISOString();
|
|
311
|
+
// No proven session for this account: running the spec would open the page
|
|
312
|
+
// logged out and pass every assertion that tolerates that — fail it in the
|
|
313
|
+
// setup phase with the establish error, exactly as a cloud run does.
|
|
314
|
+
const sessionError = spec.accountId ? sessionErrors.get(spec.accountId) : undefined;
|
|
315
|
+
if (spec.sessionMode && sessionError !== undefined) {
|
|
316
|
+
entries.push({
|
|
317
|
+
test_case_id: spec.id,
|
|
318
|
+
status: "failed",
|
|
319
|
+
phase: "setup",
|
|
320
|
+
error_message: sessionError.slice(0, IMPORT_MAX_ERROR_LEN),
|
|
321
|
+
started_at: testStarted,
|
|
322
|
+
completed_at: new Date().toISOString(),
|
|
323
|
+
frames: [],
|
|
324
|
+
frame_urls: [],
|
|
325
|
+
frame_durations_ms: [],
|
|
326
|
+
files: [],
|
|
327
|
+
});
|
|
328
|
+
done += 1;
|
|
329
|
+
failed += 1;
|
|
330
|
+
bar.clear();
|
|
331
|
+
ctx.err(`${red("✗")} ${spec.title}\n ${dim(sessionError.split("\n")[0] ?? "")}`);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const session = spec.sessionMode ? sessions.get(spec.accountId ?? "") : undefined;
|
|
267
335
|
let outcome;
|
|
268
336
|
let runError;
|
|
269
337
|
let lastStep = 0;
|
|
@@ -272,6 +340,7 @@ export const runCommands = [
|
|
|
272
340
|
dir: dir ? path.join(dir, spec.id) : undefined,
|
|
273
341
|
harvest: sync,
|
|
274
342
|
loginPassword,
|
|
343
|
+
session,
|
|
275
344
|
// Clear the rewriting bar before a mid-test log line, or the line is
|
|
276
345
|
// appended onto the live bar and fossilises it into scrollback.
|
|
277
346
|
onEvent: (line) => {
|
|
@@ -323,7 +392,10 @@ export const runCommands = [
|
|
|
323
392
|
// result, not an aborted batch: the remaining tests still deserve their run.
|
|
324
393
|
runError = err instanceof Error ? err.message : String(err);
|
|
325
394
|
}
|
|
326
|
-
const entry = toRunEntry(spec, outcome, runError, testStarted, entries.length,
|
|
395
|
+
const entry = toRunEntry(spec, outcome, runError, testStarted, entries.length, [
|
|
396
|
+
...(spec.usesLoginPassword && loginPassword ? [loginPassword] : []),
|
|
397
|
+
...(session?.secrets ?? []),
|
|
398
|
+
]);
|
|
327
399
|
entries.push(entry);
|
|
328
400
|
done += 1;
|
|
329
401
|
if (entry.status === "passed")
|
package/dist/commands/tests.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import { CliError, UsageError } from "../errors.js";
|
|
3
3
|
import { ApiError } from "../http.js";
|
|
4
4
|
import { lintPlan } from "../lint.js";
|
|
5
|
-
import { buildImportForm, executeLocalSpec, toRunEntry, } from "../local-exec.js";
|
|
5
|
+
import { buildImportForm, establishAccountSession, executeLocalSpec, toRunEntry, } from "../local-exec.js";
|
|
6
6
|
import { PlaywrightMissingError } from "../local-run.js";
|
|
7
7
|
import { dim, green, red, table, yellow } from "../output.js";
|
|
8
8
|
import { confirmInstall, installPlaywright } from "../playwright-install.js";
|
|
@@ -243,9 +243,27 @@ export const testCommands = [
|
|
|
243
243
|
frames: sync ? true : undefined,
|
|
244
244
|
});
|
|
245
245
|
let compiled = await compile();
|
|
246
|
+
// Session-mode plan: establish the account's session once (server-side, reused
|
|
247
|
+
// from the stored one when it still proves live) and replay against it — the
|
|
248
|
+
// cloud shape. An unsupported account is re-compiled to sign itself in.
|
|
249
|
+
let session;
|
|
250
|
+
if (compiled.session_mode && compiled.account_id) {
|
|
251
|
+
ctx.err(dim("Establishing the test account's session…"));
|
|
252
|
+
const established = await establishAccountSession(ctx.client, projectPath(workspaceId, projectId), compiled.account_id);
|
|
253
|
+
if (established.session) {
|
|
254
|
+
session = established.session;
|
|
255
|
+
}
|
|
256
|
+
else if (established.unsupported) {
|
|
257
|
+
ctx.err(yellow(`! ${established.error} — the replay signs in itself.`));
|
|
258
|
+
compiled = await compile();
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
throw new CliError(established.error ?? "SESSION_UNAVAILABLE");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
246
264
|
// A captured session lives encrypted in Beryl's cloud and is never handed to
|
|
247
265
|
// this machine — the cloud is the only place this plan can be proven.
|
|
248
|
-
if (compiled.requires_auth) {
|
|
266
|
+
if (compiled.requires_auth && !session) {
|
|
249
267
|
ctx.err(yellow("! This plan signs in with a captured session, so it can only be verified " +
|
|
250
268
|
"in Beryl's cloud — verifying server-side instead."));
|
|
251
269
|
try {
|
|
@@ -281,6 +299,7 @@ export const testCommands = [
|
|
|
281
299
|
requiresAuth: false,
|
|
282
300
|
usesEmail: /__vmInbox\(|__vmAwaitEmail\(/.test(content),
|
|
283
301
|
usesLoginPassword: compiled.uses_login_password,
|
|
302
|
+
inlinedLoginSteps: compiled.inlined_login_steps ?? 0,
|
|
284
303
|
email: compiled.email ?? undefined,
|
|
285
304
|
});
|
|
286
305
|
const startedAt = new Date().toISOString();
|
|
@@ -303,6 +322,7 @@ export const testCommands = [
|
|
|
303
322
|
dir: flagStr(input, "dir"),
|
|
304
323
|
harvest: true,
|
|
305
324
|
loginPassword,
|
|
325
|
+
session,
|
|
306
326
|
onEvent: (line) => ctx.err(dim(line)),
|
|
307
327
|
}));
|
|
308
328
|
}
|
|
@@ -369,7 +389,7 @@ export const testCommands = [
|
|
|
369
389
|
}
|
|
370
390
|
let runId;
|
|
371
391
|
if (sync) {
|
|
372
|
-
const entry = toRunEntry({ ...specOf(compiled.content), id: String(created.id) }, outcome, undefined, startedAt, 0, loginPassword);
|
|
392
|
+
const entry = toRunEntry({ ...specOf(compiled.content), id: String(created.id) }, outcome, undefined, startedAt, 0, [...(loginPassword ? [loginPassword] : []), ...(session?.secrets ?? [])]);
|
|
373
393
|
const imported = (await ctx.client.request("POST", `${projectPath(workspaceId, projectId)}/runs/import`, {
|
|
374
394
|
form: buildImportForm([entry], {
|
|
375
395
|
environmentId: env,
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { CliError } from "./errors.js";
|
|
4
|
+
import { ApiClient, ApiError } from "./http.js";
|
|
5
|
+
import { dim } from "./output.js";
|
|
6
|
+
/** Returned when the user presses Enter to fall back to the emailed-code flow. */
|
|
7
|
+
export const SWITCH_TO_OTP = Symbol("switch-to-otp");
|
|
8
|
+
function openBrowser(url) {
|
|
9
|
+
const [cmd, args] = process.platform === "darwin"
|
|
10
|
+
? ["open", [url]]
|
|
11
|
+
: process.platform === "win32"
|
|
12
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
13
|
+
: ["xdg-open", [url]];
|
|
14
|
+
try {
|
|
15
|
+
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
|
|
16
|
+
child.on("error", () => { });
|
|
17
|
+
child.unref();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// no browser is fine — the URL is printed for copy-paste
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Browser device-code sign-in: opens beryl.so, polls until the user confirms the code
|
|
24
|
+
* there, and resolves to the raw PAT — or to SWITCH_TO_OTP on Enter. */
|
|
25
|
+
export async function deviceLogin(ctx, stdin = process.stdin, tokenName) {
|
|
26
|
+
const anon = new ApiClient(ctx.client.baseUrl);
|
|
27
|
+
const start = (await anon.post("/auth/cli/start", {
|
|
28
|
+
token_name: tokenName ?? `CLI on ${os.hostname()}`,
|
|
29
|
+
}));
|
|
30
|
+
openBrowser(start.verification_url);
|
|
31
|
+
ctx.err(start.verification_url);
|
|
32
|
+
ctx.err(`Confirm this code in the browser: ${start.user_code}`);
|
|
33
|
+
ctx.err(dim("Press Enter to sign in with an emailed code instead"));
|
|
34
|
+
let timer;
|
|
35
|
+
let onData;
|
|
36
|
+
let done = false;
|
|
37
|
+
try {
|
|
38
|
+
return await new Promise((resolve, reject) => {
|
|
39
|
+
let interval = start.interval;
|
|
40
|
+
let failures = 0;
|
|
41
|
+
let pollInFlight = false;
|
|
42
|
+
let switchRequested = false;
|
|
43
|
+
const finish = (token) => {
|
|
44
|
+
done = true;
|
|
45
|
+
resolve(token);
|
|
46
|
+
};
|
|
47
|
+
const fail = (err) => {
|
|
48
|
+
done = true;
|
|
49
|
+
reject(err);
|
|
50
|
+
};
|
|
51
|
+
const switchToOtp = () => {
|
|
52
|
+
done = true;
|
|
53
|
+
resolve(SWITCH_TO_OTP);
|
|
54
|
+
};
|
|
55
|
+
onData = (chunk) => {
|
|
56
|
+
if (!/[\r\n]/.test(chunk.toString()))
|
|
57
|
+
return;
|
|
58
|
+
if (done || switchRequested)
|
|
59
|
+
return;
|
|
60
|
+
// An in-flight poll may already be approved server-side (the session is consumed);
|
|
61
|
+
// its result must win or the minted token is lost forever.
|
|
62
|
+
if (pollInFlight)
|
|
63
|
+
switchRequested = true;
|
|
64
|
+
else
|
|
65
|
+
switchToOtp();
|
|
66
|
+
};
|
|
67
|
+
stdin.on("data", onData);
|
|
68
|
+
stdin.resume();
|
|
69
|
+
const poll = async () => {
|
|
70
|
+
pollInFlight = true;
|
|
71
|
+
let res;
|
|
72
|
+
try {
|
|
73
|
+
res = (await anon.post("/auth/cli/poll", {
|
|
74
|
+
device_code: start.device_code,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
pollInFlight = false;
|
|
79
|
+
if (done)
|
|
80
|
+
return;
|
|
81
|
+
if (switchRequested) {
|
|
82
|
+
switchToOtp();
|
|
83
|
+
}
|
|
84
|
+
else if (++failures >= 5) {
|
|
85
|
+
fail(err);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
if (err instanceof ApiError && err.status === 429)
|
|
89
|
+
interval *= 2;
|
|
90
|
+
timer = setTimeout(() => void poll(), interval * 1000);
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
pollInFlight = false;
|
|
95
|
+
failures = 0;
|
|
96
|
+
if (done)
|
|
97
|
+
return;
|
|
98
|
+
if (res.status === "approved" && res.token) {
|
|
99
|
+
if (switchRequested)
|
|
100
|
+
ctx.err("Already approved in the browser — signed in");
|
|
101
|
+
finish(res.token);
|
|
102
|
+
}
|
|
103
|
+
else if (switchRequested) {
|
|
104
|
+
switchToOtp();
|
|
105
|
+
}
|
|
106
|
+
else if (res.status === "approved") {
|
|
107
|
+
fail(new CliError("Sign-in approved but no token was returned — try again"));
|
|
108
|
+
}
|
|
109
|
+
else if (res.status === "denied") {
|
|
110
|
+
fail(new CliError("Sign-in was denied in the browser"));
|
|
111
|
+
}
|
|
112
|
+
else if (res.status !== "pending") {
|
|
113
|
+
fail(new CliError("The sign-in request expired — run `beryl login` again"));
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
timer = setTimeout(() => void poll(), interval * 1000);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
timer = setTimeout(() => void poll(), interval * 1000);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
// Both racers are torn down whichever wins — a live timer or stdin listener would
|
|
124
|
+
// keep the process alive after login.
|
|
125
|
+
done = true;
|
|
126
|
+
if (timer)
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
if (onData)
|
|
129
|
+
stdin.off("data", onData);
|
|
130
|
+
stdin.pause();
|
|
131
|
+
}
|
|
132
|
+
}
|
package/dist/local-exec.js
CHANGED
|
@@ -9,12 +9,14 @@ export const IMPORT_MAX_FILE_BYTES = 14 * 1024 * 1024;
|
|
|
9
9
|
export const IMPORT_MAX_TOTAL_BYTES = 180 * 1024 * 1024;
|
|
10
10
|
export const IMPORT_MAX_FILES = 3600;
|
|
11
11
|
export function toRunEntry(spec, outcome, runError, startedAt, ordinal, redact) {
|
|
12
|
-
// Mirrors the cloud runner's redact_result: the spec types
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
12
|
+
// Mirrors the cloud runner's redact_result: the spec types secrets into the page
|
|
13
|
+
// and an injected session authenticates its requests, so error text and the DOM
|
|
14
|
+
// snapshot can echo them back — scrub before the bytes leave this machine. Frames
|
|
15
|
+
// and screenshots are pixels; nothing to scrub.
|
|
16
|
+
const values = (Array.isArray(redact) ? redact : [redact]).filter((v) => Boolean(v));
|
|
17
|
+
const scrub = (text) => values.reduce((out, v) => out.split(v).join("***"), text);
|
|
18
|
+
const scrubBytes = (bytes) => values.some((v) => bytes.includes(v))
|
|
19
|
+
? Buffer.from(scrub(bytes.toString("utf8")), "utf8")
|
|
18
20
|
: bytes;
|
|
19
21
|
const completedAt = new Date().toISOString();
|
|
20
22
|
const base = {
|
|
@@ -129,6 +131,30 @@ export function buildImportForm(entries, opts) {
|
|
|
129
131
|
}
|
|
130
132
|
return form;
|
|
131
133
|
}
|
|
134
|
+
/** Establish (or reuse) an account's proven session server-side — the same establish a
|
|
135
|
+
* cloud run's setup phase does, so one sign-in serves cloud and local alike. */
|
|
136
|
+
export async function establishAccountSession(client, projectBase, accountId) {
|
|
137
|
+
let res;
|
|
138
|
+
try {
|
|
139
|
+
res = (await client.request("POST", `${projectBase}/test-accounts/${accountId}/session`));
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
143
|
+
}
|
|
144
|
+
if (res.ok && res.storage_state) {
|
|
145
|
+
return {
|
|
146
|
+
session: {
|
|
147
|
+
storageState: res.storage_state,
|
|
148
|
+
initScripts: res.init_scripts ?? [],
|
|
149
|
+
secrets: res.secrets ?? [],
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
error: res.error ?? "SESSION_UNAVAILABLE",
|
|
155
|
+
unsupported: Boolean(res.unsupported),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
132
158
|
/**
|
|
133
159
|
* Run one rendered spec on this machine with its full service harness: write the
|
|
134
160
|
* inbox/login sidecars and pump `await_email` requests over the API while Playwright
|
|
@@ -158,7 +184,18 @@ export async function executeLocalSpec(deps, opts) {
|
|
|
158
184
|
testName: spec.title,
|
|
159
185
|
dir: opts.dir,
|
|
160
186
|
harvest: opts.harvest,
|
|
161
|
-
redact:
|
|
187
|
+
redact: [
|
|
188
|
+
...(spec.usesLoginPassword && opts.loginPassword ? [opts.loginPassword] : []),
|
|
189
|
+
...(opts.session?.secrets ?? []),
|
|
190
|
+
],
|
|
191
|
+
...(opts.session
|
|
192
|
+
? {
|
|
193
|
+
authSession: {
|
|
194
|
+
storageState: opts.session.storageState,
|
|
195
|
+
initScripts: opts.session.initScripts,
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
: {}),
|
|
162
199
|
setup: inbox || (spec.usesLoginPassword && opts.loginPassword !== undefined)
|
|
163
200
|
? (runDir) => {
|
|
164
201
|
if (inbox)
|
package/dist/local-run.js
CHANGED
|
@@ -21,12 +21,20 @@ const ISOLATING_CONFIG = (testDir, artifactsDir) => `import { defineConfig } fro
|
|
|
21
21
|
` outputDir: ${JSON.stringify(artifactsDir)},\n` +
|
|
22
22
|
` fullyParallel: false,\n` +
|
|
23
23
|
`});\n`;
|
|
24
|
+
export function scrubText(text, redact) {
|
|
25
|
+
let out = text;
|
|
26
|
+
for (const value of redact ?? []) {
|
|
27
|
+
if (value)
|
|
28
|
+
out = out.split(value).join("***");
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
24
32
|
export function copyTextScrubbed(src, dest, redact) {
|
|
25
|
-
if (!redact) {
|
|
33
|
+
if (!redact?.length) {
|
|
26
34
|
fs.copyFileSync(src, dest);
|
|
27
35
|
return;
|
|
28
36
|
}
|
|
29
|
-
fs.writeFileSync(dest, fs.readFileSync(src, "utf8")
|
|
37
|
+
fs.writeFileSync(dest, scrubText(fs.readFileSync(src, "utf8"), redact));
|
|
30
38
|
}
|
|
31
39
|
const PASSING = new Set(["passed", "expected"]);
|
|
32
40
|
const SKIPPED = new Set(["skipped"]);
|
|
@@ -224,8 +232,18 @@ export async function runSpecLocally(opts) {
|
|
|
224
232
|
// ephemeral run dir.
|
|
225
233
|
const outDir = opts.dir ? path.resolve(opts.dir) : runDir;
|
|
226
234
|
fs.mkdirSync(outDir, { recursive: true });
|
|
235
|
+
let specContent = opts.spec;
|
|
236
|
+
if (opts.authSession) {
|
|
237
|
+
// Mirror the cloud runner's staging: write the session files into the workdir and
|
|
238
|
+
// point the spec's storageState reference at the absolute path (auth-init.json is
|
|
239
|
+
// read relative to cwd, which is the run dir — same as the cloud workdir).
|
|
240
|
+
const storagePath = path.join(runDir, "auth-state.json");
|
|
241
|
+
fs.writeFileSync(storagePath, opts.authSession.storageState, "utf8");
|
|
242
|
+
fs.writeFileSync(path.join(runDir, "auth-init.json"), JSON.stringify(opts.authSession.initScripts), "utf8");
|
|
243
|
+
specContent = specContent.split('"auth-state.json"').join(JSON.stringify(storagePath));
|
|
244
|
+
}
|
|
227
245
|
const specPath = path.join(runDir, "beryl-local.spec.ts");
|
|
228
|
-
fs.writeFileSync(specPath,
|
|
246
|
+
fs.writeFileSync(specPath, specContent);
|
|
229
247
|
const configPath = path.join(runDir, "beryl-local.config.ts");
|
|
230
248
|
const artifactsDir = path.join(outDir, "artifacts");
|
|
231
249
|
fs.writeFileSync(configPath, ISOLATING_CONFIG(runDir, artifactsDir));
|
package/package.json
CHANGED