@beryl-so/cli 0.62.1 → 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/beryl-test-skill.js +5 -3
- package/dist/commands/tests.js +1 -1
- package/dist/commands/workspaces.js +2 -2
- package/dist/lint.js +57 -2
- package/package.json +1 -1
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
|
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: [
|
|
@@ -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/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/package.json
CHANGED