@beryl-so/cli 0.73.2 → 0.78.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 CHANGED
@@ -13,10 +13,9 @@ npx @beryl-so/cli@latest init
13
13
  ```
14
14
 
15
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 the
16
+ emails are signed up on the spot), wires the beryl MCP server into the
17
17
  agent you pick with --claude, --cursor, --vscode or --opencode (it asks when you pass
18
- none), installs the beryl-test authoring skill, and installs Playwright for local runs.
19
- Safe to re-run.
18
+ none), and installs the beryl-test authoring skill. Safe to re-run.
20
19
 
21
20
  ## Install
22
21
 
@@ -193,7 +192,7 @@ Manage a project's environments: the URLs and auth Beryl runs tests against.
193
192
  | `beryl envs list` | List a project's environments | `envs_list` |
194
193
  | `beryl envs get <env-id>` | Show one environment | `envs_get` |
195
194
  | `beryl envs create <name> <url>` | Add an environment (e.g. staging) to a project | `envs_create` |
196
- | `beryl envs update <env-id>` | Update an environment's name, URL, auth settings, Site Health screen sizes, schedule, CI and pages, or issue tracker settings | `envs_update` |
195
+ | `beryl envs update <env-id>` | Update an environment's name, URL, app ids, auth settings, Site Health screen sizes, schedule, CI and pages, or issue tracker settings | `envs_update` |
197
196
  | `beryl envs delete <env-id>` | Delete an environment | `envs_delete` |
198
197
 
199
198
  ### schedule
@@ -215,11 +214,9 @@ Author, inspect, version, and heal a project's tests: the checks Beryl runs on e
215
214
 
216
215
  | Command | Summary | MCP tool |
217
216
  | --- | --- | --- |
218
- | `beryl tests lint` | Validate a plan JSON file offline, before sending it to the server | `tests_lint` |
219
217
  | `beryl tests list` | List the project's tests with their latest result | `tests_list` |
220
218
  | `beryl tests get <test-id>` | Show one test | `tests_get` |
221
219
  | `beryl tests plan <test-id>` | Print a test's current step plan (JSON) | `tests_plan` |
222
- | `beryl tests create` | Retired: tests are now created by the cloud test-creation job (`jobs create`) | `tests_create` |
223
220
  | `beryl tests set-plan <test-id>` | Replace a test's step plan from a JSON file (creates a new version) | `tests_set_plan` |
224
221
  | `beryl tests rename <test-id> <title>` | Rename a test | `tests_rename` |
225
222
  | `beryl tests set-intent <test-id> <intent>` | Edit what a test proves, the target heal judges against | `tests_set_intent` |
@@ -237,7 +234,7 @@ Author, inspect, version, and heal a project's tests: the checks Beryl runs on e
237
234
  | `beryl tests reset <test-id>` | Discard user edits and return the test to its latest system-authored version | `tests_reset` |
238
235
  | `beryl tests heal <test-id> <state>` | Turn self-healing on or off for a test | `tests_heal` |
239
236
  | `beryl tests history <test-id>` | Pass/fail history, streak, and stability for a test | `tests_history` |
240
- | `beryl tests script [test-id]` | Print the rendered Playwright spec for a test (or an unbanked plan file) | `tests_script` |
237
+ | `beryl tests script <test-id>` | Print the rendered Playwright spec for a test | `tests_script` |
241
238
  | `beryl tests export <test-ids...>` | Export tests as Playwright .spec.ts files in a ZIP | `tests_export` |
242
239
 
243
240
  ### groups
@@ -262,7 +259,6 @@ Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and downlo
262
259
  | Command | Summary | MCP tool |
263
260
  | --- | --- | --- |
264
261
  | `beryl runs trigger` | Trigger a test run (whole suite, a subset, or one environment) | `runs_trigger` |
265
- | `beryl runs local [test-ids...]` | Run tests on your machine with Playwright; results sync to Beryl | `runs_local` |
266
262
  | `beryl runs list` | List recent runs | `runs_list` |
267
263
  | `beryl runs get <run-id>` | Show one run with its per-test results | `runs_get` |
268
264
  | `beryl runs watch <run-id>` | Follow a run until it finishes | `runs_watch` |
@@ -327,9 +323,9 @@ Manage the variables, secrets, and files the agent can use while exploring and r
327
323
  | `beryl config secrets set <key> <value>` | Create a secret (write-only; re-setting a key replaces it) | `config_secrets_set` |
328
324
  | `beryl config secrets get <key>` | Show one secret's metadata, or reveal its value with --reveal | `config_secrets_get` |
329
325
  | `beryl config secrets delete <key>` | Delete a secret | `config_secrets_delete` |
330
- | `beryl config files list` | List files uploaded for the agent to use (e.g. CSVs, upload fixtures) | `config_files_list` |
326
+ | `beryl config files list` | List uploaded files: fixtures tests attach and Android and iOS app builds | `config_files_list` |
331
327
  | `beryl config files get <file>` | Show one uploaded file's metadata | `config_files_get` |
332
- | `beryl config files upload <file>` | Upload a file | `config_files_upload` |
328
+ | `beryl config files upload <file>` | Upload a file: a fixture an upload step attaches, or an Android or iOS app build | `config_files_upload` |
333
329
  | `beryl config files download <file-id>` | Get a short-lived download URL for a file | `config_files_download` |
334
330
  | `beryl config files delete <file-id>` | Delete an uploaded file | `config_files_delete` |
335
331
 
@@ -282,7 +282,7 @@ export function mcpInstructions() {
282
282
  "magic-link flows are self-contained, with no human login needed. " +
283
283
  "New tests are authored only by Beryl's cloud agent: give it a document describing " +
284
284
  "the flows with `jobs_create`; there is no local authoring. " +
285
- "The guide (plan shape, outcome assertions, accounts, email/OTP wiring, the local " +
285
+ "The guide (plan shape, outcome assertions, accounts, email/OTP wiring, the " +
286
286
  "run-fix loop) ships as both the beryl-test skill and the `guide` tool, same " +
287
287
  `content. If a beryl-test skill stating v${version} is already loaded, do not ` +
288
288
  "call `guide`; if no beryl-test skill is available or it states another version, " +
@@ -23,7 +23,7 @@ ActionPlan shape, outcome assertions, test accounts and the project mailbox, and
23
23
  local run-fix loop.
24
24
 
25
25
  Then use the Beryl MCP tools: \`projects_list\` to find the project, \`jobs_create\` to
26
- have Beryl's cloud agent author tests from a document, and \`runs_local\` to run them here.
26
+ have Beryl's cloud agent author tests from a document, and \`runs_trigger\` to run them.
27
27
 
28
28
  The \`guide\` tool ships with the CLI and always matches the installed version, so treat
29
29
  it as the source of truth over anything cached here.`;
@@ -90,7 +90,7 @@ export const BERYL_TEST_SKILL_OTP_EXAMPLE_PLAN = {
90
90
  };
91
91
  export const BERYL_TEST_SKILL = `---
92
92
  name: beryl-test
93
- description: Create, run, and fix durable, healable end-to-end tests for a web app with Beryl. Use when asking Beryl's cloud agent to create tests from a document (\`jobs_create\`), running tests locally with \`runs_local\`, setting up test accounts, or fixing a banked test's plan with your own coding agent.
93
+ description: Create, run, and fix durable, healable end-to-end tests for a web app with Beryl. Use when asking Beryl's cloud agent to create tests from a document (\`jobs_create\`), running them in Beryl's cloud with \`runs_trigger\`, setting up test accounts, or fixing a banked test's plan with your own coding agent.
94
94
  ---
95
95
 
96
96
  # Beryl tests
@@ -98,12 +98,12 @@ description: Create, run, and fix durable, healable end-to-end tests for a web a
98
98
  beryl-test v${cliVersion()}, installed by \`beryl init\`; the \`guide\` tool always returns the current version of this guide.
99
99
 
100
100
  A Beryl test drives a real browser through a flow and asserts the flow reached its
101
- meaningful outcome. \`beryl init\` wired two MCP servers: **beryl** (\`jobs_create\`,
102
- \`accounts_list\`, \`runs_local\`, and the rest) and **playwright** (drive a real
103
- browser). You do everything below by calling those tools. Beryl **heals** tests as the
104
- app's markup drifts, but only when a test has the three things that make it durable: a
105
- real **outcome assertion**, a strong **natural-language intent**, and the **local
106
- run-fix loop**.
101
+ meaningful outcome. \`beryl init\` wired the **beryl** MCP server (\`jobs_create\`,
102
+ \`accounts_list\`, \`runs_trigger\`, and the rest). You do everything below by
103
+ calling those tools; tests are authored and run in Beryl's cloud. Beryl **heals**
104
+ tests as the app's markup drifts, but only when a test has the three things that make
105
+ it durable: a real **outcome assertion**, a strong **natural-language intent**, and a
106
+ **verified plan** (every save is proven by a replay).
107
107
 
108
108
  ## 0. Start here
109
109
 
@@ -114,16 +114,16 @@ authors one test per scenario and surface. \`jobs_get\` shows the job's status,
114
114
  it produced and any scenario it skipped. The document is sent to the LLM that drives the
115
115
  agent, so it must not hold anything the user would not share with it.
116
116
 
117
- What you do locally: set up the accounts tests sign in as (§1), run tests on this machine
117
+ What you do here: set up the accounts tests sign in as (§1), trigger and read runs
118
118
  (§4), and fix a banked test's plan when it needs it (§2).
119
119
 
120
- Set up (once): if you cannot call the Beryl tools yet, run \`beryl init\` in a terminal to
121
- install the browser and wire the tools. Then, before the first job:
120
+ Set up (once): if you cannot call the Beryl tools yet, run \`beryl init\` in a terminal
121
+ to wire them. Then, before the first job:
122
122
 
123
123
  - \`envs_list\`: the root URL gotos resolve against (§2). If it is empty, set it with
124
124
  \`envs_update\` to a URL Beryl's cloud can reach, never \`localhost\`, \`127.0.0.1\`
125
- or a private IP (refused). For a local dev server, pass \`url-override\` to
126
- \`runs_local\` instead.
125
+ or a private IP (refused). A local dev server is not testable until it is reachable
126
+ from the cloud (deploy a preview, or tunnel it).
127
127
  - \`accounts_list\`: who do authenticated tests sign in as (§1)? Store its sign-in with
128
128
  \`accounts_set_login\`, prove it with \`accounts_check\`.
129
129
  - \`mailbox_get\`: the address the account receives mail at (§5).
@@ -133,10 +133,16 @@ login plan is stored.
133
133
 
134
134
  Then:
135
135
 
136
- 1. **Create tests** with \`jobs_create\` (the document, and the environment to explore).
137
- 2. When the job is done, run the new ids together ONCE with \`runs_local\` (or
138
- \`runs_trigger\` for the cloud) so they land as one run with real first results.
139
- 3. **A test is red or wrong?** Fix its plan with the local run-fix loop (§4).
136
+ 1. **Create tests** with \`jobs_create\` (the document, and the environment to
137
+ explore). \`jobs_get\` shows progress, the tests it banked (each one already
138
+ proven by a clean replay), and every scenario it skipped, with why.
139
+ 2. A job can pause as \`awaiting_input\` when the app's login wall blocks it (no
140
+ self-serve signup). Its \`needs\` field says what to do: capture a signed-in
141
+ session with the Beryl browser extension from the webapp. The job resumes by
142
+ itself and keeps everything it already banked.
143
+ 3. When the job is done, run the new ids together ONCE with \`runs_trigger\` so they
144
+ land as one run with real first results.
145
+ 4. **A test is red or wrong?** Fix its plan with the run-fix loop (§4).
140
146
 
141
147
  ## 1. First: who do your tests sign in as?
142
148
 
@@ -285,18 +291,18 @@ SSO-only sites are not a dead end: with a dedicated Google test account they fol
285
291
  item 2 like any password account. The captured session is the FALLBACK — no test account
286
292
  can be provided, or Google challenges the scripted sign-in: a human signs in once
287
293
  through a live browser view in the Beryl webapp, Beryl stores the session encrypted
288
- server-side and injects it into cloud runs: \`runs_trigger\` runs such tests,
289
- \`runs_local\` skips them (the session never leaves Beryl's cloud).
294
+ server-side and injects it into cloud runs; the session never leaves Beryl's cloud.
290
295
 
291
- ## 2. Fixing a test's plan over the Playwright MCP
296
+ ## 2. Fixing a test's plan
292
297
 
293
298
  New tests come from \`jobs_create\` (§0). You write plan JSON only to fix a banked test
294
299
  (\`tests_plan\` returns its current plan) or to store an account's sign-in or signup
295
300
  plan (§1). Either way, the rules below hold.
296
301
 
297
- 1. **Drive the flow in a real browser first.** Walk it by hand over the Playwright MCP
298
- — log in, fill, submit — acting on elements by their accessibility ref from the
299
- latest page snapshot, never a guessed selector. Watch what actually happens.
302
+ 1. **Start from evidence, not memory.** Pull the failed run first: \`runs_get\` for
303
+ the failing step and error, \`runs_explain\` for the run's own diagnosis,
304
+ \`tests_events\` for how the test behaved over time. Fix what the evidence shows,
305
+ never what you assume the page looks like.
300
306
  2. **Write it as an ActionPlan** — a JSON object whose \`steps\` are
301
307
  \`{action, selector, url, value, ...}\`. Two structural rules:
302
308
  - the **first executed step is a \`goto\`**, and
@@ -354,10 +360,10 @@ ${JSON.stringify(BERYL_TEST_SKILL_EXAMPLE_PLAN, null, 2)
354
360
  .map((line) => ` ${line}`)
355
361
  .join("\n")}
356
362
  \`\`\`
357
- 3. **Validate offline, then save.** Lint with \`tests_lint\` (schema check, no network),
358
- inspect the exact spec with \`tests_script\`, then save with \`tests_recompile\`
359
- (replays the edit against the live site first and rejects it on a red replay) or
360
- \`tests_set_plan\` (saves with no replay; the next run proves it). Pass a new
363
+ 3. **Save through the replay gate.** \`tests_recompile\` replays the edited plan
364
+ against the live site first and rejects it on a red replay; prefer it always.
365
+ \`tests_set_plan\` saves with no replay (the next run proves it); reserve it for
366
+ when the flow cannot replay right now (for example a third-party step). Pass a new
361
367
  \`description\` only when the edit changes what the test proves (§3).
362
368
  Full ActionPlan JSON Schema:
363
369
  https://api.beryl.so/api/v1/schemas/action-plan.schema.json.
@@ -369,9 +375,32 @@ full Chromium device emulation, not a resize: the viewport, a mobile Chrome user
369
375
  touch and the device pixel ratio all change together, so a hamburger or a nav that only
370
376
  exists on small screens actually renders and is testable. \`mobile\` emulates a Pixel-class
371
377
  phone, \`tablet\` a Galaxy-class tablet; both keep a Chrome user-agent so the emulated UA
372
- matches the engine. Drive the flow at that size over the Playwright MCP (resize the MCP
373
- browser to match) so the refs you act on are the ones the small-screen layout renders. The
374
- account, session and teardown rules are unchanged; \`device\` only sets the frame it runs in.
378
+ matches the engine. A test's surface is fixed when it is created, so when fixing a plan
379
+ keep \`device\` as it is: a small-screen test's selectors must come from what the
380
+ small-screen layout renders (a hamburger, not the desktop nav). The account, session and
381
+ teardown rules are unchanged; \`device\` only sets the frame it runs in.
382
+
383
+ ### App tests (Android and iOS)
384
+ \`device: "android"\` and \`device: "ios"\` are not browsers: the plan drives the environment's
385
+ app (its \`app_ids.android\` from the newest APK, or \`app_ids.ios\` from the newest simulator
386
+ build, uploaded to Files) on an emulator or simulator in Beryl's cloud. App tests are written
387
+ and verified by a test-creation job (\`jobs_create\`), never run locally. When you edit one,
388
+ its rules differ from a web plan's:
389
+
390
+ - The first step is \`launch\` (open the app), not \`goto\`. A later \`launch\` relaunches
391
+ with the app's data kept, which is how a plan proves something persisted.
392
+ - Steps: launch, back (Android only; iOS has no back button), fill, click, press, scroll,
393
+ wait_for, expect, await_email.
394
+ - Selectors are \`id=<resource id>\` (Android) or \`id=<accessibility identifier>\` (iOS), or
395
+ \`text=<visible text or accessibility label>\`; no CSS. React Native's \`testID\` shows up as
396
+ the id on both; a Flutter icon button needs a \`Semantics\` label to have text.
397
+ - \`have_text\` needs an \`id=\` selector; to check text is on screen, use \`visible\` with
398
+ \`text=\`. No URL or title expects: an app has no URL.
399
+ - Keys: Enter and Backspace, plus Tab on Android. Never put \`\${\` in plan text.
400
+ - A signed-in app test sets \`requires_auth\` and \`auth_mode: "session"\` and rides the
401
+ account's sign-in on that surface. The test-creation job saves that sign-in itself when
402
+ the app has a login screen; \`accounts_set_login\` with \`surface: "android"\` or
403
+ \`surface: "ios"\` only replaces it.
375
404
 
376
405
  ### The outcome assertion is the whole game
377
406
 
@@ -396,6 +425,13 @@ observable proof the flow worked.
396
425
  chrome. Target the element that carries the text (the \`h1\`, the toast), assert the
397
426
  page with \`have_title\` (substring), or check "this string is visible somewhere"
398
427
  with \`expect_kind: "visible"\` + a \`text=…\` selector.
428
+ - **For output that varies run to run** (a chatbot or AI assistant reply), assert its
429
+ meaning with \`expect_kind: "judged"\`: \`selector\` locates the reply (the LAST match
430
+ is judged, so a selector matching every assistant message reads the newest one) and
431
+ \`expect_text\` states the criteria in plain words ("Lists at least two decisions and
432
+ names the people involved; no error or refusal"). Beryl waits up to 120s for the text
433
+ to appear and stop changing, then a model passes or fails it and the failure carries
434
+ the model's reason. Write criteria a correct answer always meets, never exact wording.
399
435
  - **If you can't name a success signal, the flow is not test-worthy.** Don't bank a
400
436
  test that verifies nothing; explore a different flow.
401
437
  - **Don't work around a real app failure to make a test go green.** A genuinely broken
@@ -405,13 +441,12 @@ observable proof the flow worked.
405
441
 
406
442
  Every one of these has produced a wrong plan or a red run. Check them.
407
443
 
408
- 1. **Your browser may already be signed in.** The Playwright MCP keeps a persistent
409
- profile. Author while signed in and you never see the gate — you will mark gated
410
- pages as public. **Log out first**, then confirm the gated page really redirects to
411
- the login.
444
+ 1. **A gated page can look public in signed-in evidence.** Run artifacts captured
445
+ while a session was live never show the login wall, so do not mark a flow public
446
+ (no \`requires_auth\`) from them; check how the page behaves logged out.
412
447
  2. **The sign-UP flow is not the sign-IN flow.** A brand-new address often gets an
413
448
  extra "create your account" step a returning address skips. A stored login plan must
414
- be the **returning** path — drive it twice: once to create, once to sign in again.
449
+ be the **returning** path: what the app shows the SECOND time that account arrives.
415
450
  3. **Read the DOM, not just the accessibility tree, before picking a selector.** Two
416
451
  buttons can share a label ("Continue" / "Continue with Google"), and Beryl relaxes
417
452
  \`text=X\` to a case-insensitive SUBSTRING — \`text=Continue\` is ambiguous. Use
@@ -453,19 +488,18 @@ Good:
453
488
  Weak (describes steps + asserts nothing meaningful):
454
489
  > "Goes to /login, fills the form, and checks the page loaded."
455
490
 
456
- ## 4. The local run-fix loop
491
+ ## 4. The run-fix loop
457
492
 
458
- Iterate on your machine before you rely on the cloud. \`runs_local\` runs banked tests
459
- with the Playwright runner bundled in the CLI: no cloud, no waiting for a scheduled run.
460
- Run it un-synced with a report directory against one test id while you iterate, or with
461
- no id to run the whole suite and record the results in Beryl.
493
+ Every run happens in Beryl's cloud. \`runs_trigger\` against one test id (or a list)
494
+ starts a run; \`runs_watch\` follows it live; \`runs_get\` returns each result's
495
+ failing step, error and artifacts; \`runs_explain\` turns a red run into a diagnosis.
462
496
 
463
- The loop: \`runs_local\` un-synced against the test id → read the run's \`report.json\` to
464
- see which step or assertion failed and why → fix the plan (§2) → \`tests_lint\` →
465
- \`tests_set_plan\` → run again. When the batch is done, one synced \`runs_local\` (or
466
- \`runs_trigger\`) records it as a single run. Its options,
467
- session-mode behavior, and \`await_email\` / authenticated-test handling are in the
468
- \`runs_local\` tool's own description.
497
+ The loop: \`runs_trigger\` against the test id, read the failure with \`runs_get\` /
498
+ \`runs_explain\`, fix the plan (§2), save with \`tests_recompile\` (which already
499
+ replays it against the live site, so a green recompile IS the fix proven), then one
500
+ final \`runs_trigger\` so the suite records a clean run. If recompile keeps rejecting
501
+ an edit, the evidence disagrees with your theory: pull more of it rather than trying
502
+ variations.
469
503
 
470
504
  ## 5. Testing an OTP / signup flow (\`await_email\`)
471
505
 
@@ -521,9 +555,8 @@ For a magic-link flow, replace the code steps with
521
555
 
522
556
  Two caveats:
523
557
 
524
- - \`runs_local\` serves \`await_email\` like a cloud run: it receives at the project
525
- mailbox and answers each step over the API, so the local loop covers OTP/signup flows
526
- end to end. It signs up / sends mail FOR REAL, like every run.
558
+ - Every run serves \`await_email\` from the project mailbox. It signs up / sends
559
+ mail FOR REAL, like every run.
527
560
  - §2's outcome discipline still applies: the green signal is the post-verification
528
561
  state (the welcome screen, the dashboard), not "an email arrived".
529
562
 
@@ -562,14 +595,19 @@ Two shapes:
562
595
  \`third_party_integrations_target\` names, trigger the app's message, then
563
596
  \`{ "action": "await_third_party", "provider": "slack", "expect_text": "..." }\`.
564
597
  - **Integration intact**: no declaration. Trigger the app's message, then
565
- \`await_third_party\`.
598
+ \`await_third_party\`. When the app only answers its bot, ping the bot instead:
599
+ \`{ "action": "send_third_party", "provider": "slack", "value": "hello" }\` mentions it in
600
+ the project's channel, then \`await_third_party\` waits for its reply (in the channel or
601
+ in the thread). The bot's app must be recorded for the environment first:
602
+ \`third_party_integrations_target\` with \`env\` and \`app\`. A reply only the sender can
603
+ see never reaches Beryl, so such a bot can't be tested this way.
566
604
 
567
605
  Rules:
568
606
 
569
607
  - Always tear down what the test connected in \`after\`: disconnect the integration in
570
608
  the app.
571
- - These steps run only in Beryl's cloud: \`runs_local\` skips them, so run them with
572
- \`runs_trigger\`.
609
+ - These steps need Beryl's provider accounts, which live in Beryl's cloud; they run
610
+ like any other cloud test.
573
611
  - A setup error saying Beryl's access "is being restored" is on Beryl's side: do not
574
612
  edit the test.
575
613
  - Never add steps that browse Slack itself; the actions above do all of it.
@@ -253,6 +253,10 @@ export const testAccountCommands = [
253
253
  "green.\n\n" +
254
254
  "A captured account (from the Beryl Chrome extension) takes --probe alone: it rides " +
255
255
  "the captured session and cannot sign itself in, so there is no --file.\n\n" +
256
+ "--surface android stores the sign-in Android tests use instead, beside the web one; " +
257
+ "Beryl proves it on an Android worker the first time a signed-in android test runs. " +
258
+ "A test-creation job saves one itself when it meets the app's login screen, so this " +
259
+ "is for fixing or replacing it.\n\n" +
256
260
  "Storing only stores. Run `beryl accounts check` to prove it against the live app.",
257
261
  scope: "project",
258
262
  args: [
@@ -278,11 +282,20 @@ export const testAccountCommands = [
278
282
  description: "The login_plan_hash from `beryl accounts get-login`. The write is rejected if " +
279
283
  "the stored plan changed since. Omit only when writing the first plan",
280
284
  },
285
+ {
286
+ name: "surface",
287
+ type: "string",
288
+ enum: ["android", "ios"],
289
+ description: "An app surface: store (or read) the sign-in through the app's own screens. Both " +
290
+ "plans are plans for that device (device android or ios, starting with a launch); " +
291
+ "a signed-in app test restores the session this sign-in saved on the device",
292
+ },
281
293
  ],
282
294
  examples: [
283
295
  "beryl accounts set-login acc_123 --file signin.json --probe probe.json",
284
296
  "beryl accounts set-login acc_123 --file signin.json --probe probe.json --base-hash 9f2c…",
285
297
  "beryl accounts set-login acc_456 --probe probe.json # captured account",
298
+ "beryl accounts set-login acc_123 --surface android --file app-signin.json --probe app-probe.json",
286
299
  ],
287
300
  async run(ctx, input) {
288
301
  const { workspaceId, projectId } = await ctx.requireProject(input);
@@ -291,11 +304,15 @@ export const testAccountCommands = [
291
304
  plan: flagStr(input, "file") ? readJsonFlag(input, "file") : null,
292
305
  probe: readJsonFlag(input, "probe"),
293
306
  base_hash: flagStr(input, "base-hash") ?? null,
307
+ ...(flagStr(input, "surface") ? { surface: flagStr(input, "surface") } : {}),
294
308
  }));
309
+ const surface = flagStr(input, "surface");
295
310
  return {
296
311
  data: account,
297
312
  human: `${green("Stored")} — ${account.email}\n` +
298
- dim("Now run `beryl accounts check " + account.id + "` to prove it works."),
313
+ dim(surface
314
+ ? `Beryl proves it the first time a signed-in ${surface} test runs.`
315
+ : "Now run `beryl accounts check " + account.id + "` to prove it works."),
299
316
  };
300
317
  }
301
318
  catch (err) {
@@ -314,9 +331,21 @@ export const testAccountCommands = [
314
331
  "--base-hash on the next `accounts set-login`.",
315
332
  scope: "project",
316
333
  args: [{ name: "account-id", description: "Account id", required: true }],
334
+ flags: [
335
+ {
336
+ name: "surface",
337
+ type: "string",
338
+ enum: ["android", "ios"],
339
+ description: "An app surface: store (or read) the sign-in through the app's own screens. Both " +
340
+ "plans are plans for that device (device android or ios, starting with a launch); " +
341
+ "a signed-in app test restores the session this sign-in saved on the device",
342
+ },
343
+ ],
317
344
  async run(ctx, input) {
318
345
  const { workspaceId, projectId } = await ctx.requireProject(input);
319
- const login = (await ctx.client.get(`${accountsPath(workspaceId, projectId)}/${arg(input, "account-id")}/login`));
346
+ const surface = flagStr(input, "surface");
347
+ const login = (await ctx.client.get(`${accountsPath(workspaceId, projectId)}/${arg(input, "account-id")}/login` +
348
+ (surface ? `?surface=${encodeURIComponent(surface)}` : "")));
320
349
  if (!login.plan && !login.probe)
321
350
  return {
322
351
  data: login,
@@ -1,7 +1,12 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { UsageError } from "../errors.js";
3
+ import { CliError, UsageError } from "../errors.js";
4
+ import { ApiError } from "../http.js";
5
+ import { dim, green, table, timeAgo } from "../output.js";
4
6
  import { arg, flagBool, flagStr, projectPath } from "./util.js";
7
+ const APK_RE = /\.apk$/i;
8
+ const BUILD_KINDS = new Set(["android_build", "ios_build"]);
9
+ const buildLabel = (f) => `${f.app_id ?? "?"} ${f.app_version ?? "?"} (${f.build_number ?? "?"})`;
5
10
  const configPath = (ws, p) => `${projectPath(ws, p)}/config`;
6
11
  function findByKey(rows, value, kind) {
7
12
  const byId = rows.find((r) => r.id === value);
@@ -165,11 +170,20 @@ export const configCommands = [
165
170
  },
166
171
  {
167
172
  name: "config files list",
168
- summary: "List files uploaded for the agent to use (e.g. CSVs, upload fixtures)",
173
+ summary: "List uploaded files: fixtures tests attach and Android and iOS app builds",
169
174
  scope: "project",
170
175
  async run(ctx, input) {
171
176
  const { workspaceId, projectId } = await ctx.requireProject(input);
172
- return { data: await ctx.client.get(`${configPath(workspaceId, projectId)}/files`) };
177
+ const rows = (await ctx.client.get(`${configPath(workspaceId, projectId)}/files`));
178
+ const human = table(rows.map((f) => ({
179
+ name: f.name,
180
+ kind: f.kind ?? "fixture",
181
+ app: BUILD_KINDS.has(String(f.kind)) ? buildLabel(f) : "",
182
+ env: f.environment_id ?? "",
183
+ created: timeAgo(f.created_at),
184
+ id: f.id,
185
+ })));
186
+ return { data: rows, human };
173
187
  },
174
188
  },
175
189
  {
@@ -195,32 +209,89 @@ export const configCommands = [
195
209
  },
196
210
  {
197
211
  name: "config files upload",
198
- summary: "Upload a file",
212
+ summary: "Upload a file: a fixture an upload step attaches, or an Android or iOS app build",
213
+ description: "The bytes go straight to storage over a short-lived URL, never through the API, up to " +
214
+ "1 GB. An .apk uploads as an Android build (or pass --kind android_build): Beryl reads " +
215
+ "its package name, version and native ABIs, refuses one the emulators cannot run (an " +
216
+ ".aab, a split APK, a too-new minSdk, ARM-only native code on x86 emulators). An iOS " +
217
+ "simulator build (the .app folder zipped, or EAS's .tar.gz) uploads with --kind " +
218
+ "ios_build: Beryl reads its bundle id and version and refuses an .ipa, a device build " +
219
+ "or one with no arm64 slice. App tests of an environment whose app id for that surface " +
220
+ "matches install the newest build. Beryl keeps the newest 5 builds per app per " +
221
+ "environment. Upload after each build, e.g. in CI.",
199
222
  scope: "project",
200
223
  args: [{ name: "file", description: "Path to the local file", required: true }],
201
- flags: [{ name: "env", type: "string", description: "Scope to one environment id" }],
224
+ flags: [
225
+ { name: "env", type: "string", description: "Scope to one environment id" },
226
+ {
227
+ name: "kind",
228
+ type: "string",
229
+ enum: ["fixture", "android_build", "ios_build"],
230
+ description: "fixture: a file tests attach. android_build: an Android APK (the default for .apk). " +
231
+ "ios_build: a zipped or tarred iOS simulator .app",
232
+ },
233
+ ],
234
+ examples: [
235
+ "beryl config files upload ./fixtures/avatar.png",
236
+ "beryl config files upload ./android/app/build/outputs/apk/release/app-release.apk",
237
+ "beryl config files upload ./MyApp.zip --kind ios_build",
238
+ ],
202
239
  async run(ctx, input) {
203
240
  const { workspaceId, projectId } = await ctx.requireProject(input);
204
241
  const file = arg(input, "file");
205
- let bytes;
242
+ const explicitKind = flagStr(input, "kind");
243
+ const kind = explicitKind ?? (APK_RE.test(file) ? "android_build" : "fixture");
244
+ const envId = flagStr(input, "env");
245
+ let blob;
206
246
  try {
207
- bytes = new Uint8Array(fs.readFileSync(file));
247
+ if (!fs.statSync(file).isFile())
248
+ throw new UsageError(`not a file: ${file}`);
249
+ blob = await fs.openAsBlob(file);
208
250
  }
209
251
  catch (err) {
252
+ if (err instanceof UsageError)
253
+ throw err;
210
254
  const code = err.code;
211
255
  if (code === "ENOENT")
212
256
  throw new UsageError(`file not found: ${file}`);
213
257
  throw new UsageError(`cannot read ${file} (${code ?? err.message})`);
214
258
  }
215
- const form = new FormData();
216
- form.set("file", new Blob([bytes]), path.basename(file));
217
- const envId = flagStr(input, "env");
218
- if (envId)
219
- form.set("environment_id", envId);
259
+ if (blob.size === 0)
260
+ throw new UsageError(`${file} is empty`);
261
+ const base = `${configPath(workspaceId, projectId)}/files`;
262
+ const upload = (await ctx.client.post(`${base}/uploads`, {
263
+ name: path.basename(file),
264
+ size: blob.size,
265
+ kind,
266
+ ...(envId ? { environment_id: envId } : {}),
267
+ }));
268
+ let put;
269
+ try {
270
+ put = await fetch(upload.upload_url, {
271
+ method: upload.method,
272
+ headers: upload.headers,
273
+ body: blob,
274
+ });
275
+ }
276
+ catch (err) {
277
+ throw new CliError(`Upload of ${file} failed: ${err.message}`);
278
+ }
279
+ if (!put.ok)
280
+ throw new CliError(`Upload of ${file} failed: storage answered HTTP ${put.status}`);
281
+ let done;
282
+ try {
283
+ done = (await ctx.client.post(`${base}/uploads/${upload.file_id}/complete`));
284
+ }
285
+ catch (err) {
286
+ if (err instanceof ApiError && err.status === 422 && typeof err.detail === "string")
287
+ throw new CliError(`Build refused: ${err.detail}`);
288
+ throw err;
289
+ }
220
290
  return {
221
- data: await ctx.client.request("POST", `${configPath(workspaceId, projectId)}/files`, {
222
- form,
223
- }),
291
+ data: done,
292
+ human: BUILD_KINDS.has(String(done.kind))
293
+ ? `${green("Build uploaded")}: ${buildLabel(done)} ${dim(String(done.id))}`
294
+ : `${green("Uploaded")}: ${String(done.name)} ${dim(String(done.id))}`,
224
295
  };
225
296
  },
226
297
  },
@@ -72,12 +72,17 @@ export const environmentCommands = [
72
72
  },
73
73
  {
74
74
  name: "envs update",
75
- summary: "Update an environment's name, URL, auth settings, Site Health screen sizes, schedule, CI and pages, or issue tracker settings",
75
+ summary: "Update an environment's name, URL, app ids, auth settings, Site Health screen sizes, schedule, CI and pages, or issue tracker settings",
76
76
  scope: "project",
77
77
  args: [{ name: "env-id", description: "Environment id", required: true }],
78
78
  flags: [
79
79
  { name: "name", type: "string", description: "New name" },
80
80
  { name: "url", type: "string", description: "New root URL" },
81
+ {
82
+ name: "app-id",
83
+ type: "strings",
84
+ description: 'App id for an app surface, as surface=id (android=com.acme.app, ios=com.acme.App), repeatable: that surface\'s tests install the newest uploaded build of it; "ios=" clears it',
85
+ },
81
86
  { name: "auth", type: "string", enum: ["public", "gated"], description: "New auth choice" },
82
87
  {
83
88
  name: "viewport",
@@ -91,6 +96,7 @@ export const environmentCommands = [
91
96
  ],
92
97
  examples: [
93
98
  "beryl envs update <env-id> --tracker linear --tracker-team <team-id>",
99
+ "beryl envs update <env-id> --app-id android=com.acme.app --app-id ios=com.acme.App",
94
100
  "beryl envs update <env-id> --tracker-threshold medium --tracker-max-tickets 5",
95
101
  "beryl envs update <env-id> --tracker-priority high=urgent --tracker-pause on",
96
102
  "beryl envs update <env-id> --health-schedule daily --health-time 06:00 --health-timezone Europe/Berlin",
@@ -104,6 +110,17 @@ export const environmentCommands = [
104
110
  body.name = flagStr(input, "name");
105
111
  if (flagStr(input, "url"))
106
112
  body.root_url = flagStr(input, "url");
113
+ const appIds = flagStrings(input, "app-id");
114
+ if (appIds) {
115
+ const parsed = {};
116
+ for (const pair of appIds) {
117
+ const [surface, ...rest] = pair.split("=");
118
+ if (!surface || rest.length === 0)
119
+ throw new UsageError(`--app-id must be surface=id (android=com.acme.app), got ${pair}`);
120
+ parsed[surface] = rest.join("=");
121
+ }
122
+ body.app_ids = parsed;
123
+ }
107
124
  if (flagStr(input, "auth"))
108
125
  body.requires_auth_choice = flagStr(input, "auth");
109
126
  const viewports = flagStrings(input, "viewport");