@beryl-so/cli 0.60.0 → 0.62.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.
- package/README.md +11 -0
- package/dist/adapters/cli.js +12 -6
- package/dist/adapters/mcp.js +12 -12
- package/dist/beryl-test-skill.js +27 -0
- package/dist/commands/explorations.js +2 -1
- package/dist/commands/health.js +4 -2
- package/dist/commands/init.js +16 -18
- package/dist/commands/runs.js +38 -17
- package/dist/commands/tests.js +22 -17
- package/dist/commands/third-party-integrations.js +62 -0
- package/dist/commands/watch.js +24 -8
- package/dist/config.js +27 -3
- package/dist/http.js +5 -1
- package/dist/lint.js +35 -0
- package/dist/local-exec.js +5 -0
- package/dist/registry/index.js +2 -0
- package/dist/schema.generated.js +97 -2
- package/dist/skill-tables.js +2 -0
- package/dist/sse.js +1 -1
- package/package.json +1 -1
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.
|
package/dist/adapters/cli.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadConfig } from "../config.js";
|
|
1
|
+
import { loadConfig, withFlagOverrides } from "../config.js";
|
|
2
2
|
import { createContext } from "../context.js";
|
|
3
3
|
import { CliError, EXIT_OK, EXIT_USAGE, UsageError } from "../errors.js";
|
|
4
4
|
import { ApiClient } from "../http.js";
|
|
@@ -317,11 +317,17 @@ export async function runCli(argv) {
|
|
|
317
317
|
process.stdout.write(commandHelp(spec) + "\n");
|
|
318
318
|
return EXIT_OK;
|
|
319
319
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
config.apiUrl
|
|
323
|
-
|
|
324
|
-
|
|
320
|
+
let config;
|
|
321
|
+
try {
|
|
322
|
+
config = withFlagOverrides(loadConfig(), { apiUrl: parsed.apiUrl, token: parsed.token });
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
if (err instanceof CliError) {
|
|
326
|
+
process.stderr.write(err.message + "\n");
|
|
327
|
+
return err.exitCode;
|
|
328
|
+
}
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
325
331
|
const client = new ApiClient(config.apiUrl, config.token);
|
|
326
332
|
const ctx = createContext({ client, config, json: parsed.json, mcp: false });
|
|
327
333
|
// The MCP server reports its own tool calls; every other invocation is one command.
|
package/dist/adapters/mcp.js
CHANGED
|
@@ -3,7 +3,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
3
3
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
4
4
|
import { instrument } from "@posthog/mcp";
|
|
5
5
|
import fs from "node:fs";
|
|
6
|
-
import { loadConfig } from "../config.js";
|
|
6
|
+
import { loadConfig, withFlagOverrides } from "../config.js";
|
|
7
7
|
import { createContext } from "../context.js";
|
|
8
8
|
import { AuthError, CliError } from "../errors.js";
|
|
9
9
|
import { ApiClient, ApiError } from "../http.js";
|
|
@@ -222,7 +222,7 @@ export function currentAuth(fallback) {
|
|
|
222
222
|
}
|
|
223
223
|
if (authCache?.mtimeMs === mtimeMs)
|
|
224
224
|
return authCache.auth;
|
|
225
|
-
const config = loadConfig();
|
|
225
|
+
const config = withFlagOverrides(loadConfig(), fallback.config.flagOverrides ?? {});
|
|
226
226
|
const auth = {
|
|
227
227
|
client: new ApiClient(config.apiUrl, config.token),
|
|
228
228
|
config,
|
|
@@ -330,17 +330,17 @@ export async function serveMcp(baseCtx) {
|
|
|
330
330
|
if (lines.length > 400)
|
|
331
331
|
lines.splice(0, lines.length - 400);
|
|
332
332
|
};
|
|
333
|
-
const auth = currentAuth(baseCtx);
|
|
334
|
-
const ctx = createContext({
|
|
335
|
-
client: auth.client,
|
|
336
|
-
config: auth.config,
|
|
337
|
-
json: true,
|
|
338
|
-
interactive: false,
|
|
339
|
-
mcp: true,
|
|
340
|
-
out: push,
|
|
341
|
-
err: push,
|
|
342
|
-
});
|
|
343
333
|
try {
|
|
334
|
+
const auth = currentAuth(baseCtx);
|
|
335
|
+
const ctx = createContext({
|
|
336
|
+
client: auth.client,
|
|
337
|
+
config: auth.config,
|
|
338
|
+
json: true,
|
|
339
|
+
interactive: false,
|
|
340
|
+
mcp: true,
|
|
341
|
+
out: push,
|
|
342
|
+
err: push,
|
|
343
|
+
});
|
|
344
344
|
const input = toInput(spec, request.params.arguments ?? {});
|
|
345
345
|
const result = (await withTool(request.params.name, () => spec.run(ctx, input))) ?? {};
|
|
346
346
|
return toolResult(result, lines);
|
package/dist/beryl-test-skill.js
CHANGED
|
@@ -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
|
`;
|
|
@@ -53,7 +53,8 @@ export const explorationCommands = [
|
|
|
53
53
|
name: "explorations watch",
|
|
54
54
|
summary: "Stream an exploration live: watch the agent explore and author tests",
|
|
55
55
|
description: "Replays every recorded step on connect, then follows live until the exploration " +
|
|
56
|
-
"
|
|
56
|
+
"ends. Exits 0 only if it completed; a failed or cancelled exploration, or a stream " +
|
|
57
|
+
"that keeps closing without a final status, exits 1.",
|
|
57
58
|
scope: "project",
|
|
58
59
|
args: [{ name: "exploration-id", description: "Exploration id", required: true }],
|
|
59
60
|
flags: [{ name: "timeout", type: "number", description: "Max minutes to wait" }],
|
package/dist/commands/health.js
CHANGED
|
@@ -275,7 +275,8 @@ export const healthCommands = [
|
|
|
275
275
|
"`selector`, measured values, the `viewports` it shows at and a fix hint in " +
|
|
276
276
|
"`recommendation`. The `performance` category grades Google PageSpeed on mobile " +
|
|
277
277
|
"and on desktop separately (`speeds`, each with its own Core Web Vitals) and takes " +
|
|
278
|
-
"the worse of the two; one that couldn't be measured is left out.
|
|
278
|
+
"the worse of the two; one that couldn't be measured is left out. A category's `unchecked` lists checks this run " +
|
|
279
|
+
"couldn't determine (e.g. robots.txt timed out): they neither pass nor fail. Pass --report for a single-page check started with " +
|
|
279
280
|
"`health run --path`.",
|
|
280
281
|
scope: "project",
|
|
281
282
|
flags: [
|
|
@@ -321,7 +322,8 @@ export const healthCommands = [
|
|
|
321
322
|
"With --ci it starts a CI run, waits for every page, prints a Markdown summary you can " +
|
|
322
323
|
"post as the PR comment, and exits 1 when findings at or above the environment's " +
|
|
323
324
|
"fail-on severity exist, a page couldn't be checked, or the run failed. Builds that the " +
|
|
324
|
-
"environment's CI mode doesn't cover skip and exit 0
|
|
325
|
+
"environment's CI mode doesn't cover skip and exit 0; if the stored run settings are invalid it exits 1 " +
|
|
326
|
+
"rather than skipping. CI runs file tracker tickets only when --health-ci-tickets is on.",
|
|
325
327
|
scope: "project",
|
|
326
328
|
flags: [
|
|
327
329
|
ENV_FLAG,
|
package/dist/commands/init.js
CHANGED
|
@@ -77,26 +77,21 @@ function mergeMcpConfig(file, key, entries) {
|
|
|
77
77
|
}
|
|
78
78
|
return { beryl, playwright };
|
|
79
79
|
}
|
|
80
|
-
// A tool's config lives per-user (same as the PAT it needs, in ~/.config/beryl) — so wire the
|
|
81
|
-
// MCP servers per-user too, not in a committed .mcp.json that 401s for every teammate until
|
|
82
|
-
// they run `beryl login` anyway. We DON'T hand-edit ~/.claude.json (Claude Code owns it; a
|
|
83
|
-
// corrupt write breaks the user's whole CLI) — we shell out to `claude mcp add` and let Claude
|
|
84
|
-
// own the edit. Returns false when the `claude` binary isn't on PATH so the caller can fall
|
|
85
|
-
// back to a copy-paste command.
|
|
86
80
|
function claudeUserAdd(name, entry) {
|
|
87
81
|
try {
|
|
88
82
|
execFileSync("claude", ["mcp", "add", name, "-s", "user", "--", entry.command, ...entry.args], {
|
|
89
|
-
stdio: "ignore",
|
|
83
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
90
84
|
});
|
|
91
|
-
return true;
|
|
85
|
+
return { ok: true };
|
|
92
86
|
}
|
|
93
87
|
catch (err) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
88
|
+
const e = err;
|
|
89
|
+
if (e.code === "ENOENT")
|
|
90
|
+
return { ok: false, reason: "missing" };
|
|
91
|
+
const output = `${e.stderr ?? ""}\n${e.stdout ?? ""}`.trim();
|
|
92
|
+
if (/already exists/i.test(output))
|
|
93
|
+
return { ok: true };
|
|
94
|
+
return { ok: false, reason: "failed", detail: output.split("\n")[0] || e.message };
|
|
100
95
|
}
|
|
101
96
|
}
|
|
102
97
|
function claudeAddHint(name, entry) {
|
|
@@ -399,13 +394,16 @@ export const initCommands = [
|
|
|
399
394
|
const clients = await selectClients(ctx, input.flags);
|
|
400
395
|
if (clients.has("claude") && scope === "user") {
|
|
401
396
|
// Claude Code owns ~/.claude.json — shell out to `claude mcp add` rather than write it.
|
|
402
|
-
const
|
|
403
|
-
const
|
|
404
|
-
if (
|
|
397
|
+
const beryl = claudeUserAdd("beryl", MCP_SERVER_ENTRY);
|
|
398
|
+
const playwright = claudeUserAdd("playwright", PLAYWRIGHT_SERVER_ENTRY);
|
|
399
|
+
if (beryl.ok && playwright.ok) {
|
|
405
400
|
ctx.err(`${green("✓")} beryl + playwright MCP configured ${dim("(user scope)")}`);
|
|
406
401
|
}
|
|
407
402
|
else {
|
|
408
|
-
|
|
403
|
+
const failure = [beryl, playwright].find((r) => !r.ok && r.reason === "failed");
|
|
404
|
+
ctx.err(yellow(failure
|
|
405
|
+
? `• \`claude mcp add\` failed (${failure.detail}); run these to wire user-scope MCP servers:`
|
|
406
|
+
: "• `claude` not on PATH; run these to wire user-scope MCP servers:"));
|
|
409
407
|
ctx.err(` ${cyan(claudeAddHint("beryl", MCP_SERVER_ENTRY))}`);
|
|
410
408
|
ctx.err(` ${cyan(claudeAddHint("playwright", PLAYWRIGHT_SERVER_ENTRY))}`);
|
|
411
409
|
}
|
package/dist/commands/runs.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { downloadRunArtifacts, failureImages, isFailing, resultsOf, } from "../artifacts.js";
|
|
4
|
-
import { CliError, UsageError } from "../errors.js";
|
|
5
|
-
import {
|
|
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";
|
|
6
7
|
import { countPlannedFrames, countWrittenFrames, PlaywrightMissingError, } from "../local-run.js";
|
|
7
8
|
import { dim, green, red, yellow } from "../output.js";
|
|
8
9
|
import { anyGap, confirmInstall, describeGaps, installCommandsFor, installHint, installPlaywright, playwrightGaps, } from "../playwright-install.js";
|
|
@@ -152,9 +153,12 @@ export const runCommands = [
|
|
|
152
153
|
"in the cloud: their account signs in once per invocation and every session-mode " +
|
|
153
154
|
"test rides that session; a failed sign-in fails those tests with the same " +
|
|
154
155
|
"SESSION_* reason a cloud run reports. Only a test that depends on a session Beryl " +
|
|
155
|
-
"captured server-side
|
|
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 " +
|
|
156
158
|
"--url-override at a local dev server or preview, and --dir to keep specs, artifacts, " +
|
|
157
|
-
"and reports on disk.
|
|
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.",
|
|
158
162
|
scope: "project",
|
|
159
163
|
args: [
|
|
160
164
|
{
|
|
@@ -255,6 +259,7 @@ export const runCommands = [
|
|
|
255
259
|
requiresAuth: Boolean(script.requires_auth),
|
|
256
260
|
usesEmail: /__vmInbox\(|__vmAwaitEmail\(/.test(script.content),
|
|
257
261
|
usesLoginPassword: Boolean(script.uses_login_password),
|
|
262
|
+
usesThirdParty: specUsesThirdParty(script.content),
|
|
258
263
|
inlinedLoginSteps: script.inlined_login_steps ?? 0,
|
|
259
264
|
...(script.session_mode && script.account_id
|
|
260
265
|
? { sessionMode: true, accountId: script.account_id }
|
|
@@ -263,38 +268,51 @@ export const runCommands = [
|
|
|
263
268
|
...(script.login_config_error ? { loginConfigError: script.login_config_error } : {}),
|
|
264
269
|
});
|
|
265
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 = [];
|
|
266
275
|
for (const id of ids) {
|
|
267
276
|
let script;
|
|
268
277
|
try {
|
|
269
278
|
script = await fetchScript(id);
|
|
270
279
|
}
|
|
271
280
|
catch (err) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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) {
|
|
276
285
|
ctx.err(yellow(`! ${titles.get(id) ?? id}: no runnable script — skipped (${detail})`));
|
|
277
|
-
continue;
|
|
278
286
|
}
|
|
279
|
-
|
|
287
|
+
else {
|
|
288
|
+
fetchFailed.push(id);
|
|
289
|
+
ctx.err(red(`✗ ${titles.get(id) ?? id}: could not fetch its script (${detail})`));
|
|
290
|
+
}
|
|
291
|
+
continue;
|
|
280
292
|
}
|
|
281
293
|
specs.push(toLocalSpec(id, script));
|
|
282
294
|
}
|
|
283
295
|
if (specs.length === 0)
|
|
284
|
-
throw new CliError(
|
|
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.");
|
|
285
299
|
// A test that leans on a session this CLI cannot establish gets no session here,
|
|
286
300
|
// so skip it rather than run a spec doomed at the login wall. A session-mode test
|
|
287
301
|
// is NOT in that set: its account's session is established below, once, exactly as
|
|
288
302
|
// a cloud run's setup phase does. A test whose account is missing its password
|
|
289
303
|
// would type a literal placeholder into the page: skip, with the server's fix-it
|
|
290
304
|
// message.
|
|
291
|
-
const skipped = specs.filter((s) => (s.requiresAuth && !(s.sessionMode && s.accountId)) ||
|
|
305
|
+
const skipped = specs.filter((s) => (s.requiresAuth && !(s.sessionMode && s.accountId)) ||
|
|
306
|
+
s.loginConfigError ||
|
|
307
|
+
s.usesThirdParty);
|
|
292
308
|
const runnable = specs.filter((s) => !skipped.includes(s));
|
|
293
309
|
for (const s of skipped) {
|
|
294
310
|
ctx.err(yellow(s.loginConfigError
|
|
295
311
|
? `! ${s.title}: skipped — ${s.loginConfigError}`
|
|
296
|
-
:
|
|
297
|
-
|
|
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\`).`));
|
|
298
316
|
}
|
|
299
317
|
if (runnable.length === 0)
|
|
300
318
|
throw new CliError("None of the selected tests can run locally.");
|
|
@@ -493,6 +511,9 @@ export const runCommands = [
|
|
|
493
511
|
: red(`${failed} test(s) failed`) + ` (${passed} passed)`;
|
|
494
512
|
const human = `${summaryLine}` +
|
|
495
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
|
+
: "") +
|
|
496
517
|
(runId ? `\nSynced to Beryl as run ${runId} — \`beryl runs get ${runId}\`` : "") +
|
|
497
518
|
(!sync ? `\n${dim("--no-sync: nothing recorded in Beryl")}` : "") +
|
|
498
519
|
(dir ? `\n${dim(`Specs + artifacts + reports under ${path.resolve(dir)}`)}` : "");
|
|
@@ -500,13 +521,13 @@ export const runCommands = [
|
|
|
500
521
|
data: {
|
|
501
522
|
passed,
|
|
502
523
|
failed,
|
|
503
|
-
skipped: skipped.map((s) => s.id),
|
|
524
|
+
skipped: [...skipped.map((s) => s.id), ...fetchFailed],
|
|
504
525
|
results: entries.map(({ files: _files, ...rest }) => rest),
|
|
505
526
|
...(runId ? { run_id: runId } : {}),
|
|
506
527
|
},
|
|
507
528
|
human,
|
|
508
|
-
// Exit codes are a CI contract: any failing/errored test → exit 1.
|
|
509
|
-
...(failed > 0 ? { exitCode: 1 } : {}),
|
|
529
|
+
// Exit codes are a CI contract: any failing/errored or unfetchable test → exit 1.
|
|
530
|
+
...(failed > 0 || fetchFailed.length > 0 ? { exitCode: 1 } : {}),
|
|
510
531
|
};
|
|
511
532
|
},
|
|
512
533
|
},
|
package/dist/commands/tests.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,62 @@
|
|
|
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_ids` maps each environment id to the app whose first message " +
|
|
33
|
+
"was seen there; an environment accepts only its own app once recorded. " +
|
|
34
|
+
"A 409 means the provider is not set up on this deployment (see " +
|
|
35
|
+
"`third-party-integrations list`).",
|
|
36
|
+
scope: "project",
|
|
37
|
+
flags: [
|
|
38
|
+
{
|
|
39
|
+
name: "provider",
|
|
40
|
+
type: "string",
|
|
41
|
+
required: true,
|
|
42
|
+
description: "Provider key from `third-party-integrations list`, e.g. slack",
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
examples: ["beryl third-party-integrations target --provider slack"],
|
|
46
|
+
async run(ctx, input) {
|
|
47
|
+
const provider = flagStr(input, "provider");
|
|
48
|
+
if (!provider)
|
|
49
|
+
throw new UsageError("--provider is required");
|
|
50
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
51
|
+
const target = (await ctx.client.post(`${integrationsPath(workspaceId, projectId)}/${encodeURIComponent(provider)}/target`));
|
|
52
|
+
return {
|
|
53
|
+
data: target,
|
|
54
|
+
human: `#${target.external_name}\n\n` +
|
|
55
|
+
dim(`Pick this channel in the app when the test connects ${target.provider}.`) +
|
|
56
|
+
Object.entries(target.recorded_app_ids ?? {})
|
|
57
|
+
.map(([env, app]) => dim(`\nRecorded app for environment ${env}: ${app}`))
|
|
58
|
+
.join(""),
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
];
|
package/dist/commands/watch.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import { CliError } from "../errors.js";
|
|
2
|
+
import { AuthError, CliError } from "../errors.js";
|
|
3
|
+
import { ApiError, NonJsonResponseError } from "../http.js";
|
|
3
4
|
import { sseStream } from "../sse.js";
|
|
4
5
|
import { dim, green, red, statusColor, yellow } from "../output.js";
|
|
5
6
|
import { projectPath } from "./util.js";
|
|
@@ -34,6 +35,15 @@ function runPageUrl(apiUrl, projectId, runId) {
|
|
|
34
35
|
}
|
|
35
36
|
const escapeAnnotationData = (s) => s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
36
37
|
const escapeAnnotationProp = (s) => escapeAnnotationData(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
|
|
38
|
+
// Auth, client and wrong-host errors fail identically on every retry; only a network
|
|
39
|
+
// blip, a timeout, throttling or a 5xx is worth waiting out.
|
|
40
|
+
function isRetryable(e) {
|
|
41
|
+
if (e instanceof AuthError || e instanceof NonJsonResponseError)
|
|
42
|
+
return false;
|
|
43
|
+
if (e instanceof ApiError)
|
|
44
|
+
return e.status >= 500 || e.status === 408 || e.status === 429;
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
37
47
|
function sleep(ms) {
|
|
38
48
|
return new Promise((r) => setTimeout(r, ms));
|
|
39
49
|
}
|
|
@@ -97,9 +107,10 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes, opts = {
|
|
|
97
107
|
backoffMs = 2_000;
|
|
98
108
|
}
|
|
99
109
|
catch (e) {
|
|
110
|
+
if (!isRetryable(e))
|
|
111
|
+
throw e;
|
|
100
112
|
clearStatusLine();
|
|
101
|
-
|
|
102
|
-
ctx.err(dim(`watch request failed (${e.message}); retrying`));
|
|
113
|
+
ctx.err(dim(`watch request failed (${e.message}); retrying`));
|
|
103
114
|
await sleep(backoffMs);
|
|
104
115
|
backoffMs = Math.min(backoffMs * 2, 30_000);
|
|
105
116
|
continue;
|
|
@@ -118,7 +129,11 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes, opts = {
|
|
|
118
129
|
run = (await ctx.client.get(`${path}/runs/${runId}`));
|
|
119
130
|
backoffMs = 2_000;
|
|
120
131
|
}
|
|
121
|
-
catch {
|
|
132
|
+
catch (e) {
|
|
133
|
+
if (!isRetryable(e))
|
|
134
|
+
throw e;
|
|
135
|
+
clearStatusLine();
|
|
136
|
+
ctx.err(dim(`run detail request failed (${e.message}); retrying`));
|
|
122
137
|
// Same backoff as the poll: a terminal run answers the next poll
|
|
123
138
|
// instantly, so retrying without a delay would busy-loop both endpoints.
|
|
124
139
|
await sleep(backoffMs);
|
|
@@ -148,9 +163,10 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes, opts = {
|
|
|
148
163
|
try {
|
|
149
164
|
await ctx.client.post(`${path}/runs/${runId}/cancel`);
|
|
150
165
|
}
|
|
151
|
-
catch {
|
|
152
|
-
//
|
|
153
|
-
//
|
|
166
|
+
catch (e) {
|
|
167
|
+
// The failure that triggered the cancel decides the exit code; this only
|
|
168
|
+
// warns that the run may still be consuming minutes.
|
|
169
|
+
ctx.err(yellow(`fail-fast: could not cancel run ${runId} (${e.message})`));
|
|
154
170
|
}
|
|
155
171
|
break;
|
|
156
172
|
}
|
|
@@ -279,7 +295,7 @@ export async function watchExploration(ctx, ws, project, explorationId, timeoutM
|
|
|
279
295
|
}
|
|
280
296
|
return {
|
|
281
297
|
data: { exploration_id: explorationId, status: finalStatus, test_run_id: handedOffRunId, error },
|
|
282
|
-
exitCode: finalStatus === "
|
|
298
|
+
exitCode: finalStatus === "completed" ? 0 : 1,
|
|
283
299
|
};
|
|
284
300
|
}
|
|
285
301
|
export async function pollCurrentExploration(ctx, ws, project, timeoutSeconds = 180) {
|
package/dist/config.js
CHANGED
|
@@ -2,20 +2,44 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { CliError } from "./errors.js";
|
|
5
6
|
export const DEFAULT_API_URL = "https://api.beryl.so";
|
|
7
|
+
export function withFlagOverrides(config, flags) {
|
|
8
|
+
return {
|
|
9
|
+
...config,
|
|
10
|
+
...(flags.apiUrl ? { apiUrl: flags.apiUrl } : {}),
|
|
11
|
+
...(flags.token ? { token: flags.token } : {}),
|
|
12
|
+
flagOverrides: flags,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
6
15
|
export function globalConfigPath(env = process.env) {
|
|
7
16
|
const base = env.BERYL_CONFIG_DIR ??
|
|
8
17
|
(env.XDG_CONFIG_HOME ? path.join(env.XDG_CONFIG_HOME, "beryl") : undefined) ??
|
|
9
18
|
path.join(os.homedir(), ".config", "beryl");
|
|
10
19
|
return path.join(base, "config.json");
|
|
11
20
|
}
|
|
21
|
+
// Only a missing file means "no config". Anything else must stop the command: treating
|
|
22
|
+
// it as empty would drop the token and api_url, then the next write would overwrite it.
|
|
12
23
|
function readJson(file) {
|
|
24
|
+
let text;
|
|
13
25
|
try {
|
|
14
|
-
|
|
26
|
+
text = fs.readFileSync(file, "utf8");
|
|
15
27
|
}
|
|
16
|
-
catch {
|
|
17
|
-
|
|
28
|
+
catch (err) {
|
|
29
|
+
if (err.code === "ENOENT")
|
|
30
|
+
return undefined;
|
|
31
|
+
throw new CliError(`Could not read ${file}: ${err.message}`);
|
|
32
|
+
}
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(text);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
throw new CliError(`${file} is not valid JSON (${err.message}). Fix or delete it, then retry.`);
|
|
18
39
|
}
|
|
40
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
41
|
+
throw new CliError(`${file} must hold a JSON object. Fix or delete it, then retry.`);
|
|
42
|
+
return parsed;
|
|
19
43
|
}
|
|
20
44
|
export function loadConfig(env = process.env) {
|
|
21
45
|
const globalPath = globalConfigPath(env);
|
package/dist/http.js
CHANGED
|
@@ -11,6 +11,8 @@ export class ApiError extends CliError {
|
|
|
11
11
|
this.detail = detail;
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
+
export class NonJsonResponseError extends CliError {
|
|
15
|
+
}
|
|
14
16
|
export class ApiClient {
|
|
15
17
|
baseUrl;
|
|
16
18
|
token;
|
|
@@ -85,7 +87,9 @@ export class ApiClient {
|
|
|
85
87
|
return JSON.parse(text);
|
|
86
88
|
}
|
|
87
89
|
catch {
|
|
88
|
-
|
|
90
|
+
const type = response.headers.get("content-type") ?? "no content-type";
|
|
91
|
+
throw new NonJsonResponseError(`${this.baseUrl} answered ${method} ${path} with non-JSON (${type}); ` +
|
|
92
|
+
"is the API URL pointing at the Beryl API?");
|
|
89
93
|
}
|
|
90
94
|
}
|
|
91
95
|
get(path, query) {
|
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
|
];
|
package/dist/local-exec.js
CHANGED
|
@@ -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
|
package/dist/registry/index.js
CHANGED
|
@@ -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,
|
package/dist/schema.generated.js
CHANGED
|
@@ -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":
|
|
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": [
|
package/dist/skill-tables.js
CHANGED
|
@@ -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/dist/sse.js
CHANGED
|
@@ -69,7 +69,7 @@ export async function* sseStream(client, path, signal) {
|
|
|
69
69
|
// Clean server-side close without a terminal event: reconnect and replay.
|
|
70
70
|
failures += 1;
|
|
71
71
|
if (failures >= MAX_CONSECUTIVE_FAILURES)
|
|
72
|
-
|
|
72
|
+
throw new CliError(`Stream closed ${failures} times without a terminal event; its status is unknown`);
|
|
73
73
|
await sleep(1000, signal);
|
|
74
74
|
}
|
|
75
75
|
catch (err) {
|
package/package.json
CHANGED