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