@beryl-so/cli 0.73.2 → 0.77.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,38 +1,10 @@
1
1
  import fs from "node:fs";
2
- import path from "node:path";
3
2
  import { downloadRunArtifacts, failureImages, isFailing, resultsOf, } from "../artifacts.js";
4
- import { AuthError, CliError, UsageError } from "../errors.js";
5
- import { ApiError } from "../http.js";
6
- import { buildImportForm, establishAccountSession, executeLocalSpec, IMPORT_MAX_ERROR_LEN, specUsesThirdParty, THIRD_PARTY_USE_TRIGGER, toRunEntry, } from "../local-exec.js";
7
- import { countPlannedFrames, countWrittenFrames, PlaywrightMissingError, } from "../local-run.js";
8
- import { dim, green, red, yellow } from "../output.js";
9
- import { anyGap, confirmInstall, describeGaps, installCommandsFor, installHint, installPlaywright, playwrightGaps, } from "../playwright-install.js";
10
- import { ProgressBar } from "../progress.js";
3
+ import { UsageError } from "../errors.js";
4
+ import { dim, yellow } from "../output.js";
11
5
  import { arg, flagBool, flagNum, flagStr, idsInGroup, projectPath } from "./util.js";
12
6
  import { watchRun } from "./watch.js";
13
7
  const MAX_FAILURE_SCREENSHOTS = 5;
14
- // One filesystem check before a single spec is fetched. A missing browser binary otherwise
15
- // fails EVERY test with the same launch error, which reads as a broken suite instead of a
16
- // box that can't run tests. On a TTY it's an offer; over MCP / CI it's the exact commands.
17
- async function ensureRunnableLocally(ctx) {
18
- const gaps = playwrightGaps(process.cwd());
19
- if (!anyGap(gaps))
20
- return;
21
- const commands = installCommandsFor(gaps, process.cwd());
22
- const hint = installHint(process.cwd(), gaps);
23
- if (!ctx.interactive || !(await confirmInstall(ctx.prompt, commands)))
24
- throw new CliError(hint);
25
- try {
26
- await installPlaywright(process.cwd(), (line) => ctx.err(dim(line)));
27
- }
28
- catch (err) {
29
- throw new CliError(`${err instanceof Error ? err.message : String(err)}\n\nFinish it by hand:\n ${commands}`);
30
- }
31
- const left = playwrightGaps(process.cwd());
32
- if (anyGap(left))
33
- throw new CliError(hint);
34
- ctx.err(green(`✓ ${describeGaps(gaps)} installed`));
35
- }
36
8
  export function parseHeaders(raw) {
37
9
  if (!raw || raw.length === 0)
38
10
  return null;
@@ -131,406 +103,6 @@ export const runCommands = [
131
103
  });
132
104
  },
133
105
  },
134
- {
135
- name: "runs local",
136
- summary: "Run tests on your machine with Playwright; results sync to Beryl",
137
- description: "Unlike `runs trigger`, the browser runs on YOUR machine: each test's rendered spec is " +
138
- "fetched and run with the Playwright runner bundled in the CLI (your repo's own " +
139
- "@playwright/test wins when present) and the Chromium binary it drives. " +
140
- "Both are checked once before any spec is fetched, so a machine that can't run tests " +
141
- "says so once instead of failing every test (on a terminal the CLI offers to install " +
142
- "the missing browser; over MCP it prints the exact install command). " +
143
- "Signup/OTP flows work: the CLI answers the spec's await_email steps over the API " +
144
- "against the same mailbox the cloud runner uses. Authenticated tests work too: for a plan " +
145
- "that signs itself in with {{login_email}}/{{login_password}}, the email is baked into " +
146
- "the fetched spec and the password is fetched " +
147
- "once over the logged secret-reveal route, handed to the spec the way the cloud runner " +
148
- "does, and scrubbed from any error text or DOM snapshot before results upload; a test " +
149
- "whose account has no stored password is skipped with the exact fix-it command. When the " +
150
- "run finishes, the results and replay artifacts are imported into Beryl as a normal run " +
151
- "(trigger source `local`), so history, replay, and reports all work; pass --no-sync to " +
152
- "keep a run entirely off the record while iterating. Session-mode tests behave as " +
153
- "in the cloud: their account signs in once per invocation and every session-mode " +
154
- "test rides that session; a failed sign-in fails those tests with the same " +
155
- "SESSION_* reason a cloud run reports. Only a test that depends on a session Beryl " +
156
- "captured server-side, or that has third party integration steps (cloud only), is " +
157
- "skipped, with a note. Run those with `runs trigger`. Point " +
158
- "--url-override at a local dev server or preview, and --dir to keep specs, artifacts, " +
159
- "and reports on disk. With no ids, a test with no plan yet is skipped quietly; one " +
160
- "whose script can't be fetched is listed under `skipped` and fails the run. Exits 0 " +
161
- "only if every executed test passed and every script was fetched.",
162
- scope: "project",
163
- args: [
164
- {
165
- name: "test-ids",
166
- description: "Test ids to run (from `beryl tests list`); omit to run every active test",
167
- variadic: true,
168
- },
169
- ],
170
- flags: [
171
- {
172
- name: "all",
173
- type: "boolean",
174
- description: "Run every active test in the project (the default when no ids are given)",
175
- },
176
- {
177
- name: "group",
178
- type: "string",
179
- description: "Run only the active tests in this group (by name). Cannot be combined with test ids.",
180
- },
181
- {
182
- name: "url-override",
183
- type: "string",
184
- description: "Run against this base URL instead of the environment's (e.g. http://localhost:3000)",
185
- },
186
- {
187
- name: "env",
188
- type: "string",
189
- description: "Environment id to run against and attach the imported run to. Use for a " +
190
- "standing environment; --url-override is for a throwaway host",
191
- },
192
- {
193
- name: "sync",
194
- type: "boolean",
195
- default: true,
196
- description: "Import the results into Beryl as a run when finished (--no-sync: local only, " +
197
- "nothing recorded)",
198
- },
199
- {
200
- name: "dir",
201
- type: "string",
202
- description: "Write each test's spec, artifacts, and JSON report under this directory",
203
- },
204
- ],
205
- examples: [
206
- "beryl runs local",
207
- "beryl runs local 4f… 9a…",
208
- "beryl runs local --url-override http://localhost:3000",
209
- "beryl runs local 4f… --no-sync --dir ./beryl-local",
210
- ],
211
- async run(ctx, input) {
212
- // Argument validation runs BEFORE the Chromium/Playwright precondition: a bad flag
213
- // combination should say so on any machine, not report a missing browser.
214
- // Deduped: a repeated id would put the same test twice in one imported run,
215
- // which the import manifest rejects.
216
- const explicitIds = [...new Set(input.args["test-ids"] ?? [])];
217
- const group = flagStr(input, "group");
218
- if (group && explicitIds.length > 0)
219
- throw new UsageError("--group cannot be combined with explicit test ids");
220
- if (explicitIds.length > 0 && flagBool(input, "all"))
221
- throw new UsageError("--all cannot be combined with explicit test ids");
222
- if (group && flagBool(input, "all"))
223
- throw new UsageError("--all cannot be combined with --group");
224
- // No ids and no group means the whole suite — `beryl runs local` alone is a
225
- // complete local run.
226
- const all = explicitIds.length === 0 && !group;
227
- await ensureRunnableLocally(ctx);
228
- const { workspaceId, projectId } = await ctx.requireProject(input);
229
- const sync = input.flags.sync !== false;
230
- const urlOverride = flagStr(input, "url-override");
231
- const dir = flagStr(input, "dir");
232
- const listed = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests`, {
233
- environment_id: flagStr(input, "env"),
234
- }));
235
- // The list rows carry the authored name as nl_title (title is the customer's
236
- // rename, usually unset) — fall through so the terminal shows names, not ids.
237
- const titles = new Map(listed.map((t) => [t.id, t.title || t.nl_title || t.id.slice(0, 8)]));
238
- const ids = all
239
- ? listed.filter((t) => t.is_active !== false).map((t) => t.id)
240
- : group
241
- ? idsInGroup(listed, group)
242
- : explicitIds;
243
- if (ids.length === 0)
244
- throw group
245
- ? new UsageError(`No active tests in group '${group}'.`)
246
- : new CliError("This project has no tests to run.");
247
- const fetchScript = (id) => ctx.client.get(`${projectPath(workspaceId, projectId)}/tests/${id}/script`, {
248
- // frames only matter when the run will be imported: they become the replay.
249
- // environment_id keeps the baked login_email on the same environment the
250
- // LOGIN_PASSWORD reveal below is scoped to.
251
- base_url: urlOverride,
252
- frames: sync ? true : undefined,
253
- environment_id: flagStr(input, "env"),
254
- });
255
- const toLocalSpec = (id, script) => ({
256
- id,
257
- title: titles.get(id) ?? id.slice(0, 8),
258
- content: script.content,
259
- requiresAuth: Boolean(script.requires_auth),
260
- usesEmail: /__vmInbox\(|__vmAwaitEmail\(/.test(script.content),
261
- usesLoginPassword: Boolean(script.uses_login_password),
262
- usesThirdParty: specUsesThirdParty(script.content),
263
- inlinedLoginSteps: script.inlined_login_steps ?? 0,
264
- ...(script.session_mode && script.account_id
265
- ? { sessionMode: true, accountId: script.account_id }
266
- : {}),
267
- ...(script.email ? { email: script.email } : {}),
268
- ...(script.login_config_error ? { loginConfigError: script.login_config_error } : {}),
269
- });
270
- const specs = [];
271
- // With --all, a test the server can't render (no plan yet, 409) is a quiet skip;
272
- // any other fetch failure is listed and fails the run. An explicitly-requested id
273
- // failing to render is the caller's problem.
274
- const fetchFailed = [];
275
- for (const id of ids) {
276
- let script;
277
- try {
278
- script = await fetchScript(id);
279
- }
280
- catch (err) {
281
- if (!all || err instanceof AuthError)
282
- throw err;
283
- const detail = err instanceof Error ? err.message : String(err);
284
- if (err instanceof ApiError && err.status === 409) {
285
- ctx.err(yellow(`! ${titles.get(id) ?? id}: no runnable script — skipped (${detail})`));
286
- }
287
- else {
288
- fetchFailed.push(id);
289
- ctx.err(red(`✗ ${titles.get(id) ?? id}: could not fetch its script (${detail})`));
290
- }
291
- continue;
292
- }
293
- specs.push(toLocalSpec(id, script));
294
- }
295
- if (specs.length === 0)
296
- throw new CliError(fetchFailed.length
297
- ? `Could not fetch the script for ${fetchFailed.length} test(s); nothing ran.`
298
- : "No runnable tests were found.");
299
- // A test that leans on a session this CLI cannot establish gets no session here,
300
- // so skip it rather than run a spec doomed at the login wall. A session-mode test
301
- // is NOT in that set: its account's session is established below, once, exactly as
302
- // a cloud run's setup phase does. A test whose account is missing its password
303
- // would type a literal placeholder into the page: skip, with the server's fix-it
304
- // message.
305
- const skipped = specs.filter((s) => (s.requiresAuth && !(s.sessionMode && s.accountId)) ||
306
- s.loginConfigError ||
307
- s.usesThirdParty);
308
- const runnable = specs.filter((s) => !skipped.includes(s));
309
- for (const s of skipped) {
310
- ctx.err(yellow(s.loginConfigError
311
- ? `! ${s.title}: skipped — ${s.loginConfigError}`
312
- : s.usesThirdParty
313
- ? `! ${s.title}: skipped, ${THIRD_PARTY_USE_TRIGGER}.`
314
- : `! ${s.title}: needs a session only Beryl's cloud holds, skipped ` +
315
- `(use \`beryl runs trigger\`).`));
316
- }
317
- if (runnable.length === 0)
318
- throw new CliError("None of the selected tests can run locally.");
319
- // Once per account, before any test runs — the local counterpart of the cloud
320
- // worker's run-setup phase, produced by the same server-side establish (stored-
321
- // session cache, login replay, proof). A failure here fails only that account's
322
- // tests, with the same stable-prefixed reason a cloud run reports; an account
323
- // proven unable to carry a session gets its tests re-rendered to sign themselves
324
- // in, the same degraded mode a cloud run applies.
325
- const sessions = new Map();
326
- const sessionErrors = new Map();
327
- const accountIds = [
328
- ...new Set(runnable.filter((s) => s.sessionMode && s.accountId).map((s) => s.accountId)),
329
- ];
330
- for (const accountId of accountIds) {
331
- ctx.err(dim("Establishing the test account's session (once per run)…"));
332
- const established = await establishAccountSession(ctx.client, projectPath(workspaceId, projectId), accountId);
333
- if (established.session) {
334
- sessions.set(accountId, established.session);
335
- }
336
- else if (established.unsupported) {
337
- // The server just latched UNSUPPORTED, so a re-fetch renders these specs with
338
- // their account's sign-in steps inlined — they run self-signing like any other.
339
- // Whole-object replacement, not a merge: the inlined render carries no
340
- // session_mode, and a merge would leave the stale sessionMode/accountId behind.
341
- ctx.err(yellow(`! ${established.error} — signing in per test.`));
342
- try {
343
- for (const [i, s] of runnable.entries()) {
344
- if (s.accountId !== accountId)
345
- continue;
346
- runnable[i] = toLocalSpec(s.id, await fetchScript(s.id));
347
- }
348
- }
349
- catch (err) {
350
- // A failed re-fetch fails only this account's tests, like any other
351
- // establish failure — not the whole invocation.
352
- sessionErrors.set(accountId, err instanceof Error ? err.message : String(err));
353
- }
354
- }
355
- else {
356
- sessionErrors.set(accountId, established.error ?? "SESSION_UNAVAILABLE");
357
- }
358
- }
359
- // Revealed once for the batch over the member-gated, logged route — the same
360
- // secret the cloud runner banks into run-config.json; never in the spec source.
361
- let loginPassword;
362
- if (runnable.some((s) => s.usesLoginPassword)) {
363
- try {
364
- const secret = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/config/secrets/LOGIN_PASSWORD/value`, { environment_id: flagStr(input, "env") }));
365
- loginPassword = secret.value;
366
- }
367
- catch (err) {
368
- throw new CliError("Could not reveal the LOGIN_PASSWORD secret for the saved login: " +
369
- (err instanceof Error ? err.message : String(err)));
370
- }
371
- }
372
- const startedAt = new Date().toISOString();
373
- const entries = [];
374
- const bar = new ProgressBar();
375
- let installOffered = false;
376
- let done = 0;
377
- let passed = 0;
378
- let failed = 0;
379
- for (const spec of runnable) {
380
- const stepTotal = countPlannedFrames(spec.content);
381
- const state = () => ({
382
- total: runnable.length,
383
- done,
384
- passed,
385
- failed,
386
- title: spec.title,
387
- stepTotal,
388
- });
389
- bar.update(state());
390
- if (!bar.active)
391
- ctx.err(dim(`Running ${spec.title}…`));
392
- const testStarted = new Date().toISOString();
393
- // No proven session for this account: running the spec would open the page
394
- // logged out and pass every assertion that tolerates that — fail it in the
395
- // setup phase with the establish error, exactly as a cloud run does.
396
- const sessionError = spec.accountId ? sessionErrors.get(spec.accountId) : undefined;
397
- if (spec.sessionMode && sessionError !== undefined) {
398
- entries.push({
399
- test_case_id: spec.id,
400
- status: "failed",
401
- phase: "setup",
402
- error_message: sessionError.slice(0, IMPORT_MAX_ERROR_LEN),
403
- started_at: testStarted,
404
- completed_at: new Date().toISOString(),
405
- frames: [],
406
- frame_urls: [],
407
- frame_durations_ms: [],
408
- files: [],
409
- });
410
- done += 1;
411
- failed += 1;
412
- bar.clear();
413
- ctx.err(`${red("✗")} ${spec.title}\n ${dim(sessionError.split("\n")[0] ?? "")}`);
414
- continue;
415
- }
416
- const session = spec.sessionMode ? sessions.get(spec.accountId ?? "") : undefined;
417
- let outcome;
418
- let runError;
419
- let lastStep = 0;
420
- const attempt = () => executeLocalSpec({ client: ctx.client, workspaceId, projectId }, {
421
- spec,
422
- dir: dir ? path.join(dir, spec.id) : undefined,
423
- harvest: sync,
424
- loginPassword,
425
- session,
426
- // Clear the rewriting bar before a mid-test log line, or the line is
427
- // appended onto the live bar and fossilises it into scrollback.
428
- onEvent: (line) => {
429
- bar.clear();
430
- ctx.err(dim(line));
431
- bar.update({ ...state(), step: lastStep });
432
- },
433
- onSpawn: (runDir) => {
434
- const ticker = setInterval(() => {
435
- lastStep = countWrittenFrames(runDir);
436
- bar.update({ ...state(), step: lastStep });
437
- }, 300);
438
- return () => clearInterval(ticker);
439
- },
440
- });
441
- try {
442
- try {
443
- ({ outcome, runError } = await attempt());
444
- }
445
- catch (err) {
446
- // Local Playwright missing: on a TTY offer to install it once and retry,
447
- // instead of only printing a hint the user then has to act on by hand.
448
- if (err instanceof PlaywrightMissingError && ctx.interactive && !installOffered) {
449
- installOffered = true;
450
- bar.clear();
451
- const cwd = process.cwd();
452
- const commands = installCommandsFor(playwrightGaps(cwd), cwd);
453
- if (!(await confirmInstall(ctx.prompt, commands)))
454
- throw err;
455
- try {
456
- await installPlaywright(process.cwd(), (line) => ctx.err(dim(line)));
457
- }
458
- catch (installErr) {
459
- throw new CliError(installErr instanceof Error ? installErr.message : String(installErr));
460
- }
461
- ctx.err(green("✓ Local Playwright installed — running the test…"));
462
- ({ outcome, runError } = await attempt());
463
- }
464
- else {
465
- throw err;
466
- }
467
- }
468
- }
469
- catch (err) {
470
- // Environmental failures abort the batch — nothing later would fare better.
471
- if (err instanceof PlaywrightMissingError || err instanceof CliError) {
472
- bar.clear();
473
- throw err instanceof CliError ? err : new CliError(err.message);
474
- }
475
- // The spec never ran and the executor could not classify it — an errored
476
- // result, not an aborted batch: the remaining tests still deserve their run.
477
- runError = err instanceof Error ? err.message : String(err);
478
- }
479
- const entry = toRunEntry(spec, outcome, runError, testStarted, entries.length, [
480
- ...(spec.usesLoginPassword && loginPassword ? [loginPassword] : []),
481
- ...(session?.secrets ?? []),
482
- ]);
483
- entries.push(entry);
484
- done += 1;
485
- if (entry.status === "passed")
486
- passed += 1;
487
- else
488
- failed += 1;
489
- bar.clear();
490
- ctx.err(`${entry.status === "passed" ? green("✓") : red("✗")} ${spec.title}` +
491
- `${entry.duration_ms ? dim(` (${(entry.duration_ms / 1000).toFixed(1)}s)`) : ""}` +
492
- `${entry.error_message ? `\n ${dim(entry.error_message.split("\n")[0] ?? "")}` : ""}`);
493
- }
494
- bar.clear();
495
- let runId;
496
- if (sync && entries.length > 0) {
497
- ctx.err(dim("Importing results into Beryl…"));
498
- const imported = (await ctx.client.request("POST", `${projectPath(workspaceId, projectId)}/runs/import`, {
499
- form: buildImportForm(entries, {
500
- environmentId: flagStr(input, "env"),
501
- targetUrlOverride: urlOverride,
502
- startedAt,
503
- completedAt: new Date().toISOString(),
504
- onNote: (line) => ctx.err(yellow(`! ${line}`)),
505
- }),
506
- }));
507
- runId = imported.id;
508
- }
509
- const summaryLine = failed === 0
510
- ? green(`All tests passed (${passed}/${entries.length})`)
511
- : red(`${failed} test(s) failed`) + ` (${passed} passed)`;
512
- const human = `${summaryLine}` +
513
- (skipped.length ? `\n${yellow(`${skipped.length} skipped (see notes above)`)}` : "") +
514
- (fetchFailed.length
515
- ? `\n${red(`${fetchFailed.length} could not be fetched (see notes above)`)}`
516
- : "") +
517
- (runId ? `\nSynced to Beryl as run ${runId} — \`beryl runs get ${runId}\`` : "") +
518
- (!sync ? `\n${dim("--no-sync: nothing recorded in Beryl")}` : "") +
519
- (dir ? `\n${dim(`Specs + artifacts + reports under ${path.resolve(dir)}`)}` : "");
520
- return {
521
- data: {
522
- passed,
523
- failed,
524
- skipped: [...skipped.map((s) => s.id), ...fetchFailed],
525
- results: entries.map(({ files: _files, ...rest }) => rest),
526
- ...(runId ? { run_id: runId } : {}),
527
- },
528
- human,
529
- // Exit codes are a CI contract: any failing/errored or unfetchable test → exit 1.
530
- ...(failed > 0 || fetchFailed.length > 0 ? { exitCode: 1 } : {}),
531
- };
532
- },
533
- },
534
106
  {
535
107
  name: "runs list",
536
108
  summary: "List recent runs",
@@ -1,12 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import { UsageError } from "../errors.js";
3
- import { lintPlan } from "../lint.js";
4
3
  import { table } from "../output.js";
5
- import { ACTION_PLAN_SCHEMA } from "../schema.generated.js";
6
4
  import { arg, argList, flagBool, flagNum, flagStr, flagStrings, inGroup, projectPath, readJsonFlag, } from "./util.js";
7
- export const TESTS_CREATE_RETIRED = "`tests create` is retired: tests are now created by the cloud test-creation job. " +
8
- "Describe the flows in a document and pass it to `beryl jobs create`. `runs local` " +
9
- "still runs your banked tests on this machine.";
10
5
  const testPath = (ws, p, id) => `${projectPath(ws, p)}/tests/${id}`;
11
6
  const EVENT_VERBS = {
12
7
  QUARANTINED: "quarantined",
@@ -67,54 +62,6 @@ function conciseTestRow(row) {
67
62
  };
68
63
  }
69
64
  export const testCommands = [
70
- {
71
- name: "tests lint",
72
- summary: "Validate a plan JSON file offline, before sending it to the server",
73
- description: "Checks a plan against the published ActionPlan JSON Schema: every action's required " +
74
- "fields, plus the two structural rules (the first EXECUTED step must be a goto, and at " +
75
- "least one step across before + steps must be an expect). Also the identity rules: a " +
76
- "plan that signs up with {{inbox_address}} and reads the mail must delete the account " +
77
- "in `after`, and no test plan may type {{mailbox_address}} (errors; the server refuses " +
78
- "both too). Pass --provisioning for an account's signup or sign-in plan, which may fill " +
79
- "{{mailbox_address}}. Runs entirely locally, so a malformed plan fails here instead of " +
80
- "costing a server round-trip. " +
81
- `Schema: ${ACTION_PLAN_SCHEMA.$id}`,
82
- scope: "none",
83
- flags: [
84
- { name: "file", type: "string", required: true, description: "Plan JSON file, or - for stdin" },
85
- {
86
- name: "provisioning",
87
- type: "boolean",
88
- description: "The file is an account's signup or sign-in plan (for `accounts signup` / " +
89
- "`accounts set-login`), so it may fill {{mailbox_address}}",
90
- },
91
- {
92
- name: "access",
93
- type: "string",
94
- description: "The label the plan will be banked with: read (default) or write. It is yours to " +
95
- "set; Beryl does not infer it. Only a write is checked (its closing teardown proof and {{unique}} names)",
96
- },
97
- ],
98
- examples: [
99
- "beryl tests lint --file plan.json",
100
- "beryl tests lint --file plan.json --access write",
101
- "beryl tests lint --file signup-plan.json --provisioning",
102
- ],
103
- async run(_ctx, input) {
104
- const issues = lintPlan(readJsonFlag(input, "file"), {
105
- provisioning: flagBool(input, "provisioning"),
106
- access: accessFlag(input, "read"),
107
- });
108
- const lines = issues.map((i) => `${i.severity === "warning" ? "warning" : "error"} ${i.path}: ${i.message}`);
109
- if (issues.every((i) => i.severity === "warning")) {
110
- return {
111
- data: { valid: true, issues },
112
- human: ["Plan is valid.", ...lines].join("\n"),
113
- };
114
- }
115
- return { data: { valid: false, issues }, human: lines.join("\n"), exitCode: 1 };
116
- },
117
- },
118
65
  {
119
66
  name: "tests list",
120
67
  summary: "List the project's tests with their latest result",
@@ -221,31 +168,6 @@ export const testCommands = [
221
168
  return { data: version.json_plan, human: JSON.stringify(version.json_plan, null, 2) };
222
169
  },
223
170
  },
224
- {
225
- name: "tests create",
226
- summary: "Retired: tests are now created by the cloud test-creation job (`jobs create`)",
227
- description: "Local test authoring is retired: every test is authored by Beryl's cloud agent, so all " +
228
- "plans are built one way. Give the agent a document describing the flows with " +
229
- "`jobs create`. This command creates nothing and exits 2. Local runs are unchanged: " +
230
- "`runs local` still runs banked tests on your machine.",
231
- // Project scope and the old flags stay declared so an old invocation parses and
232
- // reaches this message instead of an unknown-flag error.
233
- scope: "project",
234
- flags: [
235
- ["title", "string"],
236
- ["file", "string"],
237
- ["description", "string"],
238
- ["no-verify", "boolean"],
239
- ["url-override", "string"],
240
- ["group", "strings"],
241
- ["env", "string"],
242
- ["access", "string"],
243
- ["dir", "string"],
244
- ].map(([name, type]) => ({ name, type, description: "Ignored" })),
245
- async run() {
246
- throw new UsageError(TESTS_CREATE_RETIRED);
247
- },
248
- },
249
171
  {
250
172
  name: "tests set-plan",
251
173
  summary: "Replace a test's step plan from a JSON file (creates a new version)",
@@ -644,38 +566,14 @@ export const testCommands = [
644
566
  },
645
567
  {
646
568
  name: "tests script",
647
- summary: "Print the rendered Playwright spec for a test (or an unbanked plan file)",
648
- description: "With a test id, fetches the banked test's rendered .spec.ts. With --file, compiles a " +
649
- "plan JSON that has NOT been banked yet, so you can inspect exactly what an edited " +
650
- "plan would run before saving it. This returns the " +
651
- "executable spec. `tests plan` returns the stored JSON plan it is rendered from, " +
652
- "`tests get` the metadata row.",
569
+ summary: "Print the rendered Playwright spec for a test",
570
+ description: "Fetches the banked test's rendered .spec.ts: the executable spec. `tests plan` " +
571
+ "returns the stored JSON plan it is rendered from, `tests get` the metadata row.",
653
572
  scope: "project",
654
- args: [{ name: "test-id", description: "Test id (omit when passing --file)" }],
655
- flags: [
656
- { name: "file", type: "string", description: "Compile this plan JSON file instead of a banked test" },
657
- {
658
- name: "url-override",
659
- type: "string",
660
- description: "With --file: resolve relative gotos against this base URL",
661
- },
662
- { name: "env", type: "string", description: "With --file: environment id to render against" },
663
- ],
664
- examples: ["beryl tests script 4f…", "beryl tests script --file plan.json"],
573
+ args: [{ name: "test-id", description: "Test id", required: true }],
574
+ examples: ["beryl tests script 4f…"],
665
575
  async run(ctx, input) {
666
576
  const { workspaceId, projectId } = await ctx.requireProject(input);
667
- const id = input.args["test-id"];
668
- const file = flagStr(input, "file");
669
- if (Boolean(id) === Boolean(file))
670
- throw new UsageError("Pass exactly one of <test-id> or --file");
671
- if (file) {
672
- const data = (await ctx.client.post(`${projectPath(workspaceId, projectId)}/tests/compile`, {
673
- plan: readJsonFlag(input, "file"),
674
- base_url: flagStr(input, "url-override"),
675
- environment_id: flagStr(input, "env"),
676
- }));
677
- return { data: data, human: data.content };
678
- }
679
577
  const data = (await ctx.client.get(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/script`));
680
578
  const script = typeof data === "string" ? data : (data.script ?? data.content ?? data);
681
579
  return { data, human: typeof script === "string" ? script : JSON.stringify(data, null, 2) };
@@ -9,7 +9,8 @@ export const thirdPartyIntegrationCommands = [
9
9
  groupDefault: true,
10
10
  groupSummary: "Test a site's own integrations (Connect Slack) against Beryl's accounts on those providers.",
11
11
  description: "Lists the providers set up on this Beryl deployment. A test may use " +
12
- "`third_party_authorize` / `await_third_party` only for a provider listed here; " +
12
+ "`third_party_authorize` / `await_third_party` / `send_third_party` only for a " +
13
+ "provider listed here; " +
13
14
  "anything else is refused at save time.",
14
15
  scope: "project",
15
16
  async run(ctx, input) {
@@ -31,6 +32,8 @@ export const thirdPartyIntegrationCommands = [
31
32
  "channel in the app's settings; `await_third_party` then waits for the app's message " +
32
33
  "there. `recorded_app_ids` maps each environment id to the app whose first message " +
33
34
  "was seen there; an environment accepts only its own app once recorded. " +
35
+ "Pass `env` and `app` to record that app yourself (a Slack app id, `A...`), which " +
36
+ "`send_third_party` needs to know whose bot to mention. " +
34
37
  "A 409 means the provider is not set up on this deployment (see " +
35
38
  "`third-party-integrations list`).",
36
39
  scope: "project",
@@ -41,14 +44,34 @@ export const thirdPartyIntegrationCommands = [
41
44
  required: true,
42
45
  description: "Provider key from `third-party-integrations list`, e.g. slack",
43
46
  },
47
+ {
48
+ name: "env",
49
+ type: "string",
50
+ description: "Environment id to record `app` for (with `app`)",
51
+ },
52
+ {
53
+ name: "app",
54
+ type: "string",
55
+ description: "The connected app's id on the provider, e.g. a Slack app id (with `env`)",
56
+ },
57
+ ],
58
+ examples: [
59
+ "beryl third-party-integrations target --provider slack",
60
+ "beryl third-party-integrations target --provider slack --env <env-id> --app A0123ABCD",
44
61
  ],
45
- examples: ["beryl third-party-integrations target --provider slack"],
46
62
  async run(ctx, input) {
47
63
  const provider = flagStr(input, "provider");
48
64
  if (!provider)
49
65
  throw new UsageError("--provider is required");
66
+ const env = flagStr(input, "env");
67
+ const app = flagStr(input, "app");
68
+ if (Boolean(env) !== Boolean(app))
69
+ throw new UsageError("--env and --app go together");
50
70
  const { workspaceId, projectId } = await ctx.requireProject(input);
51
- const target = (await ctx.client.post(`${integrationsPath(workspaceId, projectId)}/${encodeURIComponent(provider)}/target`));
71
+ const base = `${integrationsPath(workspaceId, projectId)}/${encodeURIComponent(provider)}/target`;
72
+ const target = (env && app
73
+ ? await ctx.client.put(`${base}/apps/${encodeURIComponent(env)}`, { app_id: app })
74
+ : await ctx.client.post(base));
52
75
  return {
53
76
  data: target,
54
77
  human: `#${target.external_name}\n\n` +