@beryl-so/cli 0.60.0 → 0.61.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
@@ -334,6 +334,17 @@ The project's standing email addresses, where its tests receive sign-in mail.
334
334
  | `beryl mailbox read <mailbox-id>` | Read the latest email in a mailbox (waits for one to arrive) | `mailbox_read` |
335
335
  | `beryl mailbox emails <mailbox-id>` | List the emails a mailbox has received | `mailbox_emails` |
336
336
 
337
+ ### third-party-integrations
338
+
339
+ Test a site's own integrations (Connect Slack) against Beryl's accounts on those providers.
340
+
341
+ `beryl third-party-integrations` with no subcommand runs `third-party-integrations list`.
342
+
343
+ | Command | Summary | MCP tool |
344
+ | --- | --- | --- |
345
+ | `beryl third-party-integrations list` | The third party providers this project's tests can connect to | `third_party_integrations_list` |
346
+ | `beryl third-party-integrations target` | The channel this project's tests pick when the app asks where to post | `third_party_integrations_target` |
347
+
337
348
  ### accounts
338
349
 
339
350
  Durable identities on the site under test: what an authenticated test signs in as.
@@ -551,4 +551,31 @@ against your test's **intent**:
551
551
  So a test is *healable* exactly when it has **a strong intent + a real outcome
552
552
  assertion**. With a vague intent and a chrome-only assertion Beryl can't tell
553
553
  regression from cosmetic drift — it either heals over real breakage or fails on noise.
554
+
555
+ ## 7. Third party integration tests
556
+
557
+ An app's own integrations (its "Connect Slack" button, the message it then posts) are
558
+ testable against Beryl's account on that provider. Call
559
+ \`third_party_integrations_list\` first; if the provider is not listed, do not write the
560
+ test.
561
+
562
+ Two shapes:
563
+
564
+ - **Connect from scratch**: declare \`"third_party_integrations": ["slack"]\`, click the
565
+ app's Connect button, then \`{ "action": "third_party_authorize", "provider": "slack" }\`
566
+ approves the consent page and returns to the app. Pick the channel
567
+ \`third_party_integrations_target\` names, trigger the app's message, then
568
+ \`{ "action": "await_third_party", "provider": "slack", "expect_text": "..." }\`.
569
+ - **Integration intact**: no declaration. Trigger the app's message, then
570
+ \`await_third_party\`.
571
+
572
+ Rules:
573
+
574
+ - Always tear down what the test connected in \`after\`: disconnect the integration in
575
+ the app.
576
+ - These steps run only in Beryl's cloud: \`tests_create\` verifies server-side and
577
+ \`runs_local\` skips them, so run them with \`runs_trigger\`.
578
+ - A setup error saying Beryl's access "is being restored" is on Beryl's side: do not
579
+ edit the test.
580
+ - Never add steps that browse Slack itself; the actions above do all of it.
554
581
  `;
@@ -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, establishAccountSession, executeLocalSpec, IMPORT_MAX_ERROR_LEN, toRunEntry, } from "../local-exec.js";
5
+ import { buildImportForm, establishAccountSession, executeLocalSpec, IMPORT_MAX_ERROR_LEN, specUsesThirdParty, THIRD_PARTY_USE_TRIGGER, 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, installHint, installPlaywright, playwrightGaps, } from "../playwright-install.js";
@@ -152,7 +152,8 @@ export const runCommands = [
152
152
  "in the cloud: their account signs in once per invocation and every session-mode " +
153
153
  "test rides that session; a failed sign-in fails those tests with the same " +
154
154
  "SESSION_* reason a cloud run reports. Only a test that depends on a session Beryl " +
155
- "captured server-side is skipped, with a note. Run those with `runs trigger`. Point " +
155
+ "captured server-side, or that has third party integration steps (cloud only), is " +
156
+ "skipped, with a note. Run those with `runs trigger`. Point " +
156
157
  "--url-override at a local dev server or preview, and --dir to keep specs, artifacts, " +
157
158
  "and reports on disk. Exits 0 only if every executed test passed.",
158
159
  scope: "project",
@@ -255,6 +256,7 @@ export const runCommands = [
255
256
  requiresAuth: Boolean(script.requires_auth),
256
257
  usesEmail: /__vmInbox\(|__vmAwaitEmail\(/.test(script.content),
257
258
  usesLoginPassword: Boolean(script.uses_login_password),
259
+ usesThirdParty: specUsesThirdParty(script.content),
258
260
  inlinedLoginSteps: script.inlined_login_steps ?? 0,
259
261
  ...(script.session_mode && script.account_id
260
262
  ? { sessionMode: true, accountId: script.account_id }
@@ -288,13 +290,17 @@ export const runCommands = [
288
290
  // a cloud run's setup phase does. A test whose account is missing its password
289
291
  // would type a literal placeholder into the page: skip, with the server's fix-it
290
292
  // message.
291
- const skipped = specs.filter((s) => (s.requiresAuth && !(s.sessionMode && s.accountId)) || s.loginConfigError);
293
+ const skipped = specs.filter((s) => (s.requiresAuth && !(s.sessionMode && s.accountId)) ||
294
+ s.loginConfigError ||
295
+ s.usesThirdParty);
292
296
  const runnable = specs.filter((s) => !skipped.includes(s));
293
297
  for (const s of skipped) {
294
298
  ctx.err(yellow(s.loginConfigError
295
299
  ? `! ${s.title}: skipped — ${s.loginConfigError}`
296
- : `! ${s.title}: needs a session only Beryl's cloud holds — skipped ` +
297
- `(use \`beryl runs trigger\`).`));
300
+ : s.usesThirdParty
301
+ ? `! ${s.title}: skipped, ${THIRD_PARTY_USE_TRIGGER}.`
302
+ : `! ${s.title}: needs a session only Beryl's cloud holds, skipped ` +
303
+ `(use \`beryl runs trigger\`).`));
298
304
  }
299
305
  if (runnable.length === 0)
300
306
  throw new CliError("None of the selected tests can run locally.");
@@ -1,8 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import { CliError, UsageError } from "../errors.js";
3
3
  import { ApiError } from "../http.js";
4
- import { lintPlan } from "../lint.js";
5
- import { establishAccountSession, executeLocalSpec, } from "../local-exec.js";
4
+ import { lintPlan, planUsesThirdParty } from "../lint.js";
5
+ import { establishAccountSession, executeLocalSpec, THIRD_PARTY_CLOUD_ONLY, } 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, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
@@ -281,7 +281,8 @@ export const testCommands = [
281
281
  "tests is banked, run their ids together once (`runs local`, or `runs trigger` for the " +
282
282
  "cloud) so they land as ONE run with real first results. A plan that signs in with a session Beryl captured " +
283
283
  "server-side cannot replay locally (that session never leaves Beryl's cloud). It falls " +
284
- "back to server-side verification automatically, and says so. A session-mode plan replays locally " +
284
+ "back to server-side verification automatically, and says so; so does a plan with third party " +
285
+ "integration steps, which run only in Beryl's cloud. A session-mode plan replays locally " +
285
286
  "fine: the server renders it with its account's stored sign-in steps in front, so " +
286
287
  "the same identity is exercised on your machine. Optional `before` and `after` arrays hold setup and teardown " +
287
288
  "steps: `after` runs even when a main step fails, which is how a create/update/delete test " +
@@ -370,6 +371,23 @@ export const testCommands = [
370
371
  environment_id: env,
371
372
  });
372
373
  let compiled = await compile();
374
+ const verifyInCloud = async (reason) => {
375
+ ctx.err(yellow(`! ${reason}, so this plan is verified server-side instead.`));
376
+ try {
377
+ return { data: await bank({ verify: true, plan_hash: compiled.plan_hash }) };
378
+ }
379
+ catch (err) {
380
+ const failure = verifyFailureResult(err);
381
+ if (failure)
382
+ return failure;
383
+ const conflict = createConflictResult(err, compiled.plan_hash);
384
+ if (conflict)
385
+ return conflict;
386
+ throw err;
387
+ }
388
+ };
389
+ if (planUsesThirdParty(plan))
390
+ return verifyInCloud(THIRD_PARTY_CLOUD_ONLY);
373
391
  // Session-mode plan: establish the account's session once (server-side, reused
374
392
  // from the stored one when it still proves live) and replay against it — the
375
393
  // cloud shape. An unsupported account is re-compiled to sign itself in.
@@ -391,20 +409,7 @@ export const testCommands = [
391
409
  // A captured session lives encrypted in Beryl's cloud and is never handed to
392
410
  // this machine — the cloud is the only place this plan can be proven.
393
411
  if (compiled.requires_auth && !session) {
394
- ctx.err(yellow("! This plan signs in with a captured session, so it can only be verified " +
395
- "in Beryl's cloud — verifying server-side instead."));
396
- try {
397
- return { data: await bank({ verify: true, plan_hash: compiled.plan_hash }) };
398
- }
399
- catch (err) {
400
- const failure = verifyFailureResult(err);
401
- if (failure)
402
- return failure;
403
- const conflict = createConflictResult(err, compiled.plan_hash);
404
- if (conflict)
405
- return conflict;
406
- throw err;
407
- }
412
+ return verifyInCloud("A captured session never leaves Beryl's cloud");
408
413
  }
409
414
  if (compiled.login_config_error)
410
415
  throw new CliError(compiled.login_config_error);
@@ -0,0 +1,59 @@
1
+ import { UsageError } from "../errors.js";
2
+ import { dim, table } from "../output.js";
3
+ import { flagStr, projectPath } from "./util.js";
4
+ const integrationsPath = (ws, p) => `${projectPath(ws, p)}/third-party-integrations`;
5
+ export const thirdPartyIntegrationCommands = [
6
+ {
7
+ name: "third-party-integrations list",
8
+ summary: "The third party providers this project's tests can connect to",
9
+ groupDefault: true,
10
+ groupSummary: "Test a site's own integrations (Connect Slack) against Beryl's accounts on those providers.",
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; " +
13
+ "anything else is refused at save time.",
14
+ scope: "project",
15
+ async run(ctx, input) {
16
+ const { workspaceId, projectId } = await ctx.requireProject(input);
17
+ const { providers } = (await ctx.client.get(integrationsPath(workspaceId, projectId)));
18
+ return {
19
+ data: providers,
20
+ human: providers.length
21
+ ? table(providers.map((p) => ({ key: p.key, label: p.label })))
22
+ : dim("No third party providers are set up on this deployment."),
23
+ };
24
+ },
25
+ },
26
+ {
27
+ name: "third-party-integrations target",
28
+ summary: "The channel this project's tests pick when the app asks where to post",
29
+ description: "Returns the project's own destination on Beryl's provider account (for Slack, a " +
30
+ "channel), creating it on first ask. When a test connects the app, it picks this " +
31
+ "channel in the app's settings; `await_third_party` then waits for the app's message " +
32
+ "there. `recorded_app_id` is the app whose first message was seen, null until then. " +
33
+ "A 409 means the provider is not set up on this deployment (see " +
34
+ "`third-party-integrations list`).",
35
+ scope: "project",
36
+ flags: [
37
+ {
38
+ name: "provider",
39
+ type: "string",
40
+ required: true,
41
+ description: "Provider key from `third-party-integrations list`, e.g. slack",
42
+ },
43
+ ],
44
+ examples: ["beryl third-party-integrations target --provider slack"],
45
+ async run(ctx, input) {
46
+ const provider = flagStr(input, "provider");
47
+ if (!provider)
48
+ throw new UsageError("--provider is required");
49
+ const { workspaceId, projectId } = await ctx.requireProject(input);
50
+ const target = (await ctx.client.post(`${integrationsPath(workspaceId, projectId)}/${encodeURIComponent(provider)}/target`));
51
+ return {
52
+ data: target,
53
+ human: `#${target.external_name}\n\n` +
54
+ dim(`Pick this channel in the app when the test connects ${target.provider}.` +
55
+ (target.recorded_app_id ? ` Recorded app: ${target.recorded_app_id}` : "")),
56
+ };
57
+ },
58
+ },
59
+ ];
package/dist/lint.js CHANGED
@@ -16,6 +16,9 @@ export const WRITE_NEEDS_CLOSING_TEARDOWN = (detail) => "A 'write' test must end
16
16
  export const OPTIONAL_TEARDOWN_ACTION_MUST_BE_LAST = (label, action) => `${label} (${action}) is optional but not the last step of 'after'. An optional step ` +
17
17
  "that misses ends the teardown, so the steps after it would silently not run. Guard " +
18
18
  "them with an optional 'wait_for' or 'expect' instead, or move this step to the end.";
19
+ export const THIRD_PARTY_UNDECLARED = (p) => `third_party_authorize approves the install on ${p}'s consent page, which needs ` +
20
+ `Beryl's ${p} login: add "${p}" to third_party_integrations`;
21
+ export const THIRD_PARTY_UNUSED = (p) => `third_party_integrations declares "${p}" but no third_party_authorize step uses it; remove it`;
19
22
  const typeOf = (value) => {
20
23
  if (value === null)
21
24
  return "null";
@@ -174,6 +177,37 @@ function identityIssues(plan, provisioning) {
174
177
  ? [{ path: "(root)", message: MINTED_IDENTITY_NEEDS_TEARDOWN }]
175
178
  : [];
176
179
  }
180
+ export const THIRD_PARTY_AUTHORIZE = "third_party_authorize";
181
+ export const AWAIT_THIRD_PARTY = "await_third_party";
182
+ const executed = (plan) => [
183
+ ...steps(plan.before),
184
+ ...steps(plan.steps),
185
+ ...steps(plan.after),
186
+ ];
187
+ export function planUsesThirdParty(plan) {
188
+ if (typeof plan !== "object" || plan === null)
189
+ return false;
190
+ return executed(plan).some((s) => s.action === THIRD_PARTY_AUTHORIZE || s.action === AWAIT_THIRD_PARTY);
191
+ }
192
+ // Mirrors the server's ActionPlan validator, which raises the first sorted mismatch only.
193
+ function thirdPartyIssues(plan) {
194
+ if (typeof plan !== "object" || plan === null)
195
+ return [];
196
+ const record = plan;
197
+ const declared = new Set(Array.isArray(record.third_party_integrations)
198
+ ? record.third_party_integrations.map(String)
199
+ : []);
200
+ const authorized = new Set(executed(record)
201
+ .filter((s) => s.action === THIRD_PARTY_AUTHORIZE && typeof s.provider === "string")
202
+ .map((s) => s.provider));
203
+ const [undeclared] = [...authorized].filter((p) => !declared.has(p)).sort();
204
+ if (undeclared)
205
+ return [{ path: "(root)", message: THIRD_PARTY_UNDECLARED(undeclared) }];
206
+ const [unused] = [...declared].filter((p) => !authorized.has(p)).sort();
207
+ if (unused)
208
+ return [{ path: "third_party_integrations", message: THIRD_PARTY_UNUSED(unused) }];
209
+ return [];
210
+ }
177
211
  const TEARDOWN_PROOF_KINDS = new Set([
178
212
  "gone",
179
213
  "hidden",
@@ -212,6 +246,7 @@ export function lintPlan(plan, opts = {}) {
212
246
  if (issues.length > 0)
213
247
  return issues;
214
248
  return [
249
+ ...thirdPartyIssues(plan),
215
250
  ...identityIssues(plan, opts.provisioning === true),
216
251
  ...(opts.access === undefined ? [] : accessIssues(plan, opts.access)),
217
252
  ];
@@ -8,6 +8,11 @@ export const IMPORT_MAX_ERROR_LEN = 5000;
8
8
  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
+ export const THIRD_PARTY_CLOUD_ONLY = "third party integration steps run only in Beryl's cloud";
12
+ export const THIRD_PARTY_USE_TRIGGER = `${THIRD_PARTY_CLOUD_ONLY}; use \`beryl runs trigger\``;
13
+ // The rendered await_third_party helper, and the authorize step's wait to leave the provider's host.
14
+ const THIRD_PARTY_SPEC_MARKER = /__vmThirdParty\(|\.test\(u\.hostname\)/;
15
+ export const specUsesThirdParty = (content) => THIRD_PARTY_SPEC_MARKER.test(content);
11
16
  export function toRunEntry(spec, outcome, runError, startedAt, ordinal, redact) {
12
17
  // Mirrors the cloud runner's redact_result: the spec types secrets into the page
13
18
  // and an injected session authenticates its requests, so error text and the DOM
@@ -12,6 +12,7 @@ import { mcpCommands } from "../commands/mcp.js";
12
12
  import { projectCommands } from "../commands/projects.js";
13
13
  import { runCommands } from "../commands/runs.js";
14
14
  import { testCommands } from "../commands/tests.js";
15
+ import { thirdPartyIntegrationCommands } from "../commands/third-party-integrations.js";
15
16
  import { trackerCommands } from "../commands/tracker.js";
16
17
  import { workspaceCommands } from "../commands/workspaces.js";
17
18
  export const WORKSPACE_FLAG = {
@@ -70,6 +71,7 @@ export const commands = [
70
71
  ...explorationCommands,
71
72
  ...configCommands,
72
73
  ...mailboxCommands,
74
+ ...thirdPartyIntegrationCommands,
73
75
  ...testAccountCommands,
74
76
  ...accountCommands,
75
77
  ...mcpCommands,
@@ -22,7 +22,9 @@ export const ACTION_PLAN_SCHEMA = {
22
22
  "dialog",
23
23
  "switch_tab",
24
24
  "close_tab",
25
- "drag"
25
+ "drag",
26
+ "third_party_authorize",
27
+ "await_third_party"
26
28
  ],
27
29
  "title": "ActionType",
28
30
  "type": "string"
@@ -446,6 +448,54 @@ export const ACTION_PLAN_SCHEMA = {
446
448
  ]
447
449
  }
448
450
  },
451
+ {
452
+ "if": {
453
+ "properties": {
454
+ "action": {
455
+ "const": "third_party_authorize"
456
+ }
457
+ },
458
+ "required": [
459
+ "action"
460
+ ]
461
+ },
462
+ "then": {
463
+ "properties": {
464
+ "provider": {
465
+ "not": {
466
+ "type": "null"
467
+ }
468
+ }
469
+ },
470
+ "required": [
471
+ "provider"
472
+ ]
473
+ }
474
+ },
475
+ {
476
+ "if": {
477
+ "properties": {
478
+ "action": {
479
+ "const": "await_third_party"
480
+ }
481
+ },
482
+ "required": [
483
+ "action"
484
+ ]
485
+ },
486
+ "then": {
487
+ "properties": {
488
+ "provider": {
489
+ "not": {
490
+ "type": "null"
491
+ }
492
+ }
493
+ },
494
+ "required": [
495
+ "provider"
496
+ ]
497
+ }
498
+ },
449
499
  {
450
500
  "if": {
451
501
  "properties": {
@@ -901,6 +951,25 @@ export const ACTION_PLAN_SCHEMA = {
901
951
  "selector"
902
952
  ]
903
953
  }
954
+ },
955
+ {
956
+ "if": {
957
+ "properties": {
958
+ "action": {
959
+ "const": "await_email"
960
+ }
961
+ },
962
+ "required": [
963
+ "action"
964
+ ]
965
+ },
966
+ "then": {
967
+ "properties": {
968
+ "wait_s": {
969
+ "maximum": 50
970
+ }
971
+ }
972
+ }
904
973
  }
905
974
  ],
906
975
  "properties": {
@@ -1096,6 +1165,17 @@ export const ACTION_PLAN_SCHEMA = {
1096
1165
  "title": "Optional",
1097
1166
  "type": "boolean"
1098
1167
  },
1168
+ "provider": {
1169
+ "anyOf": [
1170
+ {
1171
+ "$ref": "#/$defs/ThirdPartyProvider"
1172
+ },
1173
+ {
1174
+ "type": "null"
1175
+ }
1176
+ ],
1177
+ "default": null
1178
+ },
1099
1179
  "selector": {
1100
1180
  "anyOf": [
1101
1181
  {
@@ -1164,7 +1244,7 @@ export const ACTION_PLAN_SCHEMA = {
1164
1244
  "wait_s": {
1165
1245
  "anyOf": [
1166
1246
  {
1167
- "maximum": 50,
1247
+ "maximum": 600,
1168
1248
  "minimum": 1,
1169
1249
  "type": "integer"
1170
1250
  },
@@ -1181,6 +1261,13 @@ export const ACTION_PLAN_SCHEMA = {
1181
1261
  ],
1182
1262
  "title": "PlanStep",
1183
1263
  "type": "object"
1264
+ },
1265
+ "ThirdPartyProvider": {
1266
+ "enum": [
1267
+ "slack"
1268
+ ],
1269
+ "title": "ThirdPartyProvider",
1270
+ "type": "string"
1184
1271
  }
1185
1272
  },
1186
1273
  "$id": "https://api.beryl.so/api/v1/schemas/action-plan.schema.json",
@@ -1344,6 +1431,14 @@ export const ACTION_PLAN_SCHEMA = {
1344
1431
  "minItems": 1,
1345
1432
  "title": "Steps",
1346
1433
  "type": "array"
1434
+ },
1435
+ "third_party_integrations": {
1436
+ "description": "Providers whose consent page this test approves; the browser then carries Beryl's login for them. Declare exactly the providers of its third_party_authorize steps.",
1437
+ "items": {
1438
+ "$ref": "#/$defs/ThirdPartyProvider"
1439
+ },
1440
+ "title": "Third Party Integrations",
1441
+ "type": "array"
1347
1442
  }
1348
1443
  },
1349
1444
  "required": [
@@ -36,6 +36,8 @@ export const ACTION_DOCS = {
36
36
  switch_tab: "Make another open tab the active page; `value` picks it — see below",
37
37
  close_tab: "Close the active tab (optional `value` picks one) and fall back to the previous",
38
38
  drag: "Drag the `selector` element onto the `value` TARGET selector",
39
+ third_party_authorize: "Approve the `provider` install on its consent page as Beryl's account (cloud only, see third party integration tests)",
40
+ await_third_party: "Wait for the connected app's message on Beryl's `provider` account (cloud only)",
39
41
  };
40
42
  export const EXPECT_DOCS = {
41
43
  visible: "The element is visible",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.60.0",
3
+ "version": "0.61.0",
4
4
  "description": "Beryl on the command line: projects, runs, the exploring agent, and an MCP server over the same commands.",
5
5
  "license": "MIT",
6
6
  "type": "module",