@beryl-so/cli 0.21.4 → 0.22.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.
@@ -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: "https://app.example.com/pricing" },
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: "https://app.example.com/signup" },
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
- **Create them one at a time.** Locally there is no run to share a session across, so
201
- \`tests create\` renders each session-mode test with the account's sign-in in front of it —
202
- every create performs a real sign-in. Two at once would both trigger a code and race for
203
- the newest mail in the same mailbox. In the cloud that cost disappears: the whole run
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 — locally the server renders the
224
- account's sign-in steps in front of the test, so it proves the same thing on your machine.
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\` points the run at a local dev server or a preview deploy.
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.
@@ -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 run here too: " +
112
- "in the cloud their account signs in once per run and every test rides that session, " +
113
- "and locally the server hands you the same test with that account's sign-in steps in " +
114
- "front, so it proves the same thing on your machine. Only a test that depends on a " +
115
- "session Beryl captured server-side is skipped, with a note. Point " +
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 = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests/${id}/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 Beryl holds server-side gets no session here, so
218
- // skip it rather than run a spec doomed at the login wall. Session-mode tests are
219
- // NOT in that set: the server renders them with their account's own sign-in steps
220
- // in front, so they sign themselves in here and run like any other. A test whose
221
- // account is missing its password would type a literal placeholder into the page:
222
- // same treatment, with the server's fix-it message.
223
- const skipped = specs.filter((s) => s.requiresAuth || s.loginConfigError);
224
- const runnable = specs.filter((s) => !s.requiresAuth && !s.loginConfigError);
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.requiresAuth
227
- ? `! ${s.title}: needs a session only Beryl's cloud holds — skipped ` +
228
- `(use \`beryl runs trigger\`).`
229
- : `! ${s.title}: skipped — ${s.loginConfigError}`));
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, spec.usesLoginPassword ? loginPassword : undefined);
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")
@@ -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,
@@ -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 the secret into the
13
- // page, so error text and the DOM snapshot can echo it back — scrub before the
14
- // bytes leave this machine. Frames and screenshots are pixels; nothing to scrub.
15
- const scrub = (text) => (redact ? text.split(redact).join("***") : text);
16
- const scrubBytes = (bytes) => redact && bytes.includes(redact)
17
- ? Buffer.from(bytes.toString("utf8").split(redact).join("***"), "utf8")
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: spec.usesLoginPassword ? opts.loginPassword : undefined,
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").split(redact).join("***"));
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, opts.spec);
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.21.4",
4
- "description": "Beryl on the command line projects, runs, the exploring agent, and an MCP server over the same commands.",
3
+ "version": "0.22.0",
4
+ "description": "Beryl on the command line \u2014 projects, runs, the exploring agent, and an MCP server over the same commands.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "homepage": "https://beryl.so/docs/cli",