@beryl-so/cli 0.1.0 → 0.5.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.
@@ -1,12 +1,41 @@
1
1
  import fs from "node:fs";
2
2
  import { UsageError } from "../errors.js";
3
+ import { lintPlan } from "../lint.js";
4
+ import { ACTION_PLAN_SCHEMA } from "../schema.generated.js";
3
5
  import { arg, argList, flagBool, flagNum, flagStr, projectPath, readJsonFlag } from "./util.js";
4
6
  const testPath = (ws, p, id) => `${projectPath(ws, p)}/tests/${id}`;
5
7
  export const testCommands = [
8
+ {
9
+ name: "tests lint",
10
+ summary: "Validate a plan JSON file offline, before sending it to the server",
11
+ description: "Checks a plan against the published ActionPlan JSON Schema — every action's required " +
12
+ "fields, plus the two structural rules (the first EXECUTED step must be a goto, and at " +
13
+ "least one step across before + steps must be an expect). Runs entirely locally, so a " +
14
+ "malformed plan fails here instead of costing a server round-trip. " +
15
+ `Schema: ${ACTION_PLAN_SCHEMA.$id}`,
16
+ scope: "none",
17
+ flags: [
18
+ { name: "file", type: "string", required: true, description: "Plan JSON file, or - for stdin" },
19
+ ],
20
+ examples: ["beryl tests lint --file plan.json"],
21
+ async run(_ctx, input) {
22
+ const issues = lintPlan(readJsonFlag(input, "file"));
23
+ if (issues.length === 0) {
24
+ return { data: { valid: true, issues: [] }, human: "Plan is valid." };
25
+ }
26
+ return {
27
+ data: { valid: false, issues },
28
+ human: issues.map((i) => `${i.path}: ${i.message}`).join("\n"),
29
+ exitCode: 1,
30
+ };
31
+ },
32
+ },
6
33
  {
7
34
  name: "tests list",
8
35
  summary: "List the project's tests with their latest result",
9
36
  scope: "project",
37
+ groupDefault: true,
38
+ groupSummary: "Author, inspect, version, and heal a project's tests — the checks Beryl runs on each run.",
10
39
  flags: [{ name: "env", type: "string", description: "Filter by environment id" }],
11
40
  async run(ctx, input) {
12
41
  const { workspaceId, projectId } = await ctx.requireProject(input);
@@ -44,19 +73,31 @@ export const testCommands = [
44
73
  name: "tests create",
45
74
  summary: "Create a test case from a JSON action plan — for tests authored locally, e.g. by your coding agent",
46
75
  description: "The plan is a JSON object whose steps are {action, selector, url, value, ...}: the first " +
47
- "step must be a goto, and at least one step must be an expect. By default the plan is " +
48
- "verified in a real browser before the test is accepted.",
76
+ "EXECUTED step must be a goto, and at least one step must be an expect. By default the plan " +
77
+ "is verified in a real browser before the test is accepted. Optional `before` and `after` " +
78
+ "arrays hold setup and teardown steps: `after` runs even when a main step fails, which is " +
79
+ "how a create/update/delete test cleans up the record it made on the runs that go red.",
49
80
  scope: "project",
50
81
  flags: [
51
82
  { name: "title", type: "string", required: true, description: "Title for the new test" },
52
83
  { name: "file", type: "string", required: true, description: "Plan JSON file, or - for stdin" },
84
+ {
85
+ name: "description",
86
+ type: "string",
87
+ description: "1–3 sentences stating what this test proves — the immutable outcome Beryl's healing " +
88
+ "checks against. State the purpose, not the steps; the one observable signal that's true " +
89
+ "only if the flow worked.",
90
+ },
53
91
  {
54
92
  name: "no-verify",
55
93
  type: "boolean",
56
94
  description: "Skip the compile-time browser/AI verification — trust the authored plan as-is",
57
95
  },
58
96
  ],
59
- examples: ['beryl tests create --title "Checkout happy path" --file plan.json'],
97
+ examples: [
98
+ 'beryl tests create --title "Checkout happy path" --file plan.json',
99
+ 'beryl tests create --title "Checkout happy path" --file plan.json --description "Proves a shopper can buy a product: after paying, an order-confirmation page with an order number appears."',
100
+ ],
60
101
  async run(ctx, input) {
61
102
  const title = flagStr(input, "title");
62
103
  if (!title)
@@ -67,6 +108,7 @@ export const testCommands = [
67
108
  title,
68
109
  plan: readJsonFlag(input, "file"),
69
110
  verify: !flagBool(input, "no-verify"),
111
+ description: flagStr(input, "description"),
70
112
  }),
71
113
  };
72
114
  },
@@ -74,19 +116,86 @@ export const testCommands = [
74
116
  {
75
117
  name: "tests set-plan",
76
118
  summary: "Replace a test's step plan from a JSON file (creates a new version)",
119
+ description: "Accepts the same plan shape as `tests create`, including the optional `before` and " +
120
+ "`after` sections — `after` runs on pass and on fail, so cleanup happens even when the " +
121
+ "test goes red. Pass `--description` when the re-authored plan changes what the test " +
122
+ "proves; omit it to keep the test's existing intent.",
77
123
  scope: "project",
78
124
  args: [{ name: "test-id", description: "Test id", required: true }],
79
125
  flags: [
80
126
  { name: "file", type: "string", required: true, description: "Plan JSON file, or - for stdin" },
127
+ {
128
+ name: "description",
129
+ type: "string",
130
+ description: "1–3 sentences stating what this test proves — the immutable outcome Beryl's healing " +
131
+ "checks against. State the purpose, not the steps; the one observable signal that's true " +
132
+ "only if the flow worked. Omit to keep the test's existing intent.",
133
+ },
81
134
  ],
82
135
  examples: ["beryl tests plan 4f… > plan.json # edit, then:", "beryl tests set-plan 4f… --file plan.json"],
83
136
  async run(ctx, input) {
84
137
  const { workspaceId, projectId } = await ctx.requireProject(input);
85
138
  return {
86
- data: await ctx.client.patch(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/plan`, { json_plan: readJsonFlag(input, "file") }),
139
+ data: await ctx.client.patch(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/plan`, { json_plan: readJsonFlag(input, "file"), description: flagStr(input, "description") }),
140
+ };
141
+ },
142
+ },
143
+ {
144
+ name: "tests rename",
145
+ summary: "Rename a test",
146
+ scope: "project",
147
+ args: [
148
+ { name: "test-id", description: "Test id", required: true },
149
+ { name: "title", description: "New title", required: true },
150
+ ],
151
+ examples: ['beryl tests rename 4f… "Checkout happy path"'],
152
+ async run(ctx, input) {
153
+ const { workspaceId, projectId } = await ctx.requireProject(input);
154
+ return {
155
+ data: await ctx.client.patch(testPath(workspaceId, projectId, arg(input, "test-id")), {
156
+ title: arg(input, "title"),
157
+ }),
158
+ };
159
+ },
160
+ },
161
+ {
162
+ name: "tests quarantine",
163
+ summary: "Mute a flaky test: it keeps running, but its failures stop failing the run",
164
+ description: "A quarantined test still executes and its result is still recorded and visible — its " +
165
+ "red just doesn't count towards the run's verdict, so it can't red-light a deploy. Use " +
166
+ "it on a persistently flaky test instead of deleting it (which destroys the history) or " +
167
+ "asking support to deactivate it (which stops it running at all). `off` un-quarantines.",
168
+ scope: "project",
169
+ args: [
170
+ { name: "test-id", description: "Test id", required: true },
171
+ { name: "state", description: "on | off", required: true },
172
+ ],
173
+ examples: ["beryl tests quarantine 4f… on", "beryl tests quarantine 4f… off"],
174
+ async run(ctx, input) {
175
+ const { workspaceId, projectId } = await ctx.requireProject(input);
176
+ const state = arg(input, "state").toLowerCase();
177
+ if (state !== "on" && state !== "off") {
178
+ throw new UsageError(`Expected "on" or "off", got "${arg(input, "state")}"`);
179
+ }
180
+ return {
181
+ data: await ctx.client.patch(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/quarantine`, { quarantined: state === "on" }),
87
182
  };
88
183
  },
89
184
  },
185
+ {
186
+ name: "tests delete",
187
+ summary: "Delete a test, its version history, and its results",
188
+ scope: "project",
189
+ args: [{ name: "test-id", description: "Test id", required: true }],
190
+ flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
191
+ async run(ctx, input) {
192
+ const { workspaceId, projectId } = await ctx.requireProject(input);
193
+ const testId = arg(input, "test-id");
194
+ await ctx.confirm(`Delete test ${testId} and all its history?`, flagBool(input, "force"));
195
+ await ctx.client.del(testPath(workspaceId, projectId, testId));
196
+ return { human: "Deleted." };
197
+ },
198
+ },
90
199
  {
91
200
  name: "tests recompile",
92
201
  summary: "Validate + verify an edited plan against the live site before persisting",
@@ -23,15 +23,28 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes) {
23
23
  if (kind === "run_started") {
24
24
  ctx.err(dim(`run ${runId} started`));
25
25
  }
26
+ else if (kind === "test_retrying") {
27
+ ctx.err(yellow(`↻ retrying ${dim(String(ev.test_result_id))} (attempt ${ev.attempt_no} failed)`));
28
+ }
26
29
  else if (kind === "test_completed") {
27
30
  const key = `${ev.test_result_id}:${ev.status}`;
28
31
  if (!seen.has(key)) {
29
32
  seen.add(key);
30
33
  const ok = ev.status === "passed";
31
- const mark = ok ? green("✓") : red("✗");
34
+ // A quarantined red still prints the result is recorded and visible —
35
+ // but marked as muted so it doesn't read as a build-breaking failure.
36
+ const muted = Boolean(ev.quarantined) && !ok;
37
+ const mark = ok ? green("✓") : muted ? yellow("⚠") : red("✗");
38
+ const label = muted
39
+ ? yellow(`${String(ev.status)} (quarantined)`)
40
+ : statusColor(String(ev.status));
32
41
  const duration = ev.duration_ms ? dim(` ${Math.round(Number(ev.duration_ms) / 1000)}s`) : "";
33
- const error = ev.error_message ? red(` — ${String(ev.error_message).slice(0, 120)}`) : "";
34
- ctx.err(`${mark} ${statusColor(String(ev.status))} ${dim(String(ev.test_result_id))}${duration}${error}`);
42
+ const errorText = ev.error_message ? ` — ${String(ev.error_message).slice(0, 120)}` : "";
43
+ const error = errorText ? (muted ? dim(errorText) : red(errorText)) : "";
44
+ // A pass that took a retry is still a pass, but say so — a flake absorbed in
45
+ // silence is how a suite that gates deploys stops being trusted.
46
+ const flaky = ev.flaky ? yellow(` flaky (passed on attempt ${ev.attempt_no})`) : "";
47
+ ctx.err(`${mark} ${label} ${dim(String(ev.test_result_id))}${duration}${flaky}${error}`);
35
48
  }
36
49
  }
37
50
  }
@@ -46,11 +59,19 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes) {
46
59
  }
47
60
  if (signal?.aborted)
48
61
  throw new CliError(`Timed out after ${timeoutMinutes} minutes`, 1);
62
+ // Quarantined reds are excluded from `failed` by the API (they land in their own
63
+ // counter), so they never reach this sum and never flip the exit code — but they
64
+ // are always reported, so a green build never silently hides a muted failure.
49
65
  const failed = (counters.failed ?? 0) + (counters.errored ?? 0);
50
66
  const passed = counters.passed ?? 0;
67
+ // A flaky pass likewise exits 0 — absorbing a transient blip is the point — but it
68
+ // is reported too, so a suite that only stays green by retrying can't hide it.
69
+ const extra = (counters.cancelled ? yellow(`, ${counters.cancelled} cancelled`) : "") +
70
+ (counters.quarantined ? yellow(`, ${counters.quarantined} quarantined`) : "") +
71
+ (counters.flaky ? yellow(`, ${counters.flaky} flaky`) : "");
51
72
  const summary = failed > 0
52
- ? red(`${failed} failed`) + `, ${passed} passed`
53
- : green(`${passed} passed`) + (counters.cancelled ? yellow(`, ${counters.cancelled} cancelled`) : "");
73
+ ? red(`${failed} failed`) + `, ${passed} passed` + extra
74
+ : green(`${passed} passed`) + extra;
54
75
  if (!ctx.json)
55
76
  ctx.err(`\n${statusColor(finalStatus)}: ${summary}`);
56
77
  const exitCode = finalStatus === "completed" && failed === 0 ? 0 : 1;
@@ -6,6 +6,7 @@ export const workspaceCommands = [
6
6
  {
7
7
  name: "workspaces list",
8
8
  summary: "List workspaces you belong to",
9
+ groupSummary: "Create and manage workspaces, and pick the one your commands act on by default.",
9
10
  async run(ctx) {
10
11
  const rows = (await ctx.client.get("/workspaces/"));
11
12
  return { data: rows };
@@ -115,6 +116,7 @@ export const workspaceCommands = [
115
116
  {
116
117
  name: "members list",
117
118
  summary: "List workspace members",
119
+ groupSummary: "Manage who belongs to a workspace and their roles.",
118
120
  scope: "workspace",
119
121
  async run(ctx, input) {
120
122
  const ws = await ctx.requireWorkspace(input);
@@ -154,6 +156,7 @@ export const workspaceCommands = [
154
156
  {
155
157
  name: "invites send",
156
158
  summary: "Invite someone to the workspace by email",
159
+ groupSummary: "Send, list, and revoke workspace invitations, and accept ones sent to you.",
157
160
  scope: "workspace",
158
161
  args: [{ name: "email", description: "Invitee email", required: true }],
159
162
  flags: [
package/dist/context.js CHANGED
@@ -23,6 +23,7 @@ export function createContext(options) {
23
23
  config,
24
24
  json,
25
25
  interactive: options.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY),
26
+ mcp: options.mcp,
26
27
  out: options.out ?? ((text) => process.stdout.write(text + "\n")),
27
28
  err: options.err ?? ((text) => process.stderr.write(text + "\n")),
28
29
  async prompt(question) {
package/dist/detect.js ADDED
@@ -0,0 +1,88 @@
1
+ // Login-wall detection for `projects create`. The API takes the visitor's answer as
2
+ // authoritative and never infers it (a null choice there strands the project in
3
+ // needs_capture), so the guess has to happen here, before the request.
4
+ //
5
+ // The asymmetry that shapes this: "gated" can be proven from the response, "public"
6
+ // cannot. An app that renders its login screen client-side serves the same 200 + empty
7
+ // shell as a public landing page, so absence of a login wall is not evidence of a public
8
+ // site. We therefore only claim `public` on positive evidence of real content, and leave
9
+ // everything else undecided — the caller prompts on a TTY and fails loudly in CI.
10
+ const FETCH_TIMEOUT_MS = 10_000;
11
+ const PASSWORD_FIELD = /<input[^>]+type\s*=\s*["']?password["']?/i;
12
+ // Sign-in copy anywhere in the markup. A public marketing page carries this too (its
13
+ // header "Log in" link), so it never proves gated — it only withholds a confident public.
14
+ const LOGIN_COPY = /\b(sign in|signin|log in|login|sign up|create (an )?account)\b/i;
15
+ // Body text with the markup stripped. An SPA shell collapses to ~nothing here, which is
16
+ // exactly the signal that we cannot see the real page and must not guess.
17
+ function visibleText(html) {
18
+ return html
19
+ .replace(/<(script|style|noscript|template)\b[^>]*>[\s\S]*?<\/\1>/gi, " ")
20
+ .replace(/<[^>]+>/g, " ")
21
+ .replace(/&[a-z#0-9]+;/gi, " ")
22
+ .replace(/\s+/g, " ")
23
+ .trim();
24
+ }
25
+ const MIN_PUBLIC_TEXT = 200;
26
+ const isLoginish = (url) => /(^|\/)(login|signin|sign-in|auth|authorize|sso|account\/login|users\/sign_in)(\/|$)/i.test(url.pathname);
27
+ function classify(root, final, status, body) {
28
+ if (status === 401 || status === 403)
29
+ return { choice: "gated", reason: `the site answered HTTP ${status}` };
30
+ if (status < 200 || status >= 300)
31
+ return { reason: `the site answered HTTP ${status}` };
32
+ const redirected = final.href !== root.href;
33
+ const hasPasswordField = PASSWORD_FIELD.test(body);
34
+ if (redirected && isLoginish(final))
35
+ return { choice: "gated", reason: `it redirects to a sign-in page (${final.href})` };
36
+ if (redirected && hasPasswordField)
37
+ return {
38
+ choice: "gated",
39
+ reason: `it redirects to a page with a password field (${final.href})`,
40
+ };
41
+ // The root itself is a sign-in page: either the URL says so, or it serves a password
42
+ // field and nothing else of substance.
43
+ const text = visibleText(body);
44
+ if (isLoginish(final) && hasPasswordField)
45
+ return { choice: "gated", reason: "its root URL is a sign-in page" };
46
+ if (hasPasswordField && text.length < MIN_PUBLIC_TEXT)
47
+ return { choice: "gated", reason: "the page is a sign-in form" };
48
+ // Below here nothing proves gated — but only real, login-free content proves public.
49
+ if (hasPasswordField)
50
+ return { reason: "the page serves both a password field and other content" };
51
+ if (text.length < MIN_PUBLIC_TEXT)
52
+ return {
53
+ reason: "the page renders its content in the browser, so its markup does not show whether " +
54
+ "there is a login wall",
55
+ };
56
+ if (LOGIN_COPY.test(text))
57
+ return { reason: "the page mentions signing in but shows no login form" };
58
+ return { choice: "public", reason: "the page serves content with no sign-in wall" };
59
+ }
60
+ export async function detectAuthGating(rootUrl) {
61
+ let root;
62
+ try {
63
+ root = new URL(rootUrl);
64
+ }
65
+ catch {
66
+ return { reason: `"${rootUrl}" is not a URL we can fetch` };
67
+ }
68
+ if (root.protocol !== "http:" && root.protocol !== "https:")
69
+ return { reason: `"${rootUrl}" is not an http(s) URL` };
70
+ const controller = new AbortController();
71
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
72
+ try {
73
+ const res = await fetch(root, {
74
+ redirect: "follow",
75
+ signal: controller.signal,
76
+ headers: { "User-Agent": "beryl-cli", Accept: "text/html,*/*" },
77
+ });
78
+ const body = await res.text();
79
+ return classify(root, new URL(res.url), res.status, body);
80
+ }
81
+ catch (err) {
82
+ return { reason: `the site could not be fetched (${err.message})` };
83
+ }
84
+ finally {
85
+ clearTimeout(timer);
86
+ }
87
+ }
88
+ export { classify as classifyForTest };
package/dist/lint.js ADDED
@@ -0,0 +1,125 @@
1
+ import { ACTION_PLAN_SCHEMA } from "./schema.generated.js";
2
+ const typeOf = (value) => {
3
+ if (value === null)
4
+ return "null";
5
+ if (Array.isArray(value))
6
+ return "array";
7
+ if (Number.isInteger(value))
8
+ return "integer";
9
+ return typeof value;
10
+ };
11
+ const typeMatches = (value, type) => type === "number" ? typeof value === "number" : typeOf(value) === type;
12
+ function resolve(schema, root) {
13
+ const ref = schema.$ref;
14
+ if (typeof ref !== "string")
15
+ return schema;
16
+ const key = ref.replace("#/$defs/", "");
17
+ return { ...(root.$defs?.[key] ?? {}), ...schema, $ref: undefined };
18
+ }
19
+ function matches(value, schema, root) {
20
+ return validate(value, schema, root, "").length === 0;
21
+ }
22
+ function validate(value, raw, root, path) {
23
+ const schema = resolve(raw, root);
24
+ const issues = [];
25
+ const at = (message) => issues.push({ path: path || "(root)", message });
26
+ if (schema.anyOf && !schema.anyOf.some((s) => matches(value, s, root))) {
27
+ // Optional fields are emitted as anyOf[<real type>, null]. When the value isn't
28
+ // null, the null branch is noise — report the real branch's own failure ("must
29
+ // match ^[A-Za-z0-9_]+$") instead of a useless "expected string or null".
30
+ const real = schema.anyOf.filter((s) => resolve(s, root).type !== "null");
31
+ if (value !== null && real.length === 1) {
32
+ return validate(value, real[0], root, path);
33
+ }
34
+ const types = schema.anyOf
35
+ .map((s) => resolve(s, root).type ?? resolve(s, root).enum?.join("|"))
36
+ .filter(Boolean)
37
+ .join(" or ");
38
+ at(types ? `expected ${types}` : "does not match any allowed shape");
39
+ return issues;
40
+ }
41
+ if (typeof schema.type === "string" && !typeMatches(value, schema.type)) {
42
+ at(`expected ${schema.type}, got ${typeOf(value)}`);
43
+ return issues;
44
+ }
45
+ if (schema.not && matches(value, schema.not, root)) {
46
+ at(schema.not.type === "null" ? "must not be null" : "is not allowed here");
47
+ return issues;
48
+ }
49
+ if (schema.enum && !schema.enum.includes(value)) {
50
+ at(`must be one of: ${schema.enum.join(", ")}`);
51
+ }
52
+ if (schema.const !== undefined && value !== schema.const) {
53
+ at(`must be ${JSON.stringify(schema.const)}`);
54
+ }
55
+ if (typeof value === "string") {
56
+ if (schema.pattern && !new RegExp(schema.pattern).test(value)) {
57
+ at(`must match ${schema.pattern}`);
58
+ }
59
+ // The server reads "" as absent for the truthiness-guarded fields (url, selector,
60
+ // key, capture_as/ref), so minLength is what keeps "" from linting clean and 422ing.
61
+ if (schema.minLength !== undefined && value.length < schema.minLength) {
62
+ at("must not be empty");
63
+ }
64
+ }
65
+ if (typeof value === "number") {
66
+ if (schema.minimum !== undefined && value < schema.minimum)
67
+ at(`must be >= ${schema.minimum}`);
68
+ if (schema.maximum !== undefined && value > schema.maximum)
69
+ at(`must be <= ${schema.maximum}`);
70
+ }
71
+ if (Array.isArray(value)) {
72
+ if (schema.minItems !== undefined && value.length < schema.minItems) {
73
+ at(`must have at least ${schema.minItems} item(s)`);
74
+ }
75
+ if (schema.contains && !value.some((v) => matches(v, schema.contains, root))) {
76
+ at(schema.$comment ?? "is missing a required entry");
77
+ }
78
+ schema.prefixItems?.forEach((s, i) => {
79
+ if (i < value.length)
80
+ issues.push(...validate(value[i], s, root, `${path}[${i}]`));
81
+ });
82
+ if (schema.items) {
83
+ value.forEach((v, i) => issues.push(...validate(v, schema.items, root, `${path}[${i}]`)));
84
+ }
85
+ }
86
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
87
+ const object = value;
88
+ for (const key of schema.required ?? []) {
89
+ if (object[key] === undefined)
90
+ at(`missing required field "${key}"`);
91
+ }
92
+ for (const [key, sub] of Object.entries(schema.properties ?? {})) {
93
+ if (object[key] !== undefined) {
94
+ issues.push(...validate(object[key], sub, root, path ? `${path}.${key}` : key));
95
+ }
96
+ }
97
+ }
98
+ for (const sub of schema.allOf ?? []) {
99
+ // if/then[/else] carries the per-action rules ("a fill needs a value") and the
100
+ // section-dependent plan rules (the first EXECUTED step is before[0] when there is a
101
+ // setup section, else steps[0]); a bare allOf entry carries the unconditional
102
+ // plan-level ones. Dropping `else` would silently skip the flat-plan branch — a plan
103
+ // that lints clean here and then 422s on the server, the exact hole this file closes.
104
+ if (sub.if) {
105
+ const branch = matches(value, sub.if, root) ? sub.then : sub.else;
106
+ if (branch) {
107
+ const found = validate(value, branch, root, path);
108
+ if (found.length && sub.$comment)
109
+ at(sub.$comment);
110
+ else
111
+ issues.push(...found);
112
+ }
113
+ continue;
114
+ }
115
+ const found = validate(value, sub, root, path);
116
+ if (found.length && sub.$comment)
117
+ at(sub.$comment);
118
+ else
119
+ issues.push(...found);
120
+ }
121
+ return issues;
122
+ }
123
+ export function lintPlan(plan) {
124
+ return validate(plan, ACTION_PLAN_SCHEMA, ACTION_PLAN_SCHEMA, "");
125
+ }
@@ -0,0 +1,168 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ // The customer already has @playwright/test (the MCP `init` wires it), so we invoke it
5
+ // as an external rather than bundling it — the CLI's zero-runtime-dep rule. In a node_modules
6
+ // install `playwright` is on PATH; otherwise fall back to `npx playwright`, exactly as the
7
+ // cloud runner does.
8
+ export const PLAYWRIGHT_INSTALL_HINT = "Local Playwright not found. Install it in this project, then re-run:\n" +
9
+ " npm i -D @playwright/test && npx playwright install chromium";
10
+ // Isolate the run from any playwright.config.ts in the customer's repo: a stray `testMatch`
11
+ // would exclude our spec (a zero-test run that reads as a false pass), and a `use.baseURL` /
12
+ // `use.storageState` / `globalSetup` there would silently retarget or reauth the run we mean
13
+ // to be a clean local execution of exactly this one spec. `testDir` pins us to our own dir.
14
+ // Reporter is left to the CLI flag + PLAYWRIGHT_JSON_OUTPUT_NAME (as the cloud runner does), so
15
+ // the report lands at exactly our path regardless of what a reporter here would default to.
16
+ const ISOLATING_CONFIG = (testDir, artifactsDir) => `import { defineConfig } from "@playwright/test";\n` +
17
+ `export default defineConfig({\n` +
18
+ ` testDir: ${JSON.stringify(testDir)},\n` +
19
+ ` testMatch: "beryl-local.spec.ts",\n` +
20
+ ` outputDir: ${JSON.stringify(artifactsDir)},\n` +
21
+ ` fullyParallel: false,\n` +
22
+ `});\n`;
23
+ const PASSING = new Set(["passed", "expected"]);
24
+ const SKIPPED = new Set(["skipped"]);
25
+ export class PlaywrightMissingError extends Error {
26
+ }
27
+ function runProcess(command, args, cwd, env) {
28
+ return new Promise((resolve) => {
29
+ const child = spawn(command, args, { cwd, env });
30
+ let stdout = "";
31
+ let stderr = "";
32
+ child.stdout.on("data", (d) => (stdout += d.toString()));
33
+ child.stderr.on("data", (d) => (stderr += d.toString()));
34
+ child.on("error", (err) => resolve({ code: null, stdout, stderr, spawnError: err }));
35
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
36
+ });
37
+ }
38
+ // `npx playwright` is the portable fallback when no local binary is on PATH — it resolves
39
+ // the project's @playwright/test without us hard-coding a node_modules path.
40
+ function playwrightBase(cwd) {
41
+ const binName = process.platform === "win32" ? "playwright.cmd" : "playwright";
42
+ const local = path.join(cwd, "node_modules", ".bin", binName);
43
+ if (fs.existsSync(local))
44
+ return { command: local, args: ["test"] };
45
+ const npx = process.platform === "win32" ? "npx.cmd" : "npx";
46
+ return { command: npx, args: ["playwright", "test"] };
47
+ }
48
+ // "unknown command 'test'" is the Python `playwright` shim (no `test` subcommand); the module
49
+ // errors mean @playwright/test isn't installed. Either way the actionable answer is the same
50
+ // install hint, not a stack trace.
51
+ function looksLikePlaywrightMissing(r) {
52
+ if (r.spawnError?.code === "ENOENT")
53
+ return true;
54
+ const blob = `${r.stdout}\n${r.stderr}`;
55
+ return (/unknown command ['"]?test/i.test(blob) ||
56
+ /Cannot find module ['"]@playwright\/test/i.test(blob) ||
57
+ /npm ERR!.*could not determine executable|npx.*not found/i.test(blob));
58
+ }
59
+ export function parsePlaywrightReport(data) {
60
+ const results = [];
61
+ const walk = (suites) => {
62
+ for (const suite of suites) {
63
+ for (const spec of suite.specs ?? []) {
64
+ for (const test of spec.tests ?? []) {
65
+ // Last attempt is the verdict — retries can precede it.
66
+ const last = test.results?.[test.results.length - 1];
67
+ const errors = (last?.errors ?? []).map((e) => e.message).filter(Boolean);
68
+ const artifacts = (last?.attachments ?? [])
69
+ .map((a) => a.path)
70
+ .filter((p) => Boolean(p));
71
+ results.push({
72
+ name: spec.title ?? "(unnamed)",
73
+ status: last?.status ?? "unknown",
74
+ duration_ms: last?.duration,
75
+ ...(errors.length ? { error: errors.join("\n") } : {}),
76
+ artifacts,
77
+ });
78
+ }
79
+ }
80
+ if (suite.suites)
81
+ walk(suite.suites);
82
+ }
83
+ };
84
+ walk(data.suites ?? []);
85
+ return results;
86
+ }
87
+ // passed/failed exclude skips; an empty set is neither — the caller treats it as a failure
88
+ // because a run that executed nothing is not evidence of a pass.
89
+ export function tally(results) {
90
+ const passed = results.filter((r) => PASSING.has(r.status)).length;
91
+ const skipped = results.filter((r) => SKIPPED.has(r.status)).length;
92
+ return { passed, failed: results.length - passed - skipped };
93
+ }
94
+ function parseReport(reportPath) {
95
+ return parsePlaywrightReport(JSON.parse(fs.readFileSync(reportPath, "utf8")));
96
+ }
97
+ /**
98
+ * Write `spec` next to the caller's project, run it with their local @playwright/test, and
99
+ * parse the JSON report into a structured pass/fail summary. Artifacts + the spec + report land
100
+ * in `dir` when given (kept), otherwise in a temp dir that is cleaned up. Throws
101
+ * {@link PlaywrightMissingError} when no runnable @playwright/test is found so the caller can
102
+ * print an install hint.
103
+ */
104
+ export async function runSpecLocally(opts) {
105
+ const cwd = process.cwd();
106
+ // The spec + isolating config MUST sit inside the project tree: Playwright resolves
107
+ // `@playwright/test` (imported by both) by walking UP from the config file, so a config in
108
+ // /tmp finds no node_modules and every run dies with "Cannot find module '@playwright/test'".
109
+ // A run dir under cwd walks up into the project's node_modules; it's always cleaned up.
110
+ const runDir = fs.mkdtempSync(path.join(cwd, ".beryl-local-"));
111
+ // Where the user-facing outputs (artifacts, spec copy, report) go: --dir if asked, else the
112
+ // ephemeral run dir.
113
+ const outDir = opts.dir ? path.resolve(opts.dir) : runDir;
114
+ fs.mkdirSync(outDir, { recursive: true });
115
+ const specPath = path.join(runDir, "beryl-local.spec.ts");
116
+ fs.writeFileSync(specPath, opts.spec);
117
+ const configPath = path.join(runDir, "beryl-local.config.ts");
118
+ const artifactsDir = path.join(outDir, "artifacts");
119
+ fs.writeFileSync(configPath, ISOLATING_CONFIG(runDir, artifactsDir));
120
+ const reportPath = path.join(runDir, "report.json");
121
+ const cleanup = () => fs.rmSync(runDir, { recursive: true, force: true });
122
+ const { command, args: base } = playwrightBase(cwd);
123
+ const args = [...base, `--config=${configPath}`, "--reporter=json"];
124
+ opts.onProgress?.(`Running ${command} ${base.join(" ")} on ${opts.testName}…`);
125
+ const env = { ...process.env, PLAYWRIGHT_JSON_OUTPUT_NAME: reportPath };
126
+ const result = await runProcess(command, args, cwd, env);
127
+ if (looksLikePlaywrightMissing(result)) {
128
+ cleanup();
129
+ throw new PlaywrightMissingError(PLAYWRIGHT_INSTALL_HINT);
130
+ }
131
+ let results;
132
+ try {
133
+ results = parseReport(reportPath);
134
+ }
135
+ catch (err) {
136
+ // No parsable report on a non-zero exit means the spec never ran (compile/launch error) —
137
+ // surface stderr/stdout so it isn't a silent failure.
138
+ const detail = (result.stderr || result.stdout || err.message).trim();
139
+ cleanup();
140
+ throw new Error(`Playwright produced no readable report — the spec did not run.\n${detail}`);
141
+ }
142
+ // A run that executed zero tests is a failure, not a pass — an isolating config matching
143
+ // exactly our spec should never yield an empty set, so an empty one means the spec was
144
+ // filtered/skipped away and there is no evidence it ran.
145
+ if (results.length === 0) {
146
+ const detail = (result.stderr || result.stdout || "no tests were run").trim();
147
+ cleanup();
148
+ throw new Error(`Playwright ran no tests from the rendered spec.\n${detail}`);
149
+ }
150
+ let keptSpec = specPath;
151
+ let keptReport = reportPath;
152
+ if (opts.dir) {
153
+ // Persist the exact spec + report next to the artifacts before the run dir is removed.
154
+ keptSpec = path.join(outDir, "beryl-local.spec.ts");
155
+ keptReport = path.join(outDir, "report.json");
156
+ fs.copyFileSync(specPath, keptSpec);
157
+ fs.copyFileSync(reportPath, keptReport);
158
+ }
159
+ else {
160
+ // Without --dir the artifacts lived in the run dir we're about to delete, so their paths
161
+ // would dangle — don't hand back paths to files that no longer exist.
162
+ for (const r of results)
163
+ r.artifacts = [];
164
+ }
165
+ cleanup();
166
+ const { passed, failed } = tally(results);
167
+ return { passed, failed, results, directory: outDir, spec: keptSpec, report: keptReport };
168
+ }