scenescout 3.18.0 → 3.19.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/CHANGELOG.md +26 -0
- package/README.md +3 -1
- package/dist/check-run.js +64 -8
- package/dist/cli.js +6 -4
- package/dist/engine/baseline.js +43 -0
- package/dist/engine/browser.js +215 -112
- package/dist/engine/check.js +24 -5
- package/dist/engine/dedup.js +11 -0
- package/dist/engine/design.js +4 -0
- package/dist/engine/export.js +200 -1
- package/dist/engine/flow.js +171 -4
- package/dist/export-run.js +231 -55
- package/package.json +2 -2
- package/skills/scenescout/SKILL.md +1 -1
package/dist/engine/export.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
15
|
import { readFindingPicture } from "./capture.js";
|
|
16
|
+
import { CHECK_RULES, WORTH_A_LOOK_RULES } from "./check.js";
|
|
16
17
|
import { readFindings } from "./ci.js";
|
|
17
18
|
import { isWorthALook, redactSecrets } from "./memory.js";
|
|
18
19
|
import { retryAfterMs } from "./provider.js";
|
|
@@ -31,6 +32,7 @@ export const EXPORT_OPTION_NAMES = [
|
|
|
31
32
|
"jira-link-type",
|
|
32
33
|
"jira-update",
|
|
33
34
|
"project",
|
|
35
|
+
"from",
|
|
34
36
|
"min-severity",
|
|
35
37
|
"only",
|
|
36
38
|
"max-issues",
|
|
@@ -163,7 +165,7 @@ export function parseExportArgs(args, cwd, env) {
|
|
|
163
165
|
for (let i = 0; i < args.length; i++) {
|
|
164
166
|
const a = args[i];
|
|
165
167
|
if (!a.startsWith("--"))
|
|
166
|
-
return { ok: false, error: `unexpected argument ${a}: export takes options only (the findings come from --project)` };
|
|
168
|
+
return { ok: false, error: `unexpected argument ${a}: export takes options only (the findings come from --project, or --from a check.json or ci.json)` };
|
|
167
169
|
const eq = a.indexOf("=");
|
|
168
170
|
const name = eq > 0 ? a.slice(2, eq) : a.slice(2);
|
|
169
171
|
if (CREDENTIAL_FLAGS.has(name))
|
|
@@ -276,6 +278,9 @@ export function parseExportArgs(args, cwd, env) {
|
|
|
276
278
|
const screenshots = flags.get("screenshots") ?? "on";
|
|
277
279
|
if (screenshots !== "on" && screenshots !== "off")
|
|
278
280
|
return { ok: false, error: "--screenshots must be on or off" };
|
|
281
|
+
const from = flags.get("from");
|
|
282
|
+
if (from !== undefined && from.trim() === "")
|
|
283
|
+
return { ok: false, error: "--from needs the path of a check.json or a ci.json" };
|
|
279
284
|
if (flags.has("dry-run") && flags.has("yes"))
|
|
280
285
|
return { ok: false, error: "--dry-run and --yes contradict each other: give one" };
|
|
281
286
|
const resolve = (p) => (p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p) ? p : `${cwd.replace(/[\\/]$/, "")}/${p}`);
|
|
@@ -284,6 +289,7 @@ export function parseExportArgs(args, cwd, env) {
|
|
|
284
289
|
options: {
|
|
285
290
|
...target,
|
|
286
291
|
projectDir: resolve(flags.get("project") ?? cwd),
|
|
292
|
+
...(from !== undefined ? { from: resolve(from) } : {}),
|
|
287
293
|
minSeverity: minSeverity,
|
|
288
294
|
...(only ? { only } : {}),
|
|
289
295
|
maxIssues,
|
|
@@ -371,6 +377,123 @@ function timeOf(iso) {
|
|
|
371
377
|
const t = typeof iso === "string" ? Date.parse(iso) : NaN;
|
|
372
378
|
return Number.isNaN(t) ? 0 : t;
|
|
373
379
|
}
|
|
380
|
+
// ── a check.json or a ci.json as the source ─────────────────────────────────
|
|
381
|
+
/** The prefix of a check issue's finding id: the issue's fingerprint follows it. */
|
|
382
|
+
export const CHECK_FINDING_PREFIX = "check-";
|
|
383
|
+
const MAX_ROUTES_NAMED = 10;
|
|
384
|
+
/** A route of a check, as an address on the checked site; the site itself for a route that is not a path, such as the shared chrome. */
|
|
385
|
+
function addressOf(base, route) {
|
|
386
|
+
if (!route.startsWith("/"))
|
|
387
|
+
return base;
|
|
388
|
+
try {
|
|
389
|
+
return new URL(route, base).href;
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
return route;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* A check.json issue or worth-a-look observation as a finding. Its id is its
|
|
397
|
+
* fingerprint, which the check keys on the rule and the evidence and so keeps
|
|
398
|
+
* from run to run: a later check of the same defect re-exports to the same
|
|
399
|
+
* issue. Null for an entry that does not read.
|
|
400
|
+
*/
|
|
401
|
+
function checkEntryFinding(entry, base, generatedAt, look) {
|
|
402
|
+
if (!isObject(entry) || typeof entry.rule !== "string" || typeof entry.evidence !== "string" || typeof entry.fingerprint !== "string")
|
|
403
|
+
return null;
|
|
404
|
+
const id = `${CHECK_FINDING_PREFIX}${entry.fingerprint}`;
|
|
405
|
+
if (!FINDING_ID_RE.test(id))
|
|
406
|
+
return null;
|
|
407
|
+
const routes = (Array.isArray(entry.routes) ? entry.routes : []).filter((r) => typeof r === "string");
|
|
408
|
+
const severity = look ? "low" : entry.severity;
|
|
409
|
+
if (!SEVERITIES.includes(severity))
|
|
410
|
+
return null;
|
|
411
|
+
if (look && typeof entry.convention !== "string")
|
|
412
|
+
return null;
|
|
413
|
+
const rules = look ? WORTH_A_LOOK_RULES : CHECK_RULES;
|
|
414
|
+
const rule = Object.prototype.hasOwnProperty.call(rules, entry.rule) ? rules[entry.rule] : null;
|
|
415
|
+
const first = routes[0] ?? "";
|
|
416
|
+
const named = routes.slice(0, MAX_ROUTES_NAMED).join(", ") + (routes.length > MAX_ROUTES_NAMED ? `, and ${routes.length - MAX_ROUTES_NAMED} more` : "");
|
|
417
|
+
const embed = typeof entry.embed === "string" ? entry.embed : null;
|
|
418
|
+
const flow = typeof entry.flow === "string" ? entry.flow : null;
|
|
419
|
+
return {
|
|
420
|
+
id,
|
|
421
|
+
severity: severity,
|
|
422
|
+
category: entry.rule,
|
|
423
|
+
title: `${rule?.title ?? entry.rule}: ${oneLine(entry.evidence, 120)}`,
|
|
424
|
+
detail: [
|
|
425
|
+
rule?.help ?? "",
|
|
426
|
+
routes.length ? `Seen on ${routes.length === 1 ? "1 page" : `${routes.length} pages`}: ${named}.` : "",
|
|
427
|
+
embed ? `In a frame from ${embed}.` : "",
|
|
428
|
+
`Found by scenescout check (rule ${entry.rule}).`,
|
|
429
|
+
]
|
|
430
|
+
.filter(Boolean)
|
|
431
|
+
.join(" "),
|
|
432
|
+
evidence: entry.evidence,
|
|
433
|
+
url: addressOf(base, first),
|
|
434
|
+
state: first,
|
|
435
|
+
repro: flow ? [`Replay the saved flow ${flow}`] : first ? [`Open ${first}`] : [],
|
|
436
|
+
foundAt: generatedAt,
|
|
437
|
+
runs: 1,
|
|
438
|
+
status: "open",
|
|
439
|
+
...(look ? { tier: "worth_a_look", convention: entry.convention } : {}),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
/** A ci.json finding as a finding: the same id as in the run's memory, so an export from either files it once. Null for an entry that does not read. */
|
|
443
|
+
function ciEntryFinding(entry, base, writtenAt) {
|
|
444
|
+
if (!isObject(entry) || typeof entry.id !== "string" || typeof entry.title !== "string" || !SEVERITIES.includes(entry.severity))
|
|
445
|
+
return null;
|
|
446
|
+
const route = typeof entry.path === "string" ? entry.path : "";
|
|
447
|
+
const look = entry.tier === "worth-a-look";
|
|
448
|
+
return {
|
|
449
|
+
id: entry.id,
|
|
450
|
+
severity: entry.severity,
|
|
451
|
+
category: typeof entry.category === "string" ? entry.category : "other",
|
|
452
|
+
title: entry.title,
|
|
453
|
+
detail: "",
|
|
454
|
+
...(typeof entry.evidence === "string" ? { evidence: entry.evidence } : {}),
|
|
455
|
+
url: addressOf(base, route),
|
|
456
|
+
state: route,
|
|
457
|
+
repro: [],
|
|
458
|
+
foundAt: writtenAt,
|
|
459
|
+
runs: 1,
|
|
460
|
+
status: "open",
|
|
461
|
+
...(look ? { tier: "worth_a_look", convention: typeof entry.convention === "string" ? entry.convention : "" } : {}),
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* The findings of a check.json or a ci.json, as `--from` exports them. An
|
|
466
|
+
* entry that does not read is kept as it is, so selectFindings counts it as
|
|
467
|
+
* unreadable rather than it vanishing. `writtenAt` dates a ci.json's findings,
|
|
468
|
+
* which carry no time of their own: the time the file was written.
|
|
469
|
+
*/
|
|
470
|
+
export function findingsFromResult(raw, writtenAt) {
|
|
471
|
+
if (!isObject(raw))
|
|
472
|
+
return { ok: false, error: "it is not a check.json or a ci.json" };
|
|
473
|
+
const base = typeof raw.url === "string" ? raw.url : "";
|
|
474
|
+
if (raw.tool === "scenescout-check") {
|
|
475
|
+
if (!Array.isArray(raw.issues))
|
|
476
|
+
return { ok: false, error: "it is a check.json with no list of issues" };
|
|
477
|
+
const generatedAt = typeof raw.generatedAt === "string" ? raw.generatedAt : writtenAt;
|
|
478
|
+
const looks = Array.isArray(raw.worthALook) ? raw.worthALook : [];
|
|
479
|
+
return {
|
|
480
|
+
ok: true,
|
|
481
|
+
value: {
|
|
482
|
+
kind: "check",
|
|
483
|
+
findings: [
|
|
484
|
+
...raw.issues.map((e) => checkEntryFinding(e, base, generatedAt, false) ?? e),
|
|
485
|
+
...looks.map((e) => checkEntryFinding(e, base, generatedAt, true) ?? e),
|
|
486
|
+
],
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
if (raw.tool === "scenescout" && raw.command === "ci") {
|
|
491
|
+
if (!Array.isArray(raw.findings))
|
|
492
|
+
return { ok: false, error: "it is a ci.json with no list of findings" };
|
|
493
|
+
return { ok: true, value: { kind: "ci", findings: raw.findings.map((e) => ciEntryFinding(e, base, writtenAt) ?? e) } };
|
|
494
|
+
}
|
|
495
|
+
return { ok: false, error: "it is not a check.json or a ci.json (its tool field names neither scenescout check nor scenescout ci)" };
|
|
496
|
+
}
|
|
374
497
|
const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
375
498
|
const MAX_CRITERIA_PER_ISSUE = 10;
|
|
376
499
|
/**
|
|
@@ -800,6 +923,82 @@ export function rememberFiled(map, id, issue) {
|
|
|
800
923
|
if (!held || (issue.open && !held.open) || (issue.open === held.open && issue.number < held.number))
|
|
801
924
|
map.set(id, issue);
|
|
802
925
|
}
|
|
926
|
+
// ── the local record of what was filed ──────────────────────────────────────
|
|
927
|
+
/**
|
|
928
|
+
* A tracker's listing of labelled issues can lag behind a create: GitHub's
|
|
929
|
+
* label filter and Jira's search are both eventually consistent. So each
|
|
930
|
+
* export keeps, beside the memory, the issue it filed for each finding, and
|
|
931
|
+
* a later export on the same machine reads that issue back by its number,
|
|
932
|
+
* which is consistent, before it trusts the listing's silence.
|
|
933
|
+
*/
|
|
934
|
+
export const EXPORT_RECORD_FILE = "exported.json";
|
|
935
|
+
/** How long a Jira create that may have been made holds its finding back, unless the issue turns up sooner. Jira's search can take minutes. */
|
|
936
|
+
export const UNCERTAIN_HOLD_MS = 15 * 60_000;
|
|
937
|
+
/** The key a tracker's entries are kept under: the API's address and the repository or project, so two trackers never share an entry. */
|
|
938
|
+
export function recordKey(t) {
|
|
939
|
+
return t.to === "github"
|
|
940
|
+
? `github ${t.github.apiUrl.replace(/\/+$/, "")} ${t.github.repo}`
|
|
941
|
+
: `jira ${t.jira.baseUrl.replace(/\/+$/, "")} ${t.jira.projectKey}`;
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* The record file's text, read. None yet is an empty record. A file that
|
|
945
|
+
* cannot be read is refused rather than taken as empty: empty would let an
|
|
946
|
+
* export file again what the lagging listing does not show yet.
|
|
947
|
+
*/
|
|
948
|
+
export function parseExportRecord(text) {
|
|
949
|
+
if (text === null)
|
|
950
|
+
return { ok: true, value: {} };
|
|
951
|
+
let raw;
|
|
952
|
+
try {
|
|
953
|
+
raw = JSON.parse(text);
|
|
954
|
+
}
|
|
955
|
+
catch (err) {
|
|
956
|
+
return { ok: false, error: `it is not JSON (${err instanceof Error ? err.message : String(err)})` };
|
|
957
|
+
}
|
|
958
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
959
|
+
return { ok: false, error: "it is not an object" };
|
|
960
|
+
const { version, trackers } = raw;
|
|
961
|
+
if (version !== 1)
|
|
962
|
+
return { ok: false, error: `it is version ${oneLine(version, 20)}, which this version does not read` };
|
|
963
|
+
if (!trackers || typeof trackers !== "object" || Array.isArray(trackers))
|
|
964
|
+
return { ok: false, error: "it holds no trackers" };
|
|
965
|
+
const value = {};
|
|
966
|
+
for (const [key, entries] of Object.entries(trackers)) {
|
|
967
|
+
if (!entries || typeof entries !== "object" || Array.isArray(entries))
|
|
968
|
+
return { ok: false, error: `its entry for ${oneLine(key, 80)} is not an object` };
|
|
969
|
+
const kept = {};
|
|
970
|
+
for (const [id, e] of Object.entries(entries)) {
|
|
971
|
+
const x = e && typeof e === "object" ? e : {};
|
|
972
|
+
if (!FINDING_ID_RE.test(id) || typeof x.at !== "string")
|
|
973
|
+
return { ok: false, error: `its entry for finding ${oneLine(id, 80)} cannot be read` };
|
|
974
|
+
if (x.uncertain === true)
|
|
975
|
+
kept[id] = { uncertain: true, at: x.at };
|
|
976
|
+
else if (typeof x.ref === "string" && Number.isInteger(x.number) && typeof x.url === "string")
|
|
977
|
+
kept[id] = { ref: x.ref, number: x.number, url: x.url, at: x.at };
|
|
978
|
+
else
|
|
979
|
+
return { ok: false, error: `its entry for finding ${id} cannot be read` };
|
|
980
|
+
}
|
|
981
|
+
value[key] = kept;
|
|
982
|
+
}
|
|
983
|
+
return { ok: true, value };
|
|
984
|
+
}
|
|
985
|
+
/** The record with one finding's entry set, as the text to write. The record passed in is not changed. */
|
|
986
|
+
export function recordWith(record, key, id, entry) {
|
|
987
|
+
const trackers = { ...record, [key]: { ...(record[key] ?? {}), [id]: entry } };
|
|
988
|
+
return JSON.stringify({ version: 1, trackers }, null, 2) + "\n";
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Whether a create that may have been made still holds its finding back: for
|
|
992
|
+
* UNCERTAIN_HOLD_MS after it. A time that cannot be read, or one further
|
|
993
|
+
* ahead than that (a clock set wrong), holds nothing, so no finding is held
|
|
994
|
+
* back for good.
|
|
995
|
+
*/
|
|
996
|
+
export function uncertainHolds(entry, nowMs) {
|
|
997
|
+
if (!("uncertain" in entry))
|
|
998
|
+
return false;
|
|
999
|
+
const at = Date.parse(entry.at);
|
|
1000
|
+
return Number.isFinite(at) && Math.abs(nowMs - at) < UNCERTAIN_HOLD_MS;
|
|
1001
|
+
}
|
|
803
1002
|
// ── talking to a tracker ────────────────────────────────────────────────────
|
|
804
1003
|
/** The longest a rate limit may ask an export to wait before it gives up and says when to try again. */
|
|
805
1004
|
export const MAX_RATE_WAIT_MS = 60_000;
|
package/dist/engine/flow.js
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
* gate naming the flow and the step (ADR 12).
|
|
9
9
|
*
|
|
10
10
|
* The steps are `scout_run_plan`'s steps, so a plan that worked in an
|
|
11
|
-
* exploratory run can be saved as it is, plus
|
|
12
|
-
* need for: text is visible,
|
|
11
|
+
* exploratory run can be saved as it is, plus four assertions a plan has no
|
|
12
|
+
* need for: text is visible, an element is in a state (visible, hidden,
|
|
13
|
+
* enabled, disabled, checked or unchecked), the URL matches, and a request
|
|
14
|
+
* answered with a status.
|
|
13
15
|
*
|
|
14
16
|
* Everything here is pure: the schema and its error messages, the target
|
|
15
17
|
* grammar, and the rules that decide whether a URL or a request matched. The
|
|
@@ -19,7 +21,9 @@ import fs from "node:fs";
|
|
|
19
21
|
import path from "node:path";
|
|
20
22
|
import { z } from "zod";
|
|
21
23
|
import { beaconResourceType, BROWSER_ENGINES } from "../browsers.js";
|
|
24
|
+
import { FIXTURE_KINDS } from "./fixtures.js";
|
|
22
25
|
import { MEMORY_DIRNAME, SELF_IGNORE_KEEP } from "./memory.js";
|
|
26
|
+
import { validateRoleName } from "./profiles.js";
|
|
23
27
|
/** Most steps one flow may hold. A longer flow is several flows, and each would fail more usefully on its own. */
|
|
24
28
|
export const MAX_FLOW_STEPS = 50;
|
|
25
29
|
/** Most flows one check replays. */
|
|
@@ -137,6 +141,48 @@ export function parseTarget(target) {
|
|
|
137
141
|
const name = role[2] ?? role[3] ?? role[4];
|
|
138
142
|
return name === undefined ? { by: "role", role: role[1].toLowerCase() } : { by: "role", role: role[1].toLowerCase(), name };
|
|
139
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Why a step cannot act on a target that is visible, or null when it can: a
|
|
146
|
+
* click or a select needs the control enabled, typing needs it editable. The
|
|
147
|
+
* report leads with this, then the action limit's hint, which still applies
|
|
148
|
+
* when the app enables the control later than the limit allows.
|
|
149
|
+
*/
|
|
150
|
+
export function notActionable(action, facts) {
|
|
151
|
+
if (!facts.enabled)
|
|
152
|
+
return "is visible but disabled";
|
|
153
|
+
if (action === "type" && facts.editable === false)
|
|
154
|
+
return "is visible but read-only";
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The states an expect-element step can ask of its target. `hidden` also holds
|
|
159
|
+
* when nothing matches: "the receipt was never shown" and "the dialog closed"
|
|
160
|
+
* are both the absence of a visible element.
|
|
161
|
+
*/
|
|
162
|
+
export const ELEMENT_STATES = ["visible", "hidden", "enabled", "disabled", "checked", "unchecked"];
|
|
163
|
+
/**
|
|
164
|
+
* Whether an element in the state `facts` describes satisfies `state`, and if
|
|
165
|
+
* not, what is true instead, in the words the report uses. `facts` is null when
|
|
166
|
+
* nothing matches the target.
|
|
167
|
+
*/
|
|
168
|
+
export function elementStateMatches(state, facts) {
|
|
169
|
+
if (state === "hidden")
|
|
170
|
+
return !facts || !facts.visible ? { ok: true } : { ok: false, actual: "is visible" };
|
|
171
|
+
if (!facts)
|
|
172
|
+
return { ok: false, actual: "matches nothing" };
|
|
173
|
+
if (!facts.visible)
|
|
174
|
+
return { ok: false, actual: "is not visible" };
|
|
175
|
+
if (state === "visible")
|
|
176
|
+
return { ok: true };
|
|
177
|
+
if (state === "enabled" || state === "disabled") {
|
|
178
|
+
if (facts.enabled === null)
|
|
179
|
+
return { ok: false, actual: "cannot be enabled or disabled" };
|
|
180
|
+
return facts.enabled === (state === "enabled") ? { ok: true } : { ok: false, actual: facts.enabled ? "is enabled" : "is disabled" };
|
|
181
|
+
}
|
|
182
|
+
if (facts.checked === null)
|
|
183
|
+
return { ok: false, actual: "cannot be checked" };
|
|
184
|
+
return facts.checked === (state === "checked") ? { ok: true } : { ok: false, actual: facts.checked ? "is checked" : "is unchecked" };
|
|
185
|
+
}
|
|
140
186
|
const REQUEST_RE = /^(GET|POST|PUT|PATCH|DELETE|HEAD) (\/\S*)$/;
|
|
141
187
|
function isRegex(pattern) {
|
|
142
188
|
try {
|
|
@@ -159,13 +205,26 @@ const target = z
|
|
|
159
205
|
});
|
|
160
206
|
}
|
|
161
207
|
});
|
|
162
|
-
|
|
208
|
+
/** Most times a repeat step may run its steps. A longer loop is a page that should be reached another way. */
|
|
209
|
+
export const MAX_REPEATS = 100;
|
|
210
|
+
const SINGLE_STEP_SCHEMAS = [
|
|
163
211
|
z.object({ action: z.literal("navigate"), target: z.string().regex(/^\//, "must be a path on the app, starting with /") }).strict(),
|
|
164
212
|
z.object({ action: z.literal("click"), target }).strict(),
|
|
165
213
|
z.object({ action: z.literal("type"), target, value: z.string(), pressEnter: z.boolean().optional(), replace: z.boolean().optional() }).strict(),
|
|
166
214
|
z.object({ action: z.literal("select"), target, value: z.string() }).strict(),
|
|
167
215
|
z.object({ action: z.literal("press"), value: z.string().min(1, "names the key to press, e.g. Enter") }).strict(),
|
|
216
|
+
// A small valid file generated for the input (its kind from `fixture`, else the input's accept attribute), as
|
|
217
|
+
// scout_upload attaches one. `target` is the file input or the control that opens its chooser; absent, the page's only file input.
|
|
218
|
+
z
|
|
219
|
+
.object({
|
|
220
|
+
action: z.literal("upload"),
|
|
221
|
+
target: target.optional(),
|
|
222
|
+
fixture: z.enum(FIXTURE_KINDS).optional(),
|
|
223
|
+
name: z.string().min(1).max(120).optional(),
|
|
224
|
+
})
|
|
225
|
+
.strict(),
|
|
168
226
|
z.object({ action: z.literal("expect-text"), text: z.string().min(1, "is the text that must be visible") }).strict(),
|
|
227
|
+
z.object({ action: z.literal("expect-element"), target, state: z.enum(ELEMENT_STATES) }).strict(),
|
|
169
228
|
z.object({ action: z.literal("expect-url"), pattern: z.string().min(1).refine(isRegex, { message: "is not a valid regular expression" }) }).strict(),
|
|
170
229
|
z
|
|
171
230
|
.object({
|
|
@@ -175,12 +234,48 @@ const STEP_SCHEMAS = [
|
|
|
175
234
|
})
|
|
176
235
|
.strict(),
|
|
177
236
|
];
|
|
237
|
+
const singleStepSchema = z.discriminatedUnion("action", [...SINGLE_STEP_SCHEMAS]);
|
|
238
|
+
const isRepeatable = (s) => s.action === "click" || s.action === "type" || s.action === "select" || s.action === "press";
|
|
239
|
+
const isRepeatCondition = (s) => s.action === "expect-text" || s.action === "expect-element" || s.action === "expect-url";
|
|
240
|
+
/**
|
|
241
|
+
* Repeat some actions until a condition holds: paging through a document until
|
|
242
|
+
* the Continue button is enabled, pressing Next until the last slide shows. The
|
|
243
|
+
* condition is checked first, so a page already in the wanted state runs the
|
|
244
|
+
* actions no times; it fails when the condition still does not hold after `max`.
|
|
245
|
+
*/
|
|
246
|
+
const repeatSchema = z
|
|
247
|
+
.object({
|
|
248
|
+
action: z.literal("repeat"),
|
|
249
|
+
steps: z
|
|
250
|
+
.array(singleStepSchema)
|
|
251
|
+
.min(1, "needs at least one step to repeat")
|
|
252
|
+
.max(10, "holds at most 10 steps")
|
|
253
|
+
.refine((steps) => steps.every(isRepeatable), { message: "may only hold click, type, select and press steps" }),
|
|
254
|
+
until: singleStepSchema.refine(isRepeatCondition, { message: "must be an expect-text, expect-element or expect-url step" }),
|
|
255
|
+
max: z.number().int().min(1).max(MAX_REPEATS, `is at most ${MAX_REPEATS}`),
|
|
256
|
+
})
|
|
257
|
+
.strict();
|
|
258
|
+
const STEP_SCHEMAS = [...SINGLE_STEP_SCHEMAS, repeatSchema];
|
|
178
259
|
const stepSchema = z.discriminatedUnion("action", [...STEP_SCHEMAS]);
|
|
179
260
|
export const FLOW_ACTIONS = STEP_SCHEMAS.map((s) => s.shape.action.value);
|
|
180
261
|
const flowSchema = z
|
|
181
262
|
.object({
|
|
182
263
|
name: z.string().min(1).max(100).optional(),
|
|
183
264
|
description: z.string().max(500).optional(),
|
|
265
|
+
/**
|
|
266
|
+
* Who walks the flow: a role whose sign-in `scenescout login --role` saved in the project. Absent, the flow runs
|
|
267
|
+
* in the check's own session. Flows run in file-name order, so one role's flow can pick up what another's left.
|
|
268
|
+
*/
|
|
269
|
+
role: z
|
|
270
|
+
.string()
|
|
271
|
+
.optional()
|
|
272
|
+
.superRefine((role, ctx) => {
|
|
273
|
+
if (role === undefined)
|
|
274
|
+
return;
|
|
275
|
+
const checked = validateRoleName(role);
|
|
276
|
+
if (!checked.ok)
|
|
277
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: checked.error });
|
|
278
|
+
}),
|
|
184
279
|
steps: z.array(stepSchema).min(1, "needs at least one step").max(MAX_FLOW_STEPS, `holds at most ${MAX_FLOW_STEPS} steps`),
|
|
185
280
|
})
|
|
186
281
|
.strict()
|
|
@@ -232,7 +327,10 @@ export function parseFlow(text, file) {
|
|
|
232
327
|
const parsed = parseJsonFile(text, file, flowSchema);
|
|
233
328
|
if (!parsed.ok)
|
|
234
329
|
return parsed;
|
|
235
|
-
|
|
330
|
+
const flow = { name: parsed.data.name ?? file.replace(/\.json$/i, ""), file, steps: parsed.data.steps };
|
|
331
|
+
if (parsed.data.role !== undefined)
|
|
332
|
+
flow.role = parsed.data.role;
|
|
333
|
+
return { ok: true, flow };
|
|
236
334
|
}
|
|
237
335
|
/**
|
|
238
336
|
* Read every `*.json` in a flows directory, in name order. Throws on the first
|
|
@@ -313,14 +411,27 @@ export function describeStep(step) {
|
|
|
313
411
|
return `type into ${step.target}${step.pressEnter ? " and press Enter" : ""}`;
|
|
314
412
|
case "press":
|
|
315
413
|
return `press ${step.value}`;
|
|
414
|
+
case "upload":
|
|
415
|
+
return `upload ${step.fixture ? `a ${step.fixture} file` : "a file"}${step.name ? ` named ${JSON.stringify(step.name)}` : ""} ${step.target ? `to ${step.target}` : "to the page's file input"}`;
|
|
316
416
|
case "expect-text":
|
|
317
417
|
return `expect text ${JSON.stringify(step.text)}`;
|
|
418
|
+
case "expect-element":
|
|
419
|
+
return `expect ${step.target} to be ${step.state}`;
|
|
318
420
|
case "expect-url":
|
|
319
421
|
return `expect the URL to match /${step.pattern}/`;
|
|
320
422
|
case "expect-request":
|
|
321
423
|
return `expect ${step.request} → ${step.status}`;
|
|
424
|
+
case "repeat":
|
|
425
|
+
return `repeat ${step.steps.map(describeStep).join(", then ")} until ${describeStep(step.until)} (at most ${step.max} times)`;
|
|
322
426
|
}
|
|
323
427
|
}
|
|
428
|
+
/**
|
|
429
|
+
* Why a repeat step failed when its condition never held: what never became
|
|
430
|
+
* true, how many times the actions ran, and what the last check found.
|
|
431
|
+
*/
|
|
432
|
+
export function repeatFailure(until, max, reason) {
|
|
433
|
+
return `${describeStep(until)} still did not hold after repeating ${max} time(s): ${reason}`;
|
|
434
|
+
}
|
|
324
435
|
/** Whether a step acts on the page (and so opens a new window for expect-request) rather than reading it. */
|
|
325
436
|
export function isAction(step) {
|
|
326
437
|
return !step.action.startsWith("expect-");
|
|
@@ -400,6 +511,62 @@ export function splitRefusals(refusals, appOrigin) {
|
|
|
400
511
|
}
|
|
401
512
|
return { charged, background };
|
|
402
513
|
}
|
|
514
|
+
/** A value taken from the environment when the flow runs: `${env:NAME}` in a type or select step's value. */
|
|
515
|
+
const ENV_REF = /\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
516
|
+
/** Shortest substituted value masked in the check's output. Shorter ones would mask ordinary words and numbers. */
|
|
517
|
+
export const MIN_MASKED_ENV_VALUE = 4;
|
|
518
|
+
/** Every step that takes a value, the steps inside a repeat included. */
|
|
519
|
+
function valueSteps(steps) {
|
|
520
|
+
return steps.flatMap((s) => s.action === "repeat" ? valueSteps(s.steps) : "value" in s && s.action !== "press" ? [s] : []);
|
|
521
|
+
}
|
|
522
|
+
/** The environment variables a flow's values name, each once. */
|
|
523
|
+
export function flowEnvNames(flow) {
|
|
524
|
+
return [...new Set(valueSteps(flow.steps).flatMap((s) => [...s.value.matchAll(ENV_REF)].map((m) => m[1])))];
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* The flow with every `${env:NAME}` in a type or select value replaced by that
|
|
528
|
+
* variable, and the values used, for masking; or the names that are not set.
|
|
529
|
+
* A secret such as a one-time code or a password then lives in the CI's secret
|
|
530
|
+
* store, not in the flow file.
|
|
531
|
+
*/
|
|
532
|
+
export function resolveFlowEnv(flow, env) {
|
|
533
|
+
const names = flowEnvNames(flow);
|
|
534
|
+
const missing = names.filter((n) => env[n] === undefined);
|
|
535
|
+
if (missing.length > 0)
|
|
536
|
+
return { ok: false, missing };
|
|
537
|
+
const values = new Map(names.map((n) => [n, env[n]]));
|
|
538
|
+
const sub = (steps) => steps.map((s) => {
|
|
539
|
+
if (s.action === "repeat")
|
|
540
|
+
return { ...s, steps: sub(s.steps) };
|
|
541
|
+
if (s.action === "type" || s.action === "select")
|
|
542
|
+
return { ...s, value: s.value.replace(ENV_REF, (_, n) => values.get(n)) };
|
|
543
|
+
return s;
|
|
544
|
+
});
|
|
545
|
+
return { ok: true, flow: { ...flow, steps: sub(flow.steps) }, values };
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Text with every substituted value long enough to mask replaced by the name
|
|
549
|
+
* it came from, `[$NAME]`. Applied to the check's whole result before anything
|
|
550
|
+
* is written, so a value the page echoed into a URL or an error is not saved.
|
|
551
|
+
*/
|
|
552
|
+
export function maskEnvValues(text, values) {
|
|
553
|
+
let out = text;
|
|
554
|
+
// Longest first, so a value inside a longer one does not split it.
|
|
555
|
+
for (const [name, value] of [...values].sort((a, b) => b[1].length - a[1].length)) {
|
|
556
|
+
if (value.length < MIN_MASKED_ENV_VALUE)
|
|
557
|
+
continue;
|
|
558
|
+
out = out.split(value).join(`[$${name}]`);
|
|
559
|
+
// The same value as JSON writes it, for text that is a serialised result.
|
|
560
|
+
const escaped = JSON.stringify(value).slice(1, -1);
|
|
561
|
+
if (escaped !== value)
|
|
562
|
+
out = out.split(escaped).join(`[$${name}]`);
|
|
563
|
+
}
|
|
564
|
+
return out;
|
|
565
|
+
}
|
|
566
|
+
/** The roles a set of flows run as, each once, in the order they first appear. */
|
|
567
|
+
export function flowRoles(flows) {
|
|
568
|
+
return [...new Set(flows.flatMap((f) => (f.role === undefined ? [] : [f.role])))];
|
|
569
|
+
}
|
|
403
570
|
/** The step a run stopped at, as the evidence of its issue or the reason the check could not run. */
|
|
404
571
|
export function flowStepEvidence(run) {
|
|
405
572
|
if (run.outcome.status === "passed")
|