@beryl-so/cli 0.78.0 → 0.92.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 +18 -7
- package/dist/adapters/mcp.js +23 -10
- package/dist/beryl-test-skill.js +177 -33
- package/dist/code-ref.js +81 -0
- package/dist/commands/accounts.js +74 -18
- package/dist/commands/config-vars.js +207 -20
- package/dist/commands/environments.js +14 -7
- package/dist/commands/init.js +2 -2
- package/dist/commands/jobs.js +152 -20
- package/dist/commands/projects.js +115 -20
- package/dist/commands/runs.js +68 -8
- package/dist/commands/tests.js +44 -9
- package/dist/commands/util.js +38 -6
- package/dist/commands/watch.js +3 -2
- package/dist/config.js +2 -0
- package/dist/lint.js +180 -3
- package/dist/schema.generated.js +178 -7
- package/dist/skill-tables.js +3 -0
- package/package.json +1 -1
package/dist/commands/tests.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { UsageError } from "../errors.js";
|
|
3
3
|
import { table } from "../output.js";
|
|
4
|
-
import { arg, argList, flagBool, flagNum, flagStr, flagStrings, inGroup,
|
|
4
|
+
import { arg, argList, flagBool, flagNum, flagStr, flagStrings, inGroup, jsonFileOrInline, projectPath, } from "./util.js";
|
|
5
5
|
const testPath = (ws, p, id) => `${projectPath(ws, p)}/tests/${id}`;
|
|
6
|
+
const PLAN_FLAG = {
|
|
7
|
+
name: "plan",
|
|
8
|
+
type: "json",
|
|
9
|
+
description: "The plan inline, instead of --file",
|
|
10
|
+
};
|
|
11
|
+
const planInput = (input) => jsonFileOrInline(input, "file", "plan", true);
|
|
6
12
|
const EVENT_VERBS = {
|
|
7
13
|
QUARANTINED: "quarantined",
|
|
8
14
|
UNQUARANTINED: "released from quarantine",
|
|
@@ -170,7 +176,7 @@ export const testCommands = [
|
|
|
170
176
|
},
|
|
171
177
|
{
|
|
172
178
|
name: "tests set-plan",
|
|
173
|
-
summary: "Replace a test's step plan
|
|
179
|
+
summary: "Replace a test's step plan (creates a new version)",
|
|
174
180
|
description: "Accepts an ActionPlan (the shape `tests plan` returns), including the optional `before` and " +
|
|
175
181
|
"`after` sections. `after` runs on pass and on fail, so cleanup happens even when the " +
|
|
176
182
|
"test goes red. Pass `--description` when the re-authored plan changes what the test " +
|
|
@@ -180,7 +186,8 @@ export const testCommands = [
|
|
|
180
186
|
scope: "project",
|
|
181
187
|
args: [{ name: "test-id", description: "Test id", required: true }],
|
|
182
188
|
flags: [
|
|
183
|
-
{ name: "file", type: "string",
|
|
189
|
+
{ name: "file", type: "string", description: "Plan JSON file, or - for stdin" },
|
|
190
|
+
PLAN_FLAG,
|
|
184
191
|
{
|
|
185
192
|
name: "description",
|
|
186
193
|
type: "string",
|
|
@@ -189,11 +196,16 @@ export const testCommands = [
|
|
|
189
196
|
"only if the flow worked. Omit to keep the test's existing intent.",
|
|
190
197
|
},
|
|
191
198
|
],
|
|
192
|
-
examples: [
|
|
199
|
+
examples: [
|
|
200
|
+
"beryl tests plan 4f… > plan.json # edit, then:",
|
|
201
|
+
"beryl tests set-plan 4f… --file plan.json",
|
|
202
|
+
`beryl tests set-plan 4f… --plan '{"steps":[{"action":"goto","url":"/pricing"}]}'`,
|
|
203
|
+
],
|
|
193
204
|
async run(ctx, input) {
|
|
205
|
+
const plan = planInput(input);
|
|
194
206
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
195
207
|
return {
|
|
196
|
-
data: await ctx.client.patch(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/plan`, { json_plan:
|
|
208
|
+
data: await ctx.client.patch(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/plan`, { json_plan: plan, description: flagStr(input, "description") }),
|
|
197
209
|
};
|
|
198
210
|
},
|
|
199
211
|
},
|
|
@@ -435,11 +447,13 @@ export const testCommands = [
|
|
|
435
447
|
scope: "project",
|
|
436
448
|
args: [{ name: "test-id", description: "Test id", required: true }],
|
|
437
449
|
flags: [
|
|
438
|
-
{ name: "file", type: "string",
|
|
450
|
+
{ name: "file", type: "string", description: "Plan JSON file, or - for stdin" },
|
|
451
|
+
PLAN_FLAG,
|
|
439
452
|
],
|
|
440
453
|
async run(ctx, input) {
|
|
454
|
+
const plan = planInput(input);
|
|
441
455
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
442
|
-
const res = (await ctx.client.post(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/plan/recompile`, { json_plan:
|
|
456
|
+
const res = (await ctx.client.post(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/plan/recompile`, { json_plan: plan }));
|
|
443
457
|
// Base64 in the JSON output would flood the caller's context — hand the
|
|
444
458
|
// failure screenshot over as image content instead.
|
|
445
459
|
const shot = typeof res.screenshot_b64 === "string" ? res.screenshot_b64 : undefined;
|
|
@@ -533,6 +547,8 @@ export const testCommands = [
|
|
|
533
547
|
{
|
|
534
548
|
name: "tests heal",
|
|
535
549
|
summary: "Turn self-healing on or off for a test",
|
|
550
|
+
description: "Only matters while the project's self-heal is on (`projects heal on`); it is off " +
|
|
551
|
+
"by default, and then no test heals.",
|
|
536
552
|
scope: "project",
|
|
537
553
|
args: [
|
|
538
554
|
{ name: "test-id", description: "Test id", required: true },
|
|
@@ -582,12 +598,31 @@ export const testCommands = [
|
|
|
582
598
|
{
|
|
583
599
|
name: "tests export",
|
|
584
600
|
summary: "Export tests as Playwright .spec.ts files in a ZIP",
|
|
601
|
+
description: "Writes the ZIP to disk. With --inline, nothing is written: the result carries " +
|
|
602
|
+
"the same files as {files: [{filename, content}]}.",
|
|
585
603
|
scope: "project",
|
|
586
604
|
args: [{ name: "test-ids", description: "One or more test ids", required: true, variadic: true }],
|
|
587
|
-
flags: [
|
|
605
|
+
flags: [
|
|
606
|
+
{ name: "out", type: "string", description: "Output file (default beryl-tests.zip)" },
|
|
607
|
+
{
|
|
608
|
+
name: "inline",
|
|
609
|
+
type: "boolean",
|
|
610
|
+
description: "Return the files in the result instead of writing a ZIP",
|
|
611
|
+
},
|
|
612
|
+
],
|
|
588
613
|
async run(ctx, input) {
|
|
589
614
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
590
|
-
const
|
|
615
|
+
const ids = argList(input, "test-ids");
|
|
616
|
+
if (flagBool(input, "inline")) {
|
|
617
|
+
if (flagStr(input, "out"))
|
|
618
|
+
throw new UsageError("pass --out or --inline, not both");
|
|
619
|
+
const data = (await ctx.client.post(`${projectPath(workspaceId, projectId)}/tests/export`, { test_case_ids: ids }, { format: "json" }));
|
|
620
|
+
return {
|
|
621
|
+
data,
|
|
622
|
+
human: data.files.map((f) => `// ${f.filename}\n${f.content}`).join("\n\n"),
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
const response = (await ctx.client.request("POST", `${projectPath(workspaceId, projectId)}/tests/export`, { body: { test_case_ids: ids }, raw: true }));
|
|
591
626
|
const out = flagStr(input, "out") ?? "beryl-tests.zip";
|
|
592
627
|
fs.writeFileSync(out, Buffer.from(await response.arrayBuffer()));
|
|
593
628
|
return { data: { written: out }, human: `Wrote ${out}` };
|
package/dist/commands/util.js
CHANGED
|
@@ -42,18 +42,50 @@ export function readFileArg(path, label) {
|
|
|
42
42
|
throw new UsageError(`${label}: cannot read ${path} (${code ?? err.message})`);
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
-
|
|
46
|
-
const file = flagStr(input, name);
|
|
47
|
-
if (!file)
|
|
48
|
-
throw new UsageError(`--${name} <file> is required`);
|
|
49
|
-
const text = readFileArg(file, `--${name}`);
|
|
45
|
+
function parseJson(text, label, source) {
|
|
50
46
|
try {
|
|
51
47
|
return JSON.parse(text);
|
|
52
48
|
}
|
|
53
49
|
catch (err) {
|
|
54
|
-
throw new UsageError(
|
|
50
|
+
throw new UsageError(`${label}: ${source} is not valid JSON (${err.message})`);
|
|
55
51
|
}
|
|
56
52
|
}
|
|
53
|
+
export function readJsonFlag(input, name) {
|
|
54
|
+
const file = flagStr(input, name);
|
|
55
|
+
if (!file)
|
|
56
|
+
throw new UsageError(`--${name} <file> is required`);
|
|
57
|
+
return parseJson(readFileArg(file, `--${name}`), `--${name}`, file);
|
|
58
|
+
}
|
|
59
|
+
/** A `json` flag's value: the object itself (MCP), or on the CLI JSON text, a path, or -
|
|
60
|
+
* for stdin. Text opening with { or [ is JSON, never a path. */
|
|
61
|
+
export function jsonFlag(input, name) {
|
|
62
|
+
const value = input.flags[name];
|
|
63
|
+
if (value === undefined)
|
|
64
|
+
return undefined;
|
|
65
|
+
if (typeof value !== "string") {
|
|
66
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
67
|
+
throw new UsageError(`--${name} must be a JSON object`);
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
const text = value.trim();
|
|
72
|
+
if (text.startsWith("{") || text.startsWith("["))
|
|
73
|
+
return parseJson(text, `--${name}`, "the value");
|
|
74
|
+
return parseJson(readFileArg(value, `--${name}`), `--${name}`, value);
|
|
75
|
+
}
|
|
76
|
+
export function jsonFileOrInline(input, fileFlag, inlineFlag, required) {
|
|
77
|
+
const hasFile = Boolean(flagStr(input, fileFlag));
|
|
78
|
+
const hasInline = input.flags[inlineFlag] !== undefined;
|
|
79
|
+
if (hasFile && hasInline)
|
|
80
|
+
throw new UsageError(`pass --${fileFlag} or --${inlineFlag}, not both`);
|
|
81
|
+
if (hasFile)
|
|
82
|
+
return readJsonFlag(input, fileFlag);
|
|
83
|
+
if (hasInline)
|
|
84
|
+
return jsonFlag(input, inlineFlag);
|
|
85
|
+
if (required)
|
|
86
|
+
throw new UsageError(`--${fileFlag} or --${inlineFlag} is required`);
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
57
89
|
export function flagStrings(input, name) {
|
|
58
90
|
const v = input.flags[name];
|
|
59
91
|
return v && v.length > 0 ? v : undefined;
|
package/dist/commands/watch.js
CHANGED
|
@@ -196,15 +196,16 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes, opts = {
|
|
|
196
196
|
}
|
|
197
197
|
const done = Math.max(settledCount(lastCounters), printed.size);
|
|
198
198
|
const total = lastCounters.total || knownTotal || "?";
|
|
199
|
+
const phase = progress.status === "queued" ? "queued" : "running";
|
|
199
200
|
if (tty) {
|
|
200
201
|
spin = (spin + 1) % SPINNER.length;
|
|
201
202
|
const reds = (lastCounters.failed ?? 0) + (lastCounters.errored ?? 0);
|
|
202
|
-
process.stderr.write(`\r\u001b[2K${SPINNER[spin]}
|
|
203
|
+
process.stderr.write(`\r\u001b[2K${SPINNER[spin]} ${phase} ${done}/${total} · ` +
|
|
203
204
|
`${green(`${lastCounters.passed ?? 0}✓`)} ${reds > 0 ? red(`${reds}✗`) : dim("0✗")}`);
|
|
204
205
|
}
|
|
205
206
|
else if (!ctx.json && Date.now() - lastHeartbeat > HEARTBEAT_MS) {
|
|
206
207
|
lastHeartbeat = Date.now();
|
|
207
|
-
ctx.err(dim(`still
|
|
208
|
+
ctx.err(dim(`still ${phase}: ${done}/${total} settled`));
|
|
208
209
|
}
|
|
209
210
|
}
|
|
210
211
|
clearStatusLine();
|
package/dist/config.js
CHANGED
|
@@ -4,6 +4,8 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { CliError } from "./errors.js";
|
|
6
6
|
export const DEFAULT_API_URL = "https://api.beryl.so";
|
|
7
|
+
// Must match SESSION_CAPTURE_EXTENSION_URL in api/app/core/constants.py and the webapp.
|
|
8
|
+
export const SESSION_CAPTURE_EXTENSION_URL = "https://chromewebstore.google.com/detail/beryl/nnjhiapfdhhmmjabnafdaomdmhlolfkp";
|
|
7
9
|
export function withFlagOverrides(config, flags) {
|
|
8
10
|
return {
|
|
9
11
|
...config,
|
package/dist/lint.js
CHANGED
|
@@ -4,6 +4,9 @@ import { ACTION_PLAN_SCHEMA } from "./schema.generated.js";
|
|
|
4
4
|
export const MINTED_IDENTITY_NEEDS_TEARDOWN = "This plan signs up with {{inbox_address}} and reads the mail it triggers, so it " +
|
|
5
5
|
"leaves an account behind on the site. Add an 'after' section that deletes it " +
|
|
6
6
|
"through the app (mark steps optional so a missing account ends the teardown quietly).";
|
|
7
|
+
export const CREATED_ACCOUNT_NEEDS_TEARDOWN = "This plan signs up a fresh user with create_account, so it leaves an account behind " +
|
|
8
|
+
"on the site. Add an 'after' section that deletes it through the app (mark steps " +
|
|
9
|
+
"optional so a missing account ends the teardown quietly).";
|
|
7
10
|
export const MAILBOX_ADDRESS_IN_TEST = "This plan types the standing account's address ({{mailbox_address}}) into the app. " +
|
|
8
11
|
"A test never signs in that way: one that needs to be signed in sets requires_auth " +
|
|
9
12
|
"and rides the account's session; one that needs an address the site can mail cites " +
|
|
@@ -22,6 +25,8 @@ export const OPTIONAL_TEARDOWN_ACTION_MUST_BE_LAST = (label, action) => `${label
|
|
|
22
25
|
"them with an optional 'wait_for' or 'expect' instead, or move this step to the end.";
|
|
23
26
|
export const THIRD_PARTY_UNDECLARED = (p) => `third_party_authorize approves the install on ${p}'s consent page, which needs ` +
|
|
24
27
|
`Beryl's ${p} login: add "${p}" to third_party_integrations`;
|
|
28
|
+
export const THIRD_PARTY_WITH_SIGN_IN_AS = "A test that switches accounts with sign_in_as cannot also approve a third party " +
|
|
29
|
+
"install: the switched-to browser carries no third party login.";
|
|
25
30
|
export const THIRD_PARTY_UNUSED = (p) => `third_party_integrations declares "${p}" but no third_party_authorize step uses it; remove it`;
|
|
26
31
|
const typeOf = (value) => {
|
|
27
32
|
if (value === null)
|
|
@@ -155,7 +160,8 @@ const steps = (section) => Array.isArray(section)
|
|
|
155
160
|
? section.filter((s) => typeof s === "object" && s !== null)
|
|
156
161
|
: [];
|
|
157
162
|
const cites = (handle) => (step) => Object.values(step).some((v) => typeof v === "string" && v.includes(`{{${handle}}}`));
|
|
158
|
-
const
|
|
163
|
+
const INBOX_ADDRESS_RE = /\{\{inbox_address(?:_[2-9])?\}\}/;
|
|
164
|
+
const citesInboxAddress = (step) => Object.values(step).some((v) => typeof v === "string" && INBOX_ADDRESS_RE.test(v));
|
|
159
165
|
const citesMailboxAddress = cites("mailbox_address");
|
|
160
166
|
const TEARDOWN_GUARD_ACTIONS = new Set(["wait_for", "expect"]);
|
|
161
167
|
function optionalPlacementIssues(after) {
|
|
@@ -208,6 +214,9 @@ function thirdPartyIssues(plan) {
|
|
|
208
214
|
const [undeclared] = [...authorized].filter((p) => !declared.has(p)).sort();
|
|
209
215
|
if (undeclared)
|
|
210
216
|
return [{ path: "(root)", message: THIRD_PARTY_UNDECLARED(undeclared) }];
|
|
217
|
+
if (declared.size > 0 && executed(record).some((s) => s.action === "sign_in_as")) {
|
|
218
|
+
return [{ path: "(root)", message: THIRD_PARTY_WITH_SIGN_IN_AS }];
|
|
219
|
+
}
|
|
211
220
|
const [unused] = [...declared].filter((p) => !authorized.has(p)).sort();
|
|
212
221
|
if (unused)
|
|
213
222
|
return [{ path: "third_party_integrations", message: THIRD_PARTY_UNUSED(unused) }];
|
|
@@ -260,6 +269,171 @@ const APP_STRING_FIELDS = [
|
|
|
260
269
|
];
|
|
261
270
|
const sortedList = (values) => [...values].sort().join(", ");
|
|
262
271
|
const sectionSteps = (plan) => ["before", "steps", "after"].flatMap((section) => steps(plan[section]).map((step, i) => ({ path: `${section}[${i}]`, section, step })));
|
|
272
|
+
// The fresh-address handle a create_account step signs up; an omitted value means
|
|
273
|
+
// {{inbox_address}}. Null for any other step, or a value naming no fresh address.
|
|
274
|
+
const createdHandle = (step) => {
|
|
275
|
+
if (step.action !== "create_account")
|
|
276
|
+
return null;
|
|
277
|
+
if (step.value === undefined || step.value === null)
|
|
278
|
+
return "inbox_address";
|
|
279
|
+
if (typeof step.value !== "string")
|
|
280
|
+
return null;
|
|
281
|
+
return /^\s*\{\{\s*(inbox_address(?:_[2-9])?)\s*\}\}\s*$/.exec(step.value)?.[1] ?? null;
|
|
282
|
+
};
|
|
283
|
+
// Mirrors the server: recipient names an alias an earlier step typed, on an await_email.
|
|
284
|
+
function recipientIssues(plan) {
|
|
285
|
+
if (typeof plan !== "object" || plan === null)
|
|
286
|
+
return [];
|
|
287
|
+
const typed = new Set();
|
|
288
|
+
const issues = [];
|
|
289
|
+
for (const { path, step } of sectionSteps(plan)) {
|
|
290
|
+
const recipient = step.recipient;
|
|
291
|
+
if (typeof recipient === "string") {
|
|
292
|
+
if (step.action !== "await_email") {
|
|
293
|
+
issues.push({ path, message: "recipient applies only to an await_email step" });
|
|
294
|
+
}
|
|
295
|
+
else if (!typed.has(recipient)) {
|
|
296
|
+
issues.push({
|
|
297
|
+
path,
|
|
298
|
+
message: `reads mail sent to {{${recipient}}}, but no earlier step types that address ` +
|
|
299
|
+
"into the app, so nothing could have been sent to it.",
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
for (const v of Object.values(step)) {
|
|
304
|
+
if (typeof v !== "string")
|
|
305
|
+
continue;
|
|
306
|
+
for (const m of v.matchAll(/\{\{(inbox_address(?:_[2-9])?)\}\}/g)) {
|
|
307
|
+
if (m[1])
|
|
308
|
+
typed.add(m[1]);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const created = createdHandle(step);
|
|
312
|
+
if (created)
|
|
313
|
+
typed.add(created);
|
|
314
|
+
}
|
|
315
|
+
return issues;
|
|
316
|
+
}
|
|
317
|
+
// The fields a {{handle}} may be cited in (ALL_HANDLE_REF_FIELDS on the server).
|
|
318
|
+
const HANDLE_REF_FIELDS = ["value", "url", "selector", "option", "expect_text"];
|
|
319
|
+
function citedInboxHandles(step) {
|
|
320
|
+
const out = [];
|
|
321
|
+
for (const field of HANDLE_REF_FIELDS) {
|
|
322
|
+
const v = step[field];
|
|
323
|
+
if (typeof v !== "string")
|
|
324
|
+
continue;
|
|
325
|
+
for (const m of v.matchAll(/\{\{\s*(inbox_address(?:_[2-9])?)\s*\}\}/g)) {
|
|
326
|
+
if (m[1])
|
|
327
|
+
out.push(m[1]);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
const created = createdHandle(step);
|
|
331
|
+
if (created)
|
|
332
|
+
out.push(created);
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
// Mirrors the server: create_account names a fresh address no earlier step typed, sits in
|
|
336
|
+
// before or steps, never inside an account's own sign-in plan, and the test tears down.
|
|
337
|
+
function createAccountIssues(plan, provisioning) {
|
|
338
|
+
if (typeof plan !== "object" || plan === null)
|
|
339
|
+
return [];
|
|
340
|
+
const record = plan;
|
|
341
|
+
const typed = new Set();
|
|
342
|
+
const issues = [];
|
|
343
|
+
let creates = false;
|
|
344
|
+
for (const { path, section, step } of sectionSteps(record)) {
|
|
345
|
+
if (!creates &&
|
|
346
|
+
HANDLE_REF_FIELDS.some((f) => typeof step[f] === "string" && /\{\{\s*created_password\s*\}\}/.test(step[f]))) {
|
|
347
|
+
issues.push({
|
|
348
|
+
path,
|
|
349
|
+
message: "cites {{created_password}}, the password a create_account step signs up with, " +
|
|
350
|
+
"but no earlier step is a create_account.",
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
if (step.action === "create_account") {
|
|
354
|
+
creates = true;
|
|
355
|
+
const handle = createdHandle(step);
|
|
356
|
+
if (provisioning) {
|
|
357
|
+
issues.push({
|
|
358
|
+
path,
|
|
359
|
+
message: "An account's own signup or sign-in plan is what create_account replays, so it " +
|
|
360
|
+
"cannot contain one; create_account is for tests.",
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
else if (handle === null) {
|
|
364
|
+
issues.push({
|
|
365
|
+
path,
|
|
366
|
+
message: "create_account value names the fresh address the new user signs up with: " +
|
|
367
|
+
"{{inbox_address}} or {{inbox_address_2}} to {{inbox_address_9}}",
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
else if (section === "after") {
|
|
371
|
+
issues.push({
|
|
372
|
+
path,
|
|
373
|
+
message: "create_account signs up a user in the teardown; create it in 'before' or 'steps'.",
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
else if (typed.has(handle)) {
|
|
377
|
+
issues.push({
|
|
378
|
+
path,
|
|
379
|
+
message: `create_account signs up {{${handle}}}, but an earlier step already types that ` +
|
|
380
|
+
"address, so that user would sign up twice. Give create_account a fresh handle.",
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
for (const handle of citedInboxHandles(step))
|
|
385
|
+
typed.add(handle);
|
|
386
|
+
}
|
|
387
|
+
if (creates && !provisioning && !steps(record.after).some((s) => s.action !== "expect")) {
|
|
388
|
+
issues.push({ path: "(root)", message: CREATED_ACCOUNT_NEEDS_TEARDOWN });
|
|
389
|
+
}
|
|
390
|
+
return issues;
|
|
391
|
+
}
|
|
392
|
+
// Mirrors the server: account names a test account label or a fresh-address handle the
|
|
393
|
+
// plan types, on a sign_in_as only, and never inside an account's own sign-in plan.
|
|
394
|
+
function signInAsIssues(plan, provisioning) {
|
|
395
|
+
if (typeof plan !== "object" || plan === null)
|
|
396
|
+
return [];
|
|
397
|
+
const all = sectionSteps(plan);
|
|
398
|
+
const cited = new Set();
|
|
399
|
+
for (const { step } of all) {
|
|
400
|
+
for (const handle of citedInboxHandles(step))
|
|
401
|
+
cited.add(handle);
|
|
402
|
+
}
|
|
403
|
+
const issues = [];
|
|
404
|
+
for (const { path, step } of all) {
|
|
405
|
+
const account = step.account;
|
|
406
|
+
if (step.action !== "sign_in_as") {
|
|
407
|
+
if (account !== undefined && account !== null) {
|
|
408
|
+
issues.push({ path, message: "account applies only to a sign_in_as step" });
|
|
409
|
+
}
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (provisioning) {
|
|
413
|
+
issues.push({
|
|
414
|
+
path,
|
|
415
|
+
message: "An account's own signup or sign-in plan acts as that account only; " +
|
|
416
|
+
"sign_in_as is for tests.",
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
else if (typeof account === "string" && account.includes("{{")) {
|
|
420
|
+
issues.push({
|
|
421
|
+
path,
|
|
422
|
+
message: "sign_in_as account names the account bare (admin, inbox_address_2), not as a {{handle}}",
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
else if (typeof account === "string" &&
|
|
426
|
+
/^inbox_address(?:_[2-9])?$/.test(account) &&
|
|
427
|
+
!cited.has(account)) {
|
|
428
|
+
issues.push({
|
|
429
|
+
path,
|
|
430
|
+
message: `switches to the fresh user {{${account}}}, but no step types that address ` +
|
|
431
|
+
"into the app, so that user is never created.",
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return issues;
|
|
436
|
+
}
|
|
263
437
|
const appStepRules = (p) => [
|
|
264
438
|
(s) => p === "ios" && s.action === "back"
|
|
265
439
|
? "is not available on ios: iOS has no back button; tap the app's own back control."
|
|
@@ -357,8 +531,8 @@ const COUNT_KINDS = new Set(["gone", "have_count"]);
|
|
|
357
531
|
// Shorter typed values ("1", "ok") would "name" half the page's buttons.
|
|
358
532
|
const MIN_TYPED_NAME_LEN = 3;
|
|
359
533
|
const SELECTOR_TEXT_RE = /text=["']?([^"'>]+?)["']?\s*(?:>>|$)|(?:has-text|text-is|text)\(\s*["']([^"']+)["']\s*\)|\[(?:name|title|aria-label)\s*[*^$]?=\s*["']([^"']+)["']/g;
|
|
360
|
-
//
|
|
361
|
-
const anchored = (text) => typeof text === "string" && /\{\{\s*(unique|uuid|inbox_address)\s*\}\}/.test(text);
|
|
534
|
+
// A fresh address is minted per execution too, so a record named by one is its own.
|
|
535
|
+
const anchored = (text) => typeof text === "string" && /\{\{\s*(unique|uuid|inbox_address(?:_[2-9])?)\s*\}\}/.test(text);
|
|
362
536
|
const selectorTexts = (selector) => typeof selector === "string"
|
|
363
537
|
? [...selector.matchAll(SELECTOR_TEXT_RE)].map((m) => m[1] ?? m[2] ?? m[3] ?? "")
|
|
364
538
|
: [];
|
|
@@ -417,6 +591,9 @@ export function lintPlan(plan, opts = {}) {
|
|
|
417
591
|
...deviceIssues(plan),
|
|
418
592
|
...thirdPartyIssues(plan),
|
|
419
593
|
...identityIssues(plan, opts.provisioning === true),
|
|
594
|
+
...recipientIssues(plan),
|
|
595
|
+
...signInAsIssues(plan, opts.provisioning === true),
|
|
596
|
+
...createAccountIssues(plan, opts.provisioning === true),
|
|
420
597
|
...(opts.access === undefined ? [] : accessIssues(plan, opts.access)),
|
|
421
598
|
];
|
|
422
599
|
}
|