@beryl-so/cli 0.61.0 → 0.63.2
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/dist/adapters/cli.js +12 -6
- package/dist/adapters/mcp.js +12 -12
- package/dist/beryl-test-skill.js +5 -3
- 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 +27 -12
- package/dist/commands/tests.js +1 -1
- package/dist/commands/third-party-integrations.js +6 -3
- package/dist/commands/watch.js +24 -8
- package/dist/commands/workspaces.js +2 -2
- package/dist/config.js +27 -3
- package/dist/http.js +5 -1
- package/dist/lint.js +57 -2
- package/dist/sse.js +1 -1
- package/package.json +1 -1
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
|
@@ -198,14 +198,16 @@ The trade is drift — run 200 has 200 of everything run 1 created. So:
|
|
|
198
198
|
uploads or toggles a record or setting that is still different once the test ends. A
|
|
199
199
|
\`write\` must undo itself: end \`after\` with an \`expect\` proving the undo: \`gone\`,
|
|
200
200
|
\`hidden\`, \`have_count\` or \`count_delta\` for a removal, \`have_text\`,
|
|
201
|
-
\`have_value\` or \`persisted\` for a restore.
|
|
201
|
+
\`have_value\` or \`persisted\` for a restore. Type what it creates with
|
|
202
|
+
\`{{unique}}\` (\`Report {{unique}}\`) and target that same name in \`after\` and in
|
|
203
|
+
the proof: a teardown or count that targets a fixed typed name is refused.
|
|
202
204
|
- **\`read\`** = it only observes: navigating, searching, filtering, sorting, opening a
|
|
203
205
|
menu or dialog and reading it, or clicking a destructive button and then CANCELLING.
|
|
204
206
|
Nothing is committed. Opening a confirm dialog and cancelling is a \`read\`; a submit
|
|
205
207
|
that stays disabled is a \`read\`.
|
|
206
208
|
A flow that both changes and reads is a \`write\`. Only the \`write\` label is checked
|
|
207
|
-
(its closing teardown proof, and a \`write\` whose teardown
|
|
208
|
-
run); \`read\` is taken at your word, so a real write you mislabel \`read\` silently
|
|
209
|
+
(its closing teardown proof and its \`{{unique}}\` names, and a \`write\` whose teardown
|
|
210
|
+
removes nothing fails the run); \`read\` is taken at your word, so a real write you mislabel \`read\` silently
|
|
209
211
|
drifts the account. Label deliberately, and flip a banked test with \`tests_set_access\`.
|
|
210
212
|
|
|
211
213
|
### If there is no account yet
|
|
@@ -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,7 +1,8 @@
|
|
|
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";
|
|
4
|
+
import { AuthError, CliError, UsageError } from "../errors.js";
|
|
5
|
+
import { ApiError } from "../http.js";
|
|
5
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";
|
|
@@ -155,7 +156,9 @@ export const runCommands = [
|
|
|
155
156
|
"captured server-side, or that has third party integration steps (cloud only), is " +
|
|
156
157
|
"skipped, with a note. Run those with `runs trigger`. Point " +
|
|
157
158
|
"--url-override at a local dev server or preview, and --dir to keep specs, artifacts, " +
|
|
158
|
-
"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.",
|
|
159
162
|
scope: "project",
|
|
160
163
|
args: [
|
|
161
164
|
{
|
|
@@ -265,25 +268,34 @@ export const runCommands = [
|
|
|
265
268
|
...(script.login_config_error ? { loginConfigError: script.login_config_error } : {}),
|
|
266
269
|
});
|
|
267
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 = [];
|
|
268
275
|
for (const id of ids) {
|
|
269
276
|
let script;
|
|
270
277
|
try {
|
|
271
278
|
script = await fetchScript(id);
|
|
272
279
|
}
|
|
273
280
|
catch (err) {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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) {
|
|
278
285
|
ctx.err(yellow(`! ${titles.get(id) ?? id}: no runnable script — skipped (${detail})`));
|
|
279
|
-
continue;
|
|
280
286
|
}
|
|
281
|
-
|
|
287
|
+
else {
|
|
288
|
+
fetchFailed.push(id);
|
|
289
|
+
ctx.err(red(`✗ ${titles.get(id) ?? id}: could not fetch its script (${detail})`));
|
|
290
|
+
}
|
|
291
|
+
continue;
|
|
282
292
|
}
|
|
283
293
|
specs.push(toLocalSpec(id, script));
|
|
284
294
|
}
|
|
285
295
|
if (specs.length === 0)
|
|
286
|
-
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.");
|
|
287
299
|
// A test that leans on a session this CLI cannot establish gets no session here,
|
|
288
300
|
// so skip it rather than run a spec doomed at the login wall. A session-mode test
|
|
289
301
|
// is NOT in that set: its account's session is established below, once, exactly as
|
|
@@ -499,6 +511,9 @@ export const runCommands = [
|
|
|
499
511
|
: red(`${failed} test(s) failed`) + ` (${passed} passed)`;
|
|
500
512
|
const human = `${summaryLine}` +
|
|
501
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
|
+
: "") +
|
|
502
517
|
(runId ? `\nSynced to Beryl as run ${runId} — \`beryl runs get ${runId}\`` : "") +
|
|
503
518
|
(!sync ? `\n${dim("--no-sync: nothing recorded in Beryl")}` : "") +
|
|
504
519
|
(dir ? `\n${dim(`Specs + artifacts + reports under ${path.resolve(dir)}`)}` : "");
|
|
@@ -506,13 +521,13 @@ export const runCommands = [
|
|
|
506
521
|
data: {
|
|
507
522
|
passed,
|
|
508
523
|
failed,
|
|
509
|
-
skipped: skipped.map((s) => s.id),
|
|
524
|
+
skipped: [...skipped.map((s) => s.id), ...fetchFailed],
|
|
510
525
|
results: entries.map(({ files: _files, ...rest }) => rest),
|
|
511
526
|
...(runId ? { run_id: runId } : {}),
|
|
512
527
|
},
|
|
513
528
|
human,
|
|
514
|
-
// Exit codes are a CI contract: any failing/errored test → exit 1.
|
|
515
|
-
...(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 } : {}),
|
|
516
531
|
};
|
|
517
532
|
},
|
|
518
533
|
},
|
package/dist/commands/tests.js
CHANGED
|
@@ -134,7 +134,7 @@ export const testCommands = [
|
|
|
134
134
|
name: "access",
|
|
135
135
|
type: "string",
|
|
136
136
|
description: "The label the plan will be banked with: read (default) or write. It is yours to " +
|
|
137
|
-
"set; Beryl does not infer it. Only a write is checked (its closing teardown proof)",
|
|
137
|
+
"set; Beryl does not infer it. Only a write is checked (its closing teardown proof and {{unique}} names)",
|
|
138
138
|
},
|
|
139
139
|
],
|
|
140
140
|
examples: [
|
|
@@ -29,7 +29,8 @@ export const thirdPartyIntegrationCommands = [
|
|
|
29
29
|
description: "Returns the project's own destination on Beryl's provider account (for Slack, a " +
|
|
30
30
|
"channel), creating it on first ask. When a test connects the app, it picks this " +
|
|
31
31
|
"channel in the app's settings; `await_third_party` then waits for the app's message " +
|
|
32
|
-
"there. `
|
|
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. " +
|
|
33
34
|
"A 409 means the provider is not set up on this deployment (see " +
|
|
34
35
|
"`third-party-integrations list`).",
|
|
35
36
|
scope: "project",
|
|
@@ -51,8 +52,10 @@ export const thirdPartyIntegrationCommands = [
|
|
|
51
52
|
return {
|
|
52
53
|
data: target,
|
|
53
54
|
human: `#${target.external_name}\n\n` +
|
|
54
|
-
dim(`Pick this channel in the app when the test connects ${target.provider}.` +
|
|
55
|
-
|
|
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(""),
|
|
56
59
|
};
|
|
57
60
|
},
|
|
58
61
|
},
|
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) {
|
|
@@ -111,7 +111,7 @@ export const workspaceCommands = [
|
|
|
111
111
|
scope: "workspace",
|
|
112
112
|
args: [
|
|
113
113
|
{ name: "user-id", description: "Member's user id", required: true },
|
|
114
|
-
{ name: "role", description: "OWNER or
|
|
114
|
+
{ name: "role", description: "OWNER, USER or VIEWER", required: true },
|
|
115
115
|
],
|
|
116
116
|
async run(ctx, input) {
|
|
117
117
|
const ws = await ctx.requireWorkspace(input);
|
|
@@ -142,7 +142,7 @@ export const workspaceCommands = [
|
|
|
142
142
|
scope: "workspace",
|
|
143
143
|
args: [{ name: "email", description: "Invitee email", required: true }],
|
|
144
144
|
flags: [
|
|
145
|
-
{ name: "role", type: "string", enum: ["OWNER", "USER"], description: "Role (default USER)" },
|
|
145
|
+
{ name: "role", type: "string", enum: ["OWNER", "USER", "VIEWER"], description: "Role (default USER)" },
|
|
146
146
|
],
|
|
147
147
|
async run(ctx, input) {
|
|
148
148
|
const ws = await ctx.requireWorkspace(input);
|
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
|
@@ -13,6 +13,10 @@ export const WRITE_NEEDS_CLOSING_TEARDOWN = (detail) => "A 'write' test must end
|
|
|
13
13
|
"proves it: the last step of 'after' is an expect with expect_kind 'gone', 'hidden', " +
|
|
14
14
|
"'have_count', 'count_delta' (something removed) or 'have_text', 'have_value', " +
|
|
15
15
|
`'persisted' (something restored). ${detail}`;
|
|
16
|
+
export const WRITE_NEEDS_UNIQUE = (detail) => "A 'write' test must tie what it creates, cleans up and counts to {{unique}}, so a " +
|
|
17
|
+
"record another run left behind can never match: type the record's name with " +
|
|
18
|
+
"{{unique}} (e.g. 'Report {{unique}}') and target it by that same name in " +
|
|
19
|
+
`'after' and in the closing proof. ${detail}`;
|
|
16
20
|
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
21
|
"that misses ends the teardown, so the steps after it would silently not run. Guard " +
|
|
18
22
|
"them with an optional 'wait_for' or 'expect' instead, or move this step to the end.";
|
|
@@ -234,12 +238,63 @@ function accessIssues(plan, access) {
|
|
|
234
238
|
const detail = `Its last step is ${String(last.action)}${kind ? ` ${kind}` : ""}.`;
|
|
235
239
|
return [{ path: "(root)", message: WRITE_NEEDS_CLOSING_TEARDOWN(detail) }];
|
|
236
240
|
}
|
|
237
|
-
return
|
|
241
|
+
return uniqueAnchorIssues(plan);
|
|
242
|
+
}
|
|
243
|
+
const COUNT_KINDS = new Set(["gone", "have_count"]);
|
|
244
|
+
// Shorter typed values ("1", "ok") would "name" half the page's buttons.
|
|
245
|
+
const MIN_TYPED_NAME_LEN = 3;
|
|
246
|
+
const SELECTOR_TEXT_RE = /text=["']?([^"'>]+?)["']?\s*(?:>>|$)|(?:has-text|text-is|text)\(\s*["']([^"']+)["']\s*\)|\[(?:name|title|aria-label)\s*[*^$]?=\s*["']([^"']+)["']/g;
|
|
247
|
+
// {{inbox_address}} is minted fresh per execution too, so a record named by it is its own.
|
|
248
|
+
const anchored = (text) => typeof text === "string" && /\{\{\s*(unique|uuid|inbox_address)\s*\}\}/.test(text);
|
|
249
|
+
const selectorTexts = (selector) => typeof selector === "string"
|
|
250
|
+
? [...selector.matchAll(SELECTOR_TEXT_RE)].map((m) => m[1] ?? m[2] ?? m[3] ?? "")
|
|
251
|
+
: [];
|
|
252
|
+
const hasWords = (needle, haystack) => new RegExp(`(?<![\\p{L}\\p{N}_])${needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\p{L}\\p{N}_])`, "u").test(haystack);
|
|
253
|
+
// "Delete Seed" names a typed "Seed", and `text=Seed` names a typed "Seed alpha".
|
|
254
|
+
const names = (text, typed) => hasWords(typed, text) || (typed.startsWith(text) && hasWords(text, typed));
|
|
255
|
+
// The server words the selector with Python's repr; match it so the messages agree.
|
|
256
|
+
const pyRepr = (v) => typeof v !== "string" ? "None" : v.includes("'") && !v.includes('"') ? `"${v}"` : `'${v}'`;
|
|
257
|
+
// Mirrors _assert_unique_anchors in api/app/ta_shared/schemas.py.
|
|
258
|
+
function uniqueAnchorIssues(plan) {
|
|
259
|
+
const body = [...steps(plan.before), ...steps(plan.steps)];
|
|
260
|
+
const after = steps(plan.after);
|
|
261
|
+
const labelled = ["before", "steps", "after"].flatMap((name) => steps(plan[name]).map((s, i) => [`${name}[${i}]`, s, name === "after"]));
|
|
262
|
+
const typed = body
|
|
263
|
+
.filter((s) => s.action === "fill" && typeof s.value === "string" && s.value.length >= MIN_TYPED_NAME_LEN)
|
|
264
|
+
.map((s) => s.value);
|
|
265
|
+
const refuse = (label, s, why) => [
|
|
266
|
+
{
|
|
267
|
+
path: label,
|
|
268
|
+
message: WRITE_NEEDS_UNIQUE(`${label} (${String(s.action)} ${pyRepr(s.selector)}) ${why}`),
|
|
269
|
+
},
|
|
270
|
+
];
|
|
271
|
+
for (const [label, s, inAfter] of labelled) {
|
|
272
|
+
if (!inAfter && !COUNT_KINDS.has(String(s.expect_kind)))
|
|
273
|
+
continue;
|
|
274
|
+
for (const text of selectorTexts(s.selector)) {
|
|
275
|
+
for (const value of typed) {
|
|
276
|
+
if (!names(text, value))
|
|
277
|
+
continue;
|
|
278
|
+
if (!anchored(value)) {
|
|
279
|
+
return refuse(label, s, `targets ${pyRepr(value)}, typed without {{unique}}.`);
|
|
280
|
+
}
|
|
281
|
+
if (!anchored(text))
|
|
282
|
+
return refuse(label, s, "targets a fixed name.");
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const last = after[after.length - 1] ?? {};
|
|
287
|
+
const named = typed.some((v) => anchored(v) &&
|
|
288
|
+
(String(last.selector ?? "").includes(v) ||
|
|
289
|
+
selectorTexts(last.selector).some((t) => names(t, v))));
|
|
290
|
+
return anchored(last.selector) && !named
|
|
291
|
+
? refuse(`after[${after.length - 1}]`, last, "names a {{unique}} record the test never typed.")
|
|
292
|
+
: [];
|
|
238
293
|
}
|
|
239
294
|
// `provisioning`: the plan is an account's own signup or sign-in plan, the one place
|
|
240
295
|
// {{mailbox_address}} is legal (mirrors the server's validate_editable_plan flag).
|
|
241
296
|
// `access`: the label the plan will be banked with. It is the author's call (Beryl no
|
|
242
|
-
// longer infers it from the plan); given, only a `write` is checked
|
|
297
|
+
// longer infers it from the plan); given, only a `write` is checked: its closing proof and {{unique}} names.
|
|
243
298
|
export function lintPlan(plan, opts = {}) {
|
|
244
299
|
const issues = validate(plan, ACTION_PLAN_SCHEMA, ACTION_PLAN_SCHEMA, "");
|
|
245
300
|
// The identity rules read a well-formed plan; a shape error is reported alone.
|
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