@plainconceptsplatform/workflows 1.0.14 → 1.2.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.
- package/dist/catalog-installation.js +17 -7
- package/dist/route-processing.js +1 -1
- package/dist/workflow-catalog.d.ts +4 -1
- package/dist/workflow-catalog.js +10 -1
- package/loops/actions/capture-evidence/action.yml +118 -0
- package/loops/actions/capture-evidence/capture.mjs +693 -0
- package/loops/actions/classify-route/action.yml +1 -0
- package/loops/actions/classify-route/classify-route.sh +15 -3
- package/loops/actions/report-workflow-errors/action.yml +1 -0
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +78 -61
- package/loops/docs/diagrams.md +48 -44
- package/loops/workflows/agent-merge-gate.md +0 -45
- package/loops/workflows/agent-release.md +73 -17
- package/loops/workflows/evidence.yml +460 -0
- package/loops/workflows/release-tag.yml +98 -0
- package/loops/workflows/shared/opencode-ci.md +5 -6
- package/loops/workflows/work-router.yml +29 -14
- package/package.json +1 -1
- package/loops/actions/attach-screenshots/action.yml +0 -93
- package/loops/workflows/agent-visual-verify.md +0 -350
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
// Managed by @plainconceptsplatform/workflows. Source: loops/actions/capture-evidence/capture.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
+
//
|
|
3
|
+
// Replays a change's verification plan against the running app and saves one screenshot per
|
|
4
|
+
// waypoint. No model runs here: the plan was written by the agent that implemented the change
|
|
5
|
+
// (the harness skill pc-repo-verify), and this only follows it.
|
|
6
|
+
//
|
|
7
|
+
// node capture.mjs inspect <repo-root> <plan.md>...
|
|
8
|
+
// prints {"runnable","url","start","plans"} as JSON, so the workflow knows whether to start
|
|
9
|
+
// the app at all and which address to wait for
|
|
10
|
+
// node capture.mjs capture <repo-root> <out-dir> <plan.md>...
|
|
11
|
+
// writes one screenshot per waypoint under <out-dir>/files/, laid out as the repository is:
|
|
12
|
+
// <plan's directory>/evidence/NN-<capture>.jpg beside a manifest.json for that plan, so the
|
|
13
|
+
// publish job copies files/ over the default branch as is. Writes the run's manifest.json and
|
|
14
|
+
// comment.md into <out-dir>
|
|
15
|
+
// node capture.mjs unreached <repo-root> <out-dir> <plan.md>...
|
|
16
|
+
// the app never answered: writes manifest.json and comment.md saying so, and nothing else
|
|
17
|
+
//
|
|
18
|
+
// The action installs `yaml` and `playwright-core` next to a copy of this file, which is why the
|
|
19
|
+
// imports are bare: ESM resolves them from the file's own directory, never from NODE_PATH.
|
|
20
|
+
|
|
21
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
22
|
+
import { basename, dirname, join, posix } from "node:path";
|
|
23
|
+
import { pathToFileURL } from "node:url";
|
|
24
|
+
|
|
25
|
+
const DEFAULT_URL = "http://localhost:3000";
|
|
26
|
+
const DEFAULT_VIEWPORT = { width: 1280, height: 800 };
|
|
27
|
+
const STEP_TIMEOUT_MS = Number(process.env.EVIDENCE_STEP_TIMEOUT_MS || 15000);
|
|
28
|
+
const NAVIGATION_TIMEOUT_MS = Number(process.env.EVIDENCE_NAVIGATION_TIMEOUT_MS || 45000);
|
|
29
|
+
// Stands in for the address the screenshots end up at. Only the publish job knows it, because it
|
|
30
|
+
// is the commit that job pushes, so the comment is written here and the publish job fills it in.
|
|
31
|
+
export const IMAGE_BASE = "__EVIDENCE_BASE__";
|
|
32
|
+
export const MARKER = "<!-- agentic-evidence -->";
|
|
33
|
+
// Opens the machine-readable copy of the manifest at the end of the comment, which is what other
|
|
34
|
+
// tools read (Project Health shows the screenshots with each issue) instead of parsing markdown.
|
|
35
|
+
export const EMBED_MARKER = "agentic-evidence-manifest";
|
|
36
|
+
// The folder beside each verification plan that keeps its screenshots.
|
|
37
|
+
export const EVIDENCE_DIR = "evidence";
|
|
38
|
+
// Stays well under GitHub's 65,536 character comment limit with the markdown above it.
|
|
39
|
+
const EMBED_LIMIT = 30000;
|
|
40
|
+
|
|
41
|
+
const ACTIONS = new Set(["open", "click", "dblclick", "fill", "press", "select", "check", "uncheck", "hover", "wait"]);
|
|
42
|
+
const LOCATOR_KEYS = ["role", "text", "label", "placeholder", "testid", "alt", "title", "css"];
|
|
43
|
+
const LOAD_STATES = new Set(["load", "domcontentloaded", "networkidle"]);
|
|
44
|
+
|
|
45
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
46
|
+
const asArray = (value) => (Array.isArray(value) ? value : value === undefined || value === null ? [] : [value]);
|
|
47
|
+
const firstLine = (error) => String(error?.message ?? error).split("\n")[0].trim();
|
|
48
|
+
|
|
49
|
+
export function slug(value) {
|
|
50
|
+
const text = String(value ?? "")
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
53
|
+
.replace(/^-+|-+$/g, "");
|
|
54
|
+
return text.slice(0, 60) || "waypoint";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The directory a plan lives in, which is where its screenshots are kept. The path comes from the
|
|
58
|
+
// pull request's file list, so it is repository-relative; anything that could climb out of the
|
|
59
|
+
// repository is refused rather than written.
|
|
60
|
+
export function planDir(planPath) {
|
|
61
|
+
const dir = posix.dirname(String(planPath).replace(/\\/g, "/"));
|
|
62
|
+
if (dir === "." || dir.startsWith("/") || dir.split("/").includes("..")) {
|
|
63
|
+
throw new Error(`unsafe plan path ${planPath}`);
|
|
64
|
+
}
|
|
65
|
+
return dir;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The change a plan belongs to, from its directory. Archived changes carry a date prefix.
|
|
69
|
+
export function changeName(planPath) {
|
|
70
|
+
return basename(dirname(planPath)).replace(/^\d{4}-\d{2}-\d{2}-/, "") || "change";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Parsing ─────────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
// A plan is a markdown file with one fenced ```yaml block holding the journey. Plans written
|
|
76
|
+
// before the fenced form put the YAML straight into the file, so those are tried as a fallback
|
|
77
|
+
// from their first top-level key to the end of the file.
|
|
78
|
+
export function extractPlan(markdown, parseYaml) {
|
|
79
|
+
const text = String(markdown).replace(/\r\n/g, "\n");
|
|
80
|
+
const candidates = [...text.matchAll(/^```ya?ml[^\n]*\n([\s\S]*?)^```/gm)].map((match) => match[1]);
|
|
81
|
+
for (const key of ["environment", "journey"]) {
|
|
82
|
+
const at = text.search(new RegExp(`^${key}:`, "m"));
|
|
83
|
+
if (at >= 0) candidates.push(text.slice(at));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const source of candidates) {
|
|
87
|
+
let doc;
|
|
88
|
+
try {
|
|
89
|
+
doc = parseYaml(source);
|
|
90
|
+
} catch {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!isObject(doc)) continue;
|
|
94
|
+
if (doc.status === "not-applicable") return { status: "not-applicable", reason: String(doc.reason ?? "").trim() };
|
|
95
|
+
if (Array.isArray(doc.journey) && doc.journey.length > 0) return normalizePlan(doc);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const stub = text.match(/^status:\s*not-applicable\s*$/m);
|
|
99
|
+
if (stub) {
|
|
100
|
+
const reason = text.slice(stub.index + stub[0].length).match(/^reason:\s*\|?\s*\n?([\s\S]*)/m);
|
|
101
|
+
return { status: "not-applicable", reason: reason ? reason[1].trim().split("\n")[0].trim() : "" };
|
|
102
|
+
}
|
|
103
|
+
return { status: "no-journey", reason: "the plan has no replayable journey block" };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseViewport(value) {
|
|
107
|
+
if (isObject(value) && Number(value.width) > 0 && Number(value.height) > 0) {
|
|
108
|
+
return { width: Number(value.width), height: Number(value.height) };
|
|
109
|
+
}
|
|
110
|
+
const match = String(value ?? "").match(/^(\d+)\s*x\s*(\d+)$/i);
|
|
111
|
+
return match ? { width: Number(match[1]), height: Number(match[2]) } : DEFAULT_VIEWPORT;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function normalizePlan(doc) {
|
|
115
|
+
const environment = isObject(doc.environment) ? doc.environment : {};
|
|
116
|
+
const used = new Set();
|
|
117
|
+
const waypoints = doc.journey.map((entry, index) => {
|
|
118
|
+
const raw = isObject(entry) ? entry : {};
|
|
119
|
+
let capture = slug(raw.capture ?? raw.waypoint ?? `waypoint-${index + 1}`);
|
|
120
|
+
while (used.has(capture)) capture = `${capture}-${index + 1}`;
|
|
121
|
+
used.add(capture);
|
|
122
|
+
const steps = [];
|
|
123
|
+
const ignored = [];
|
|
124
|
+
for (const step of [...asArray(raw.steps), ...asArray(raw.arrive)]) {
|
|
125
|
+
const parsed = parseStep(step);
|
|
126
|
+
if (parsed.ignored !== undefined) ignored.push(parsed.ignored);
|
|
127
|
+
else steps.push(parsed);
|
|
128
|
+
}
|
|
129
|
+
return { n: index + 1, capture, expect: String(raw.expect ?? "").trim(), steps, ignored };
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
status: "runnable",
|
|
134
|
+
url: String(doc.url ?? environment.url ?? DEFAULT_URL).trim() || DEFAULT_URL,
|
|
135
|
+
start: String(doc.start ?? environment.start ?? "").trim(),
|
|
136
|
+
viewport: parseViewport(doc.viewport ?? environment.viewport),
|
|
137
|
+
waypoints,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function locatorOf(value) {
|
|
142
|
+
if (typeof value === "string") return { text: value };
|
|
143
|
+
if (!isObject(value)) return undefined;
|
|
144
|
+
const key = LOCATOR_KEYS.find((name) => value[name] !== undefined && value[name] !== null);
|
|
145
|
+
if (key === undefined) return undefined;
|
|
146
|
+
const locator = { [key]: String(value[key]) };
|
|
147
|
+
if (key === "role" && value.name !== undefined) locator.name = String(value.name);
|
|
148
|
+
if (value.exact === true) locator.exact = true;
|
|
149
|
+
if (value.nth !== undefined && Number.isInteger(Number(value.nth))) locator.nth = Number(value.nth);
|
|
150
|
+
if (value.last === true) locator.last = true;
|
|
151
|
+
return locator;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseWait(value) {
|
|
155
|
+
if (typeof value === "number") return { ms: value };
|
|
156
|
+
if (typeof value === "string") {
|
|
157
|
+
if (/^\d+$/.test(value)) return { ms: Number(value) };
|
|
158
|
+
if (LOAD_STATES.has(value)) return { load: value };
|
|
159
|
+
return { text: value };
|
|
160
|
+
}
|
|
161
|
+
if (!isObject(value)) return undefined;
|
|
162
|
+
if (value.load !== undefined) return { load: String(value.load) };
|
|
163
|
+
if (value.ms !== undefined) return { ms: Number(value.ms) };
|
|
164
|
+
if (value.url !== undefined) return { url: String(value.url) };
|
|
165
|
+
if (value.text !== undefined && Object.keys(value).length === 1) return { text: String(value.text) };
|
|
166
|
+
const locator = locatorOf(value);
|
|
167
|
+
return locator ? { locator } : undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function parseStep(step) {
|
|
171
|
+
if (typeof step === "string") return parseCommand(step);
|
|
172
|
+
if (!isObject(step)) return { ignored: String(step) };
|
|
173
|
+
const actions = Object.keys(step).filter((key) => ACTIONS.has(key));
|
|
174
|
+
if (actions.length !== 1) return { ignored: JSON.stringify(step) };
|
|
175
|
+
const action = actions[0];
|
|
176
|
+
const argument = step[action];
|
|
177
|
+
|
|
178
|
+
switch (action) {
|
|
179
|
+
case "open":
|
|
180
|
+
return argument ? { action, target: String(argument) } : { ignored: JSON.stringify(step) };
|
|
181
|
+
case "wait": {
|
|
182
|
+
const wait = parseWait(argument);
|
|
183
|
+
return wait ? { action, ...wait } : { ignored: JSON.stringify(step) };
|
|
184
|
+
}
|
|
185
|
+
case "press": {
|
|
186
|
+
const key = isObject(argument) ? argument.key : argument;
|
|
187
|
+
if (!key) return { ignored: JSON.stringify(step) };
|
|
188
|
+
const locator = isObject(argument) ? locatorOf(argument) : undefined;
|
|
189
|
+
return { action, key: String(key), ...(locator ? { locator } : {}) };
|
|
190
|
+
}
|
|
191
|
+
case "fill":
|
|
192
|
+
case "select": {
|
|
193
|
+
const locator = locatorOf(argument);
|
|
194
|
+
if (!locator || !isObject(argument)) return { ignored: JSON.stringify(step) };
|
|
195
|
+
return { action, locator, value: String(argument.value ?? "") };
|
|
196
|
+
}
|
|
197
|
+
default: {
|
|
198
|
+
const locator = locatorOf(argument);
|
|
199
|
+
if (!locator) return { ignored: JSON.stringify(step) };
|
|
200
|
+
const button = isObject(argument) && argument.button ? String(argument.button) : undefined;
|
|
201
|
+
return { action, locator, ...(button ? { button } : {}) };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function tokenize(line) {
|
|
207
|
+
const tokens = [];
|
|
208
|
+
let current = "";
|
|
209
|
+
let inToken = false;
|
|
210
|
+
let quote = null;
|
|
211
|
+
for (const character of line) {
|
|
212
|
+
if (quote) {
|
|
213
|
+
if (character === quote) quote = null;
|
|
214
|
+
else current += character;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (character === '"' || character === "'") {
|
|
218
|
+
quote = character;
|
|
219
|
+
inToken = true;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (/\s/.test(character)) {
|
|
223
|
+
if (inToken) tokens.push(current);
|
|
224
|
+
current = "";
|
|
225
|
+
inToken = false;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
current += character;
|
|
229
|
+
inToken = true;
|
|
230
|
+
}
|
|
231
|
+
if (inToken) tokens.push(current);
|
|
232
|
+
return tokens;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// The agent-browser idiom the first plans were written in: `agent-browser find role button click
|
|
236
|
+
// --name "Save"`. Every command that only reads (snapshot, network, text) has nothing to replay
|
|
237
|
+
// and is reported as ignored rather than failed.
|
|
238
|
+
export function parseCommand(line) {
|
|
239
|
+
const tokens = tokenize(String(line).trim());
|
|
240
|
+
if (tokens[0] === "agent-browser") tokens.shift();
|
|
241
|
+
const flags = {};
|
|
242
|
+
const args = [];
|
|
243
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
244
|
+
const token = tokens[index];
|
|
245
|
+
if (token.startsWith("--")) {
|
|
246
|
+
const next = tokens[index + 1];
|
|
247
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
248
|
+
flags[token.slice(2)] = next;
|
|
249
|
+
index++;
|
|
250
|
+
} else {
|
|
251
|
+
flags[token.slice(2)] = true;
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
args.push(token);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const [verb, ...rest] = args;
|
|
259
|
+
const ignored = { ignored: String(line).trim() };
|
|
260
|
+
switch (verb) {
|
|
261
|
+
case "open":
|
|
262
|
+
case "goto":
|
|
263
|
+
case "navigate":
|
|
264
|
+
return rest[0] ? { action: "open", target: rest[0] } : ignored;
|
|
265
|
+
case "wait":
|
|
266
|
+
if (typeof flags.load === "string") return { action: "wait", load: flags.load };
|
|
267
|
+
if (typeof flags.text === "string") return { action: "wait", text: flags.text };
|
|
268
|
+
if (typeof flags.url === "string") return { action: "wait", url: flags.url };
|
|
269
|
+
if (rest[0] && /^\d+$/.test(rest[0])) return { action: "wait", ms: Number(rest[0]) };
|
|
270
|
+
return rest[0] ? { action: "wait", locator: { css: rest[0] } } : ignored;
|
|
271
|
+
case "find":
|
|
272
|
+
return parseFind(rest, flags) ?? ignored;
|
|
273
|
+
case "click":
|
|
274
|
+
case "dblclick":
|
|
275
|
+
case "hover":
|
|
276
|
+
case "check":
|
|
277
|
+
case "uncheck":
|
|
278
|
+
return rest[0] ? { action: verb, locator: { css: rest[0] }, ...(typeof flags.button === "string" ? { button: flags.button } : {}) } : ignored;
|
|
279
|
+
case "fill":
|
|
280
|
+
case "type":
|
|
281
|
+
return rest.length >= 2 ? { action: "fill", locator: { css: rest[0] }, value: rest[1] } : ignored;
|
|
282
|
+
case "select":
|
|
283
|
+
return rest.length >= 2 ? { action: "select", locator: { css: rest[0] }, value: rest[1] } : ignored;
|
|
284
|
+
case "press":
|
|
285
|
+
case "key":
|
|
286
|
+
return rest[0] ? { action: "press", key: rest[0] } : ignored;
|
|
287
|
+
default:
|
|
288
|
+
return ignored;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function parseFind(rest, flags) {
|
|
293
|
+
const [kind, ...more] = rest;
|
|
294
|
+
let locator;
|
|
295
|
+
let tail;
|
|
296
|
+
switch (kind) {
|
|
297
|
+
case "role":
|
|
298
|
+
locator = { role: more[0], ...(typeof flags.name === "string" ? { name: flags.name } : {}) };
|
|
299
|
+
tail = more.slice(1);
|
|
300
|
+
break;
|
|
301
|
+
case "text":
|
|
302
|
+
case "label":
|
|
303
|
+
case "placeholder":
|
|
304
|
+
case "alt":
|
|
305
|
+
case "title":
|
|
306
|
+
case "testid":
|
|
307
|
+
locator = { [kind]: more[0] };
|
|
308
|
+
tail = more.slice(1);
|
|
309
|
+
break;
|
|
310
|
+
case "first":
|
|
311
|
+
locator = { css: more[0] };
|
|
312
|
+
tail = more.slice(1);
|
|
313
|
+
break;
|
|
314
|
+
case "last":
|
|
315
|
+
locator = { css: more[0], last: true };
|
|
316
|
+
tail = more.slice(1);
|
|
317
|
+
break;
|
|
318
|
+
case "nth":
|
|
319
|
+
locator = { css: more[1], nth: Number(more[0]) };
|
|
320
|
+
tail = more.slice(2);
|
|
321
|
+
break;
|
|
322
|
+
default:
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
if (Object.values(locator).some((value) => value === undefined || Number.isNaN(value))) return undefined;
|
|
326
|
+
if (flags.exact === true) locator.exact = true;
|
|
327
|
+
|
|
328
|
+
const [verb, value] = tail;
|
|
329
|
+
switch (verb) {
|
|
330
|
+
case "click":
|
|
331
|
+
case "dblclick":
|
|
332
|
+
case "hover":
|
|
333
|
+
case "check":
|
|
334
|
+
case "uncheck":
|
|
335
|
+
return { action: verb, locator, ...(typeof flags.button === "string" ? { button: flags.button } : {}) };
|
|
336
|
+
case "fill":
|
|
337
|
+
case "type":
|
|
338
|
+
return { action: "fill", locator, value: value ?? "" };
|
|
339
|
+
case "select":
|
|
340
|
+
return { action: "select", locator, value: value ?? "" };
|
|
341
|
+
case "press":
|
|
342
|
+
return value ? { action: "press", locator, key: value } : undefined;
|
|
343
|
+
default:
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ── Replay ──────────────────────────────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
export function describeStep(step) {
|
|
351
|
+
const where = step.locator
|
|
352
|
+
? Object.entries(step.locator).map(([key, value]) => `${key}=${JSON.stringify(value)}`).join(" ")
|
|
353
|
+
: "";
|
|
354
|
+
switch (step.action) {
|
|
355
|
+
case "open":
|
|
356
|
+
return `open ${step.target}`;
|
|
357
|
+
case "wait":
|
|
358
|
+
if (step.load) return `wait for ${step.load}`;
|
|
359
|
+
if (step.text) return `wait for text ${JSON.stringify(step.text)}`;
|
|
360
|
+
if (step.url) return `wait for url ${step.url}`;
|
|
361
|
+
if (step.ms !== undefined) return `wait ${step.ms}ms`;
|
|
362
|
+
return `wait for ${where}`;
|
|
363
|
+
case "fill":
|
|
364
|
+
case "select":
|
|
365
|
+
return `${step.action} ${where}`;
|
|
366
|
+
case "press":
|
|
367
|
+
return `press ${step.key}${where ? ` on ${where}` : ""}`;
|
|
368
|
+
default:
|
|
369
|
+
return `${step.action} ${where}`;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function locate(page, locator) {
|
|
374
|
+
let found;
|
|
375
|
+
const exact = locator.exact === true ? { exact: true } : {};
|
|
376
|
+
if (locator.role !== undefined) found = page.getByRole(locator.role, { ...(locator.name !== undefined ? { name: locator.name } : {}), ...exact });
|
|
377
|
+
else if (locator.text !== undefined) found = page.getByText(locator.text, exact);
|
|
378
|
+
else if (locator.label !== undefined) found = page.getByLabel(locator.label, exact);
|
|
379
|
+
else if (locator.placeholder !== undefined) found = page.getByPlaceholder(locator.placeholder, exact);
|
|
380
|
+
else if (locator.testid !== undefined) found = page.getByTestId(locator.testid);
|
|
381
|
+
else if (locator.alt !== undefined) found = page.getByAltText(locator.alt, exact);
|
|
382
|
+
else if (locator.title !== undefined) found = page.getByTitle(locator.title, exact);
|
|
383
|
+
else found = page.locator(locator.css);
|
|
384
|
+
// Lenient on purpose: this archives what a person would have seen, it is not a test, and a
|
|
385
|
+
// strict-mode error on two matching buttons would lose the screenshot for nothing.
|
|
386
|
+
if (locator.nth !== undefined) return found.nth(locator.nth);
|
|
387
|
+
if (locator.last) return found.last();
|
|
388
|
+
return found.first();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function resolveUrl(target, base) {
|
|
392
|
+
return new URL(target, base.endsWith("/") ? base : `${base}/`).href;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function runStep(page, step, baseUrl) {
|
|
396
|
+
switch (step.action) {
|
|
397
|
+
case "open":
|
|
398
|
+
await page.goto(resolveUrl(step.target, baseUrl), { waitUntil: "load" });
|
|
399
|
+
return;
|
|
400
|
+
case "click":
|
|
401
|
+
await locate(page, step.locator).click(step.button ? { button: step.button } : {});
|
|
402
|
+
return;
|
|
403
|
+
case "dblclick":
|
|
404
|
+
await locate(page, step.locator).dblclick();
|
|
405
|
+
return;
|
|
406
|
+
case "hover":
|
|
407
|
+
await locate(page, step.locator).hover();
|
|
408
|
+
return;
|
|
409
|
+
case "check":
|
|
410
|
+
await locate(page, step.locator).check();
|
|
411
|
+
return;
|
|
412
|
+
case "uncheck":
|
|
413
|
+
await locate(page, step.locator).uncheck();
|
|
414
|
+
return;
|
|
415
|
+
case "fill":
|
|
416
|
+
await locate(page, step.locator).fill(step.value);
|
|
417
|
+
return;
|
|
418
|
+
case "select":
|
|
419
|
+
await locate(page, step.locator).selectOption(step.value);
|
|
420
|
+
return;
|
|
421
|
+
case "press":
|
|
422
|
+
if (step.locator) await locate(page, step.locator).press(step.key);
|
|
423
|
+
else await page.keyboard.press(step.key);
|
|
424
|
+
return;
|
|
425
|
+
case "wait":
|
|
426
|
+
if (step.ms !== undefined) {
|
|
427
|
+
await page.waitForTimeout(Math.min(Math.max(step.ms, 0), 30000));
|
|
428
|
+
} else if (step.load) {
|
|
429
|
+
// An app that keeps a poll or a stream open never goes network-idle. Waiting for it is
|
|
430
|
+
// a courtesy before the screenshot, so running out of patience is not a failure.
|
|
431
|
+
const state = LOAD_STATES.has(step.load) ? step.load : "load";
|
|
432
|
+
await page.waitForLoadState(state, { timeout: STEP_TIMEOUT_MS }).catch(() => {});
|
|
433
|
+
} else if (step.text) {
|
|
434
|
+
await page.getByText(step.text).first().waitFor({ state: "visible" });
|
|
435
|
+
} else if (step.url) {
|
|
436
|
+
await page.waitForURL((url) => url.href.includes(step.url));
|
|
437
|
+
} else if (step.locator) {
|
|
438
|
+
await locate(page, step.locator).waitFor({ state: "visible" });
|
|
439
|
+
}
|
|
440
|
+
return;
|
|
441
|
+
default:
|
|
442
|
+
throw new Error(`unsupported step ${step.action}`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function replay(browser, plan, outDir, dir) {
|
|
447
|
+
const context = await browser.newContext({ viewport: plan.viewport, ignoreHTTPSErrors: true });
|
|
448
|
+
const page = await context.newPage();
|
|
449
|
+
page.setDefaultTimeout(STEP_TIMEOUT_MS);
|
|
450
|
+
page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
|
|
451
|
+
|
|
452
|
+
const results = [];
|
|
453
|
+
try {
|
|
454
|
+
for (const waypoint of plan.waypoints) {
|
|
455
|
+
const errors = [];
|
|
456
|
+
for (const step of waypoint.steps) {
|
|
457
|
+
try {
|
|
458
|
+
await runStep(page, step, plan.url);
|
|
459
|
+
} catch (error) {
|
|
460
|
+
// The rest of this waypoint depends on the step that failed. The screenshot is still
|
|
461
|
+
// taken: where the journey stopped is the most useful picture there is.
|
|
462
|
+
errors.push(`${describeStep(step)}: ${firstLine(error)}`);
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// Repository-relative, so the comment, the manifest and the commit all name the same path.
|
|
467
|
+
const file = `${dir}/${EVIDENCE_DIR}/${String(waypoint.n).padStart(2, "0")}-${waypoint.capture}.jpg`;
|
|
468
|
+
let captured = false;
|
|
469
|
+
try {
|
|
470
|
+
await page.screenshot({ path: join(outDir, "files", file), type: "jpeg", quality: 70, fullPage: true });
|
|
471
|
+
captured = true;
|
|
472
|
+
} catch (error) {
|
|
473
|
+
errors.push(`screenshot: ${firstLine(error)}`);
|
|
474
|
+
}
|
|
475
|
+
results.push({
|
|
476
|
+
n: waypoint.n,
|
|
477
|
+
capture: waypoint.capture,
|
|
478
|
+
expect: waypoint.expect,
|
|
479
|
+
file: captured ? file : null,
|
|
480
|
+
reached: errors.length === 0,
|
|
481
|
+
errors,
|
|
482
|
+
ignored: waypoint.ignored,
|
|
483
|
+
});
|
|
484
|
+
console.log(`${dir} ${waypoint.n} ${waypoint.capture}: ${errors.length === 0 ? "ok" : errors.join("; ")}`);
|
|
485
|
+
}
|
|
486
|
+
} finally {
|
|
487
|
+
await context.close();
|
|
488
|
+
}
|
|
489
|
+
return results;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ── Report ──────────────────────────────────────────────────────────────────────────────────
|
|
493
|
+
|
|
494
|
+
const escapeMarkdown = (text) => String(text).replace(/([\\`*_[\]<>|])/g, "\\$1");
|
|
495
|
+
|
|
496
|
+
// The manifest other tools read, as an HTML comment so it never renders. JSON can carry `--` only
|
|
497
|
+
// inside strings, where `\u002d` means the same character, so escaping it keeps the comment closed.
|
|
498
|
+
export function embedManifest(manifest) {
|
|
499
|
+
const compact = (keepExpect) => ({
|
|
500
|
+
version: 2,
|
|
501
|
+
base: IMAGE_BASE,
|
|
502
|
+
sha: manifest.sha ?? "",
|
|
503
|
+
runUrl: manifest.runUrl ?? "",
|
|
504
|
+
...(manifest.app ? { app: manifest.app } : {}),
|
|
505
|
+
plans: (manifest.plans ?? []).map((plan) => ({
|
|
506
|
+
path: plan.path,
|
|
507
|
+
change: plan.change,
|
|
508
|
+
status: plan.status,
|
|
509
|
+
...(plan.reason ? { reason: plan.reason } : {}),
|
|
510
|
+
...(plan.waypoints
|
|
511
|
+
? {
|
|
512
|
+
waypoints: plan.waypoints.map((w) => ({
|
|
513
|
+
n: w.n,
|
|
514
|
+
capture: w.capture,
|
|
515
|
+
...(keepExpect && w.expect ? { expect: w.expect } : {}),
|
|
516
|
+
reached: w.reached,
|
|
517
|
+
file: w.file,
|
|
518
|
+
})),
|
|
519
|
+
}
|
|
520
|
+
: {}),
|
|
521
|
+
})),
|
|
522
|
+
});
|
|
523
|
+
let json = JSON.stringify(compact(true));
|
|
524
|
+
if (json.length > EMBED_LIMIT) json = JSON.stringify(compact(false));
|
|
525
|
+
if (json.length > EMBED_LIMIT) return "";
|
|
526
|
+
return `<!-- ${EMBED_MARKER}\n${json.replace(/-(?=-)/g, "\\u002d")}\n-->`;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Reads the embedded manifest back out of a comment body, or null when there is none.
|
|
530
|
+
export function readEmbeddedManifest(body) {
|
|
531
|
+
const match = String(body ?? "").match(new RegExp(`<!-- ${EMBED_MARKER}\\n([\\s\\S]*?)\\n-->`));
|
|
532
|
+
if (!match) return null;
|
|
533
|
+
try {
|
|
534
|
+
return JSON.parse(match[1]);
|
|
535
|
+
} catch {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export function renderComment(manifest) {
|
|
541
|
+
const lines = [MARKER, "## Visual evidence", ""];
|
|
542
|
+
const short = manifest.sha ? manifest.sha.slice(0, 7) : "";
|
|
543
|
+
const run = manifest.runUrl ? ` ([run](${manifest.runUrl}))` : "";
|
|
544
|
+
lines.push(`The verification plan replayed against \`${short}\`, the head of this pull request${run}.`, "");
|
|
545
|
+
if ((manifest.plans ?? []).some((plan) => plan.status === "captured")) {
|
|
546
|
+
lines.push(`The screenshots are kept beside each plan, in its \`${EVIDENCE_DIR}/\` folder on the default branch.`, "");
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (manifest.app && manifest.app.started === false) {
|
|
550
|
+
lines.push(`Nothing was captured: the app did not answer at ${manifest.app.url} within ${manifest.app.waitedSeconds} seconds of \`${manifest.app.start}\`. The run log has the app's output.`, "");
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
for (const plan of manifest.plans) {
|
|
554
|
+
lines.push(`### ${escapeMarkdown(plan.change)}`, "");
|
|
555
|
+
if (plan.status === "not-applicable") {
|
|
556
|
+
lines.push(`No UI surface${plan.reason ? `: ${escapeMarkdown(plan.reason)}` : "."}`, "");
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
if (plan.status !== "captured") {
|
|
560
|
+
lines.push(`Not replayed: ${escapeMarkdown(plan.reason || plan.status)}.`, "");
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
const missed = plan.waypoints.filter((waypoint) => !waypoint.reached).length;
|
|
564
|
+
const summary = `${plan.waypoints.length} waypoint${plan.waypoints.length === 1 ? "" : "s"}${missed > 0 ? `, ${missed} did not reach their state` : ""}`;
|
|
565
|
+
lines.push("<details>", `<summary>${summary}</summary>`, "");
|
|
566
|
+
for (const waypoint of plan.waypoints) {
|
|
567
|
+
const heading = `**${waypoint.n}. ${escapeMarkdown(waypoint.capture)}**${waypoint.expect ? `: ${escapeMarkdown(waypoint.expect)}` : ""}`;
|
|
568
|
+
lines.push(heading);
|
|
569
|
+
if (!waypoint.reached) lines.push(`> Stopped at ${escapeMarkdown(waypoint.errors.join("; "))}`);
|
|
570
|
+
if (waypoint.file) lines.push("", ``);
|
|
571
|
+
lines.push("");
|
|
572
|
+
}
|
|
573
|
+
lines.push("</details>", "");
|
|
574
|
+
}
|
|
575
|
+
const embedded = embedManifest(manifest);
|
|
576
|
+
if (embedded) lines.push(embedded, "");
|
|
577
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ── Commands ────────────────────────────────────────────────────────────────────────────────
|
|
581
|
+
|
|
582
|
+
async function loadPlans(root, paths, parseYaml) {
|
|
583
|
+
const plans = [];
|
|
584
|
+
for (const path of paths) {
|
|
585
|
+
let plan;
|
|
586
|
+
try {
|
|
587
|
+
plan = extractPlan(await readFile(join(root, path), "utf8"), parseYaml);
|
|
588
|
+
} catch (error) {
|
|
589
|
+
plan = { status: "unreadable", reason: firstLine(error) };
|
|
590
|
+
}
|
|
591
|
+
plans.push({ path, change: changeName(path), ...plan });
|
|
592
|
+
}
|
|
593
|
+
return plans;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function inspect(root, paths) {
|
|
597
|
+
const { parse } = await import("yaml");
|
|
598
|
+
const plans = await loadPlans(root, paths, parse);
|
|
599
|
+
const runnable = plans.filter((plan) => plan.status === "runnable");
|
|
600
|
+
const first = runnable[0];
|
|
601
|
+
return {
|
|
602
|
+
runnable: runnable.length > 0,
|
|
603
|
+
url: first?.url ?? "",
|
|
604
|
+
start: first?.start ?? "",
|
|
605
|
+
plans: plans.map((plan) => ({ path: plan.path, change: plan.change, status: plan.status, waypoints: plan.waypoints?.length ?? 0 })),
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function capture(root, outDir, paths) {
|
|
610
|
+
const { parse } = await import("yaml");
|
|
611
|
+
const { chromium } = await import("playwright-core");
|
|
612
|
+
await mkdir(outDir, { recursive: true });
|
|
613
|
+
const plans = await loadPlans(root, paths, parse);
|
|
614
|
+
const runnable = plans.filter((plan) => plan.status === "runnable");
|
|
615
|
+
const manifest = { version: 2, plans: [] };
|
|
616
|
+
|
|
617
|
+
const browser = runnable.length > 0 ? await chromium.launch({ headless: true }) : undefined;
|
|
618
|
+
try {
|
|
619
|
+
for (const plan of plans) {
|
|
620
|
+
if (plan.status !== "runnable") {
|
|
621
|
+
manifest.plans.push({ path: plan.path, change: plan.change, status: plan.status, reason: plan.reason ?? "" });
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
const dir = planDir(plan.path);
|
|
625
|
+
await mkdir(join(outDir, "files", dir, EVIDENCE_DIR), { recursive: true });
|
|
626
|
+
const waypoints = await replay(browser, plan, outDir, dir);
|
|
627
|
+
manifest.plans.push({ path: plan.path, change: plan.change, status: "captured", url: plan.url, waypoints });
|
|
628
|
+
}
|
|
629
|
+
} finally {
|
|
630
|
+
await browser?.close();
|
|
631
|
+
}
|
|
632
|
+
return manifest;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function main(argv) {
|
|
636
|
+
const [mode, root, ...rest] = argv;
|
|
637
|
+
if (mode === "inspect" && root) {
|
|
638
|
+
process.stdout.write(`${JSON.stringify(await inspect(root, rest))}\n`);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
if (mode === "capture" && root && rest.length > 0) {
|
|
642
|
+
const [outDir, ...paths] = rest;
|
|
643
|
+
const manifest = await capture(root, outDir, paths);
|
|
644
|
+
manifest.sha = process.env.EVIDENCE_SHA ?? "";
|
|
645
|
+
manifest.runUrl = process.env.EVIDENCE_RUN_URL ?? "";
|
|
646
|
+
// Each plan keeps its own record beside its screenshots, so the folder explains itself on the
|
|
647
|
+
// default branch without the pull request comment.
|
|
648
|
+
for (const plan of manifest.plans.filter((p) => p.status === "captured")) {
|
|
649
|
+
const record = { version: 2, sha: manifest.sha, runUrl: manifest.runUrl, plan };
|
|
650
|
+
await writeFile(join(outDir, "files", planDir(plan.path), EVIDENCE_DIR, "manifest.json"), `${JSON.stringify(record, null, 2)}\n`);
|
|
651
|
+
}
|
|
652
|
+
await writeFile(join(outDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
653
|
+
await writeFile(join(outDir, "comment.md"), renderComment(manifest));
|
|
654
|
+
const shots = manifest.plans.flatMap((plan) => plan.waypoints ?? []).filter((waypoint) => waypoint.file).length;
|
|
655
|
+
console.log(`captured ${shots} screenshot(s)`);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (mode === "unreached" && root && rest.length > 0) {
|
|
659
|
+
// The app never answered: no browser, only the record that says so and why.
|
|
660
|
+
const [outDir, ...paths] = rest;
|
|
661
|
+
const { parse } = await import("yaml");
|
|
662
|
+
await mkdir(outDir, { recursive: true });
|
|
663
|
+
const plans = await loadPlans(root, paths, parse);
|
|
664
|
+
const manifest = {
|
|
665
|
+
version: 2,
|
|
666
|
+
sha: process.env.EVIDENCE_SHA ?? "",
|
|
667
|
+
runUrl: process.env.EVIDENCE_RUN_URL ?? "",
|
|
668
|
+
app: {
|
|
669
|
+
started: false,
|
|
670
|
+
url: process.env.EVIDENCE_APP_URL ?? "",
|
|
671
|
+
start: process.env.EVIDENCE_START ?? "",
|
|
672
|
+
waitedSeconds: Number(process.env.EVIDENCE_WAITED_SECONDS ?? 0),
|
|
673
|
+
},
|
|
674
|
+
plans: plans.map((plan) => ({
|
|
675
|
+
path: plan.path,
|
|
676
|
+
change: plan.change,
|
|
677
|
+
status: plan.status === "runnable" ? "not-run" : plan.status,
|
|
678
|
+
reason: plan.status === "runnable" ? "the app did not start" : plan.reason ?? "",
|
|
679
|
+
})),
|
|
680
|
+
};
|
|
681
|
+
await writeFile(join(outDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
682
|
+
await writeFile(join(outDir, "comment.md"), renderComment(manifest));
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
throw new Error("usage: capture.mjs inspect <root> <plan>... | capture|unreached <root> <out-dir> <plan>...");
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
689
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
690
|
+
console.error(firstLine(error));
|
|
691
|
+
process.exit(1);
|
|
692
|
+
});
|
|
693
|
+
}
|