@momentiq/dark-factory-cli 2.17.0 → 3.0.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.
Files changed (136) hide show
  1. package/dist/adapters/codex-sdk.d.ts +30 -5
  2. package/dist/adapters/codex-sdk.d.ts.map +1 -1
  3. package/dist/adapters/codex-sdk.js +50 -15
  4. package/dist/adapters/codex-sdk.js.map +1 -1
  5. package/dist/adapters/cursor-sdk.d.ts.map +1 -1
  6. package/dist/adapters/cursor-sdk.js +14 -2
  7. package/dist/adapters/cursor-sdk.js.map +1 -1
  8. package/dist/adapters/index.d.ts +1 -0
  9. package/dist/adapters/index.d.ts.map +1 -1
  10. package/dist/adapters/index.js +6 -0
  11. package/dist/adapters/index.js.map +1 -1
  12. package/dist/adapters/mode-scope.d.ts +86 -0
  13. package/dist/adapters/mode-scope.d.ts.map +1 -0
  14. package/dist/adapters/mode-scope.js +456 -0
  15. package/dist/adapters/mode-scope.js.map +1 -0
  16. package/dist/branch-protection/audit_branch_protection.py +80 -2
  17. package/dist/cli.d.ts +5 -0
  18. package/dist/cli.d.ts.map +1 -1
  19. package/dist/cli.js +31 -1
  20. package/dist/cli.js.map +1 -1
  21. package/dist/commands/mode.d.ts +2 -0
  22. package/dist/commands/mode.d.ts.map +1 -0
  23. package/dist/commands/mode.js +235 -0
  24. package/dist/commands/mode.js.map +1 -0
  25. package/dist/commands/objectives.d.ts +27 -3
  26. package/dist/commands/objectives.d.ts.map +1 -1
  27. package/dist/commands/objectives.js +347 -53
  28. package/dist/commands/objectives.js.map +1 -1
  29. package/dist/commands/onboard.d.ts.map +1 -1
  30. package/dist/commands/onboard.js +3 -1
  31. package/dist/commands/onboard.js.map +1 -1
  32. package/dist/cycle-doc-validator/validate_cycle_doc.py +128 -11
  33. package/dist/doctor.d.ts.map +1 -1
  34. package/dist/doctor.js +15 -7
  35. package/dist/doctor.js.map +1 -1
  36. package/dist/evidence/index.d.ts +1 -0
  37. package/dist/evidence/index.d.ts.map +1 -1
  38. package/dist/evidence/index.js +5 -0
  39. package/dist/evidence/index.js.map +1 -1
  40. package/dist/evidence/ui-visual/embed.d.ts +65 -0
  41. package/dist/evidence/ui-visual/embed.d.ts.map +1 -0
  42. package/dist/evidence/ui-visual/embed.js +176 -0
  43. package/dist/evidence/ui-visual/embed.js.map +1 -0
  44. package/dist/evidence/ui-visual/index.d.ts +2 -0
  45. package/dist/evidence/ui-visual/index.d.ts.map +1 -0
  46. package/dist/evidence/ui-visual/index.js +6 -0
  47. package/dist/evidence/ui-visual/index.js.map +1 -0
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +1 -1
  51. package/dist/index.js.map +1 -1
  52. package/dist/mcp/cycle-doc/parser.d.ts +3 -3
  53. package/dist/mcp/cycle-doc/parser.d.ts.map +1 -1
  54. package/dist/mcp/cycle-doc/parser.js +2 -2
  55. package/dist/mcp/cycle-doc/parser.js.map +1 -1
  56. package/dist/mcp/tools/doctor.d.ts +5 -0
  57. package/dist/mcp/tools/doctor.d.ts.map +1 -1
  58. package/dist/mcp/tools/doctor.js +10 -1
  59. package/dist/mcp/tools/doctor.js.map +1 -1
  60. package/dist/mcp/tools/review-bypass.d.ts +5 -0
  61. package/dist/mcp/tools/review-bypass.d.ts.map +1 -1
  62. package/dist/mcp/tools/review-bypass.js +9 -1
  63. package/dist/mcp/tools/review-bypass.js.map +1 -1
  64. package/dist/mode/apply.d.ts +8 -0
  65. package/dist/mode/apply.d.ts.map +1 -0
  66. package/dist/mode/apply.js +118 -0
  67. package/dist/mode/apply.js.map +1 -0
  68. package/dist/mode/context.d.ts +50 -0
  69. package/dist/mode/context.d.ts.map +1 -0
  70. package/dist/mode/context.js +127 -0
  71. package/dist/mode/context.js.map +1 -0
  72. package/dist/mode/critic-registration.d.ts +66 -0
  73. package/dist/mode/critic-registration.d.ts.map +1 -0
  74. package/dist/mode/critic-registration.js +279 -0
  75. package/dist/mode/critic-registration.js.map +1 -0
  76. package/dist/mode/guard.d.ts +15 -0
  77. package/dist/mode/guard.d.ts.map +1 -0
  78. package/dist/mode/guard.js +160 -0
  79. package/dist/mode/guard.js.map +1 -0
  80. package/dist/mode/init.d.ts +17 -0
  81. package/dist/mode/init.d.ts.map +1 -0
  82. package/dist/mode/init.js +147 -0
  83. package/dist/mode/init.js.map +1 -0
  84. package/dist/mode/loadConfig.d.ts +34 -0
  85. package/dist/mode/loadConfig.d.ts.map +1 -0
  86. package/dist/mode/loadConfig.js +38 -0
  87. package/dist/mode/loadConfig.js.map +1 -0
  88. package/dist/mode/prompts/designer-baseline.md +47 -0
  89. package/dist/mode/resolve.d.ts +24 -0
  90. package/dist/mode/resolve.d.ts.map +1 -0
  91. package/dist/mode/resolve.js +65 -0
  92. package/dist/mode/resolve.js.map +1 -0
  93. package/dist/mode/scope-policy.d.ts +41 -0
  94. package/dist/mode/scope-policy.d.ts.map +1 -0
  95. package/dist/mode/scope-policy.js +97 -0
  96. package/dist/mode/scope-policy.js.map +1 -0
  97. package/dist/mode/status.d.ts +14 -0
  98. package/dist/mode/status.d.ts.map +1 -0
  99. package/dist/mode/status.js +13 -0
  100. package/dist/mode/status.js.map +1 -0
  101. package/dist/onboard/seeders/design-md.d.ts +3 -0
  102. package/dist/onboard/seeders/design-md.d.ts.map +1 -0
  103. package/dist/onboard/seeders/design-md.js +138 -0
  104. package/dist/onboard/seeders/design-md.js.map +1 -0
  105. package/dist/onboard/seeders/index.d.ts +8 -0
  106. package/dist/onboard/seeders/index.d.ts.map +1 -1
  107. package/dist/onboard/seeders/index.js +3 -0
  108. package/dist/onboard/seeders/index.js.map +1 -1
  109. package/dist/onboard/validate.d.ts +36 -1
  110. package/dist/onboard/validate.d.ts.map +1 -1
  111. package/dist/onboard/validate.js +136 -42
  112. package/dist/onboard/validate.js.map +1 -1
  113. package/dist/skills/config.d.ts +2 -110
  114. package/dist/skills/config.d.ts.map +1 -1
  115. package/dist/skills/config.js +20 -58
  116. package/dist/skills/config.js.map +1 -1
  117. package/dist/skills/index.d.ts +1 -1
  118. package/dist/skills/index.d.ts.map +1 -1
  119. package/dist/skills/index.js +1 -1
  120. package/dist/skills/index.js.map +1 -1
  121. package/dist/skills/install.d.ts.map +1 -1
  122. package/dist/skills/install.js +7 -0
  123. package/dist/skills/install.js.map +1 -1
  124. package/package.json +3 -3
  125. package/skills/designer-brief/SKILL.md.tmpl +74 -0
  126. package/skills/designer-brief/skill.json +17 -0
  127. package/skills/designer-build/SKILL.md.tmpl +63 -0
  128. package/skills/designer-build/skill.json +17 -0
  129. package/skills/designer-handoff/SKILL.md.tmpl +64 -0
  130. package/skills/designer-handoff/skill.json +17 -0
  131. package/skills/designer-ship/SKILL.md.tmpl +77 -0
  132. package/skills/designer-ship/skill.json +17 -0
  133. package/skills/objectives/SKILL.md.tmpl +34 -6
  134. package/skills/verify/producer/ui-visual-surfaces.ts +261 -0
  135. package/skills/verify/producer/ui-visual.config.ts +22 -0
  136. package/skills/verify/producer/ui-visual.producer.spec.ts +137 -0
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Auto-derived capture surfaces for the Dark Factory **`ui-visual`** verification
3
+ * route (Designer Mode, cycle 27 Phase C).
4
+ *
5
+ * The `playwright` route (`coverage.ts` + `ui-route.producer.spec.ts`) makes a
6
+ * human hand-author `SURFACES[]` — one entry per URL, with a `covers` glob and a
7
+ * copy-stable `requiredHeading`. That is right for an engineer who knows the app.
8
+ * A non-technical Designer-Mode operator cannot. `ui-visual` instead DERIVES the
9
+ * capture surfaces from the changed Next.js **app-router** files: a changed
10
+ * `app/pricing/page.tsx` maps to the URL `/pricing`, which the producer navigates
11
+ * and screenshots. No `SURFACES[]` to edit.
12
+ *
13
+ * This module is PURE (no browser, no fs, no Dark Factory / vendor dependency) and
14
+ * is the load-bearing half of the route — the path→URL mapping and the fail-closed
15
+ * classification — so it is unit-tested without a browser (mirroring `coverage.ts`).
16
+ *
17
+ * ── CONSUMER REFERENCE FILE — ships in the npm tarball next to `coverage.ts`;
18
+ * copy it into your repo alongside `ui-visual.producer.spec.ts` and own it.
19
+ * `df skills install verify` does NOT overwrite it.
20
+ */
21
+
22
+ /** One capture surface derived from a changed app-router file. */
23
+ export interface DerivedSurface {
24
+ /** URL path to navigate (relative to baseURL), e.g. "/", "/pricing". Dynamic
25
+ * routes carry a `:param` placeholder (e.g. "/blog/:slug") and are NOT
26
+ * directly navigable — see `dynamic`. */
27
+ path: string;
28
+ /** Filesystem-safe slug for the evidence subdir, e.g. "home", "pricing". */
29
+ slug: string;
30
+ /** The changed source path this surface is the evidence for. */
31
+ covers: string;
32
+ /** True when the route has a `[param]` / `[...catch]` segment: the producer
33
+ * cannot navigate it without a concrete value, so it is recorded (for human
34
+ * review) rather than auto-captured. */
35
+ dynamic: boolean;
36
+ }
37
+
38
+ export interface DerivedSurfacePartition {
39
+ /** Static, navigable surfaces — the producer captures each. De-duped by slug. */
40
+ surfaces: DerivedSurface[];
41
+ /** Dynamic routes touched by the change — recorded (need a concrete param),
42
+ * not auto-navigated. De-duped by slug. */
43
+ dynamic: DerivedSurface[];
44
+ /** Changed product-UI paths with NO derivable route (a component, a lib, a
45
+ * non-`app/` `*.tsx`). Auto-derivation cannot know which page renders a
46
+ * component, so these FAIL CLOSED — the change must also touch the page(s)
47
+ * that render them, or the operator adds an explicit surface. Mirrors the
48
+ * `coverage.ts` fail-closed contract. */
49
+ uncovered: string[];
50
+ }
51
+
52
+ // App-router files that render a routable surface. `route.*` (API route
53
+ // handlers) render no UI and are treated as non-visual (exempt). `default.*`
54
+ // (parallel-route fallbacks) render UI at their segment, so they count.
55
+ const ROUTE_FILE_BASENAMES = new Set(["page", "layout", "template", "default"]);
56
+ const ROUTE_FILE_RE = /\.(tsx|jsx|ts|js)$/;
57
+
58
+ /**
59
+ * Harness / non-UI files that are EXEMPT from the coverage requirement — the
60
+ * route's own machinery, styles, tests, type decls, config, data. A change to
61
+ * ONLY these does not require a capture (there is no rendered surface). Kept in
62
+ * sync with `coverage.ts`'s `NON_SURFACE_GLOBS` philosophy but self-contained.
63
+ */
64
+ export const UI_VISUAL_NON_SURFACE_SUFFIXES: readonly string[] = [
65
+ ".css",
66
+ ".scss",
67
+ ".json",
68
+ ".md",
69
+ ".mdx",
70
+ ".d.ts",
71
+ ".test.ts",
72
+ ".test.tsx",
73
+ ".spec.ts",
74
+ ".spec.tsx",
75
+ ];
76
+
77
+ function isNonSurface(path: string): boolean {
78
+ const lower = path.toLowerCase();
79
+ if (UI_VISUAL_NON_SURFACE_SUFFIXES.some((s) => lower.endsWith(s))) return true;
80
+ // The producer harness itself + Playwright config + test dirs.
81
+ if (/(^|\/)tests?(\/|$)/.test(lower)) return true;
82
+ if (/(^|\/)__tests__(\/|$)/.test(lower)) return true;
83
+ if (/(^|\/)(coverage|ui-visual-surfaces)\.ts$/.test(lower)) return true;
84
+ if (/playwright[^/]*\.config\.(ts|js|mjs|cjs)$/.test(lower)) return true;
85
+ if (/\.producer\.spec\.ts$/.test(lower)) return true;
86
+ return false;
87
+ }
88
+
89
+ /**
90
+ * Map one changed source path to a route surface, or `null` when it maps to no
91
+ * navigable route (a component, a private `_folder` colocation file, a file not
92
+ * under an `app/` router root, or an API `route.*` handler).
93
+ */
94
+ export function deriveSurface(changedPath: string): DerivedSurface | null {
95
+ if (ROUTE_FILE_RE.test(changedPath) === false) return null;
96
+ const segments = changedPath.split("/");
97
+ const file = segments[segments.length - 1] ?? "";
98
+ const dotIdx = file.indexOf(".");
99
+ const base = dotIdx >= 0 ? file.slice(0, dotIdx) : file;
100
+ if (!ROUTE_FILE_BASENAMES.has(base)) return null;
101
+
102
+ // Locate the app-router root: the LAST `app` segment (handles `web/app/…`,
103
+ // `apps/web/src/app/…`, `src/app/…`). No `app/` root ⇒ not a routed file.
104
+ const appIdx = segments.lastIndexOf("app");
105
+ if (appIdx < 0) return null;
106
+
107
+ const routeDirSegments = segments.slice(appIdx + 1, segments.length - 1);
108
+ const urlSegments: string[] = [];
109
+ let dynamic = false;
110
+ for (const seg of routeDirSegments) {
111
+ // Route groups `(marketing)` and intercepting markers `(.)`,`(..)` — removed
112
+ // from the URL.
113
+ if (seg.startsWith("(") && seg.endsWith(")")) continue;
114
+ // Parallel-route slots `@modal` — not part of the URL.
115
+ if (seg.startsWith("@")) continue;
116
+ // Private folders `_components` opt OUT of routing — the file is colocation,
117
+ // not a route.
118
+ if (seg.startsWith("_")) return null;
119
+ // Dynamic segments `[slug]`, `[...all]`, `[[...opt]]` → `:slug` placeholder.
120
+ const dyn = seg.match(/^\[+\.{0,3}(.+?)\]+$/);
121
+ if (dyn) {
122
+ dynamic = true;
123
+ urlSegments.push(`:${dyn[1]}`);
124
+ continue;
125
+ }
126
+ urlSegments.push(seg);
127
+ }
128
+
129
+ const path = urlSegments.length === 0 ? "/" : `/${urlSegments.join("/")}`;
130
+ const slug = slugForUrl(urlSegments);
131
+ return { path, slug, covers: changedPath, dynamic };
132
+ }
133
+
134
+ function sanitizeSlug(raw: string): string {
135
+ return raw
136
+ .replace(/:/g, "")
137
+ .replace(/[^a-z0-9-]+/gi, "-")
138
+ .replace(/-+/g, "-")
139
+ .replace(/^-|-$/g, "")
140
+ .toLowerCase();
141
+ }
142
+
143
+ function slugForUrl(urlSegments: readonly string[]): string {
144
+ if (urlSegments.length === 0) return "home";
145
+ return sanitizeSlug(urlSegments.join("-"));
146
+ }
147
+
148
+ /**
149
+ * Partition changed paths (already restricted to the route's trigger surface —
150
+ * `web/**` / `*.tsx` — by `playwright-route.sh`) into the surfaces to capture,
151
+ * the dynamic routes to record, and the uncovered product paths that FAIL CLOSED.
152
+ *
153
+ * An empty `changed` (a manual run with the env unset) yields nothing derivable;
154
+ * the producer falls back to capturing the home surface so evidence is never zero.
155
+ */
156
+ export function deriveSurfacesFromChangedPaths(
157
+ changed: readonly string[],
158
+ ): DerivedSurfacePartition {
159
+ const surfacesBySlug = new Map<string, DerivedSurface>();
160
+ const dynamicBySlug = new Map<string, DerivedSurface>();
161
+ const uncovered: string[] = [];
162
+
163
+ for (const raw of changed) {
164
+ const path = raw.trim();
165
+ if (!path) continue;
166
+ if (isNonSurface(path)) continue; // harness / non-UI — exempt.
167
+ const surface = deriveSurface(path);
168
+ if (surface === null) {
169
+ uncovered.push(path);
170
+ continue;
171
+ }
172
+ const bucket = surface.dynamic ? dynamicBySlug : surfacesBySlug;
173
+ if (!bucket.has(surface.slug)) bucket.set(surface.slug, surface);
174
+ }
175
+
176
+ return {
177
+ surfaces: [...surfacesBySlug.values()],
178
+ dynamic: [...dynamicBySlug.values()],
179
+ uncovered,
180
+ };
181
+ }
182
+
183
+ /**
184
+ * The home surface, smoke-captured when the change armed the route but derived NO
185
+ * navigable surface AND touched no dynamic/uncovered path (a harness-only or
186
+ * signal-less manual run) — so the route never passes with zero evidence.
187
+ */
188
+ export const UI_VISUAL_HOME_FALLBACK: DerivedSurface = {
189
+ path: "/",
190
+ slug: "home",
191
+ covers: "(smoke)",
192
+ dynamic: false,
193
+ };
194
+
195
+ /** The producer's capture decision: what to navigate, or why to fail closed. */
196
+ export interface CapturePlan {
197
+ /** Surfaces the producer captures (empty when `failClosed` is set). */
198
+ capture: DerivedSurface[];
199
+ /** Non-null when the route CANNOT produce real evidence and must fail the run
200
+ * (a dynamic page that can't be auto-navigated, or an uncovered product path).
201
+ * `null` means the plan is safe to capture. */
202
+ failClosed: { code: "uncovered" | "dynamic-unmapped"; paths: string[]; message: string } | null;
203
+ }
204
+
205
+ /**
206
+ * Decide what the `ui-visual` producer captures for a partition of changed paths,
207
+ * or whether it must FAIL CLOSED. The fail-closed contract (extracted here so it is
208
+ * unit-tested without a browser):
209
+ *
210
+ * - `uncovered` path(s) present → fail closed (a bare component can't be evidenced
211
+ * by rendering an unrelated surface).
212
+ * - `dynamic` route(s) present → fail closed. A `[slug]` / `[...all]` page has no
213
+ * auto-navigable URL, so this auto-derive route cannot evidence it; capturing
214
+ * anything else (a static sibling, or the home fallback) would be a false pass
215
+ * for the changed page. Dynamic pages belong to the engineer `playwright` route
216
+ * (hand-authored concrete surfaces). Fail closed even when static surfaces are
217
+ * also present — a run is all-or-nothing.
218
+ * - otherwise capture the derived static surfaces; if there are none, smoke-capture
219
+ * the home surface so evidence is never zero.
220
+ */
221
+ export function planCapture(partition: DerivedSurfacePartition): CapturePlan {
222
+ const { surfaces, dynamic, uncovered } = partition;
223
+
224
+ if (uncovered.length > 0) {
225
+ return {
226
+ capture: [],
227
+ failClosed: {
228
+ code: "uncovered",
229
+ paths: [...uncovered],
230
+ message:
231
+ "ui-visual route: the following changed UI path(s) map to NO app-router " +
232
+ "surface, so this route cannot produce real evidence for them and FAILS " +
233
+ "CLOSED:\n" +
234
+ uncovered.map((p) => ` - ${p}`).join("\n") +
235
+ "\n\nAuto-derivation captures app-router pages (e.g. app/foo/page.tsx → " +
236
+ "/foo). A bare component has no route: also change (or add a page under " +
237
+ "app/ that renders) the affected UI, so the capture is real.",
238
+ },
239
+ };
240
+ }
241
+
242
+ if (dynamic.length > 0) {
243
+ return {
244
+ capture: [],
245
+ failClosed: {
246
+ code: "dynamic-unmapped",
247
+ paths: dynamic.map((d) => d.path),
248
+ message:
249
+ "ui-visual route: dynamic app-router page(s) changed. This auto-derive " +
250
+ "route does NOT support dynamic pages (a `[slug]` / `[...all]` route has " +
251
+ "no concrete URL to auto-navigate), so it FAILS CLOSED:\n" +
252
+ dynamic.map((d) => ` - ${d.covers} → ${d.path}`).join("\n") +
253
+ "\n\nCapture dynamic pages with the engineer `playwright` route instead " +
254
+ "(its hand-authored SURFACES[] take real params).",
255
+ },
256
+ };
257
+ }
258
+
259
+ if (surfaces.length > 0) return { capture: [...surfaces], failClosed: null };
260
+ return { capture: [UI_VISUAL_HOME_FALLBACK], failClosed: null };
261
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Playwright config for the **`ui-visual` (Designer Mode) verification-route
3
+ * producer** (cycle 27 Phase C).
4
+ *
5
+ * DRY over the base UI-route config: it reuses every setting from
6
+ * `playwright.ui-route.config.ts` (one chromium project, no retries, optional
7
+ * storage-state, optional local-preview `webServer`, all the same env knobs) and
8
+ * changes ONLY `testMatch` so this config runs `ui-visual.producer.spec.ts`
9
+ * instead of `ui-route.producer.spec.ts`. Kept a separate file so the shared
10
+ * `playwright-route.sh` can select it via `DF_UI_ROUTE_CONFIG=ui-visual.config.ts`
11
+ * without disturbing the `playwright` route.
12
+ *
13
+ * ── CONSUMER REFERENCE FILE — copy into your repo next to the base config +
14
+ * the producer spec and own it. `df skills install verify` does NOT overwrite it.
15
+ */
16
+ import { defineConfig } from "@playwright/test";
17
+ import base from "./playwright.ui-route.config";
18
+
19
+ export default defineConfig({
20
+ ...base,
21
+ testMatch: /ui-visual\.producer\.spec\.ts$/,
22
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Reusable **`ui-visual` (Designer Mode) verification-route producer** (cycle 27
3
+ * Phase C).
4
+ *
5
+ * The Designer-Mode sibling of `ui-route.producer.spec.ts`. Same evidence shape
6
+ * and the same shared plumbing (`playwright-route.sh`, pointed here via
7
+ * `DF_UI_ROUTE_CONFIG=ui-visual.config.ts`), with ONE difference that makes it
8
+ * usable by a non-technical operator: the capture surfaces are **auto-derived**
9
+ * from the changed Next.js app-router files (`ui-visual-surfaces.ts`) instead of a
10
+ * hand-authored `SURFACES[]`. A changed `app/pricing/page.tsx` is captured at
11
+ * `/pricing`; the designer edits no config.
12
+ *
13
+ * For each derived surface it captures the required floor:
14
+ * (1) an **ARIA snapshot** (accessibility tree — structural, tamper-resistant),
15
+ * (2) an **after screenshot** at the SHA under review, plus an optional
16
+ * **before screenshot** baseline when `DF_UI_ROUTE_BEFORE_BASE_URL` is set
17
+ * (the before/after pair the PR embed helper renders for humans).
18
+ *
19
+ * Fail-closed coverage (see `planCapture`): a changed product-UI path that maps to
20
+ * NO route (a bare component, a non-`app/` `*.tsx`), or a DYNAMIC page (`[slug]` /
21
+ * `[...all]`), BLOCKS the route — auto-derivation can evidence neither by rendering
22
+ * an unrelated surface. Dynamic pages are unsupported by this auto-derive route, so
23
+ * capture them with the engineer `playwright` route (hand-authored surfaces); cover
24
+ * a component by also touching the page(s) that render it. Never passes with zero
25
+ * evidence: a harness-only or signal-less run smoke-captures the home surface.
26
+ *
27
+ * ── CONSUMER REFERENCE FILE — copy into your repo (with `ui-visual-surfaces.ts`
28
+ * and `ui-visual.config.ts`) and own it. `df skills install verify` does NOT
29
+ * overwrite it.
30
+ */
31
+ import { test, expect, type Page } from "@playwright/test";
32
+ import { mkdirSync, writeFileSync } from "node:fs";
33
+ import { join } from "node:path";
34
+ import {
35
+ deriveSurfacesFromChangedPaths,
36
+ planCapture,
37
+ type DerivedSurface,
38
+ } from "./ui-visual-surfaces";
39
+
40
+ const SHA = process.env.DF_UI_ROUTE_SHA ?? "unknown";
41
+ const OUT_DIR =
42
+ process.env.DF_UI_ROUTE_OUT ?? `agent-reviews/quality-gates/ui/${SHA}`;
43
+ const BEFORE_BASE_URL = process.env.DF_UI_ROUTE_BEFORE_BASE_URL?.replace(/\/$/, "");
44
+
45
+ const CHANGED_UI_PATHS = (process.env.DF_UI_ROUTE_CHANGED_PATHS ?? "")
46
+ .split(/\r?\n/)
47
+ .map((p) => p.trim())
48
+ .filter(Boolean);
49
+
50
+ const partition = deriveSurfacesFromChangedPaths(CHANGED_UI_PATHS);
51
+
52
+ // The capture decision + fail-closed contract live in the pure, unit-tested
53
+ // `planCapture`: a dynamic-page change FAILS CLOSED (it has no auto-navigable URL
54
+ // — see the module header) instead of "passing" on an unrelated capture. Never
55
+ // zero evidence for a real static change.
56
+ const plan = planCapture(partition);
57
+ const CAPTURE_SURFACES: DerivedSurface[] = plan.capture;
58
+
59
+ function routeOutDir(slug: string): string {
60
+ return join(OUT_DIR, slug);
61
+ }
62
+
63
+ // FAIL-CLOSED coverage gate: when `planCapture` cannot produce real evidence for a
64
+ // changed path (an uncovered bare component, or a dynamic page with no concrete
65
+ // surface), the route FAILS rather than "passing" on an unrelated home capture.
66
+ test("ui-visual coverage — every changed UI path maps to a real capture or is exempt", async () => {
67
+ if (plan.failClosed) throw new Error(plan.failClosed.message);
68
+ expect(plan.failClosed).toBeNull();
69
+ });
70
+
71
+ async function captureBefore(page: Page, surface: DerivedSurface, dir: string): Promise<void> {
72
+ if (!BEFORE_BASE_URL) return;
73
+ const beforeUrl = `${BEFORE_BASE_URL}${surface.path}`;
74
+ try {
75
+ await page.goto(beforeUrl, { waitUntil: "networkidle", timeout: 30_000 });
76
+ await page.screenshot({ path: join(dir, "before.png"), fullPage: true });
77
+ } catch (err) {
78
+ // The baseline is best-effort; a missing/unreachable baseline must not fail
79
+ // the route (the ARIA + after capture is the floor). Record why.
80
+ test.info().annotations.push({
81
+ type: "before-capture-skipped",
82
+ description: `baseline ${beforeUrl} unreachable: ${
83
+ err instanceof Error ? err.message : String(err)
84
+ }`,
85
+ });
86
+ }
87
+ }
88
+
89
+ for (const surface of CAPTURE_SURFACES) {
90
+ test(`ui-visual evidence — ${surface.slug} (${surface.path})`, async ({ page }) => {
91
+ const dir = routeOutDir(surface.slug);
92
+ mkdirSync(dir, { recursive: true });
93
+
94
+ // --- (optional) BEFORE baseline from the configured origin ---
95
+ await captureBefore(page, surface, dir);
96
+
97
+ // --- AFTER: the SHA under review ---
98
+ await page.goto(surface.path, { waitUntil: "networkidle", timeout: 30_000 });
99
+
100
+ // Structural render floor: a real page exposes a `main` landmark. This is a
101
+ // relaxed, copy-STABLE assertion (no per-surface heading to hand-author) that
102
+ // still fails a blank screen / crash / error boundary — the point of a gate.
103
+ await expect(page.getByRole("main").first()).toBeVisible({ timeout: 15_000 });
104
+
105
+ // Floor evidence #1 — ARIA snapshot (accessibility tree). Structural,
106
+ // diff-able, hard to stage. Non-trivial, then persisted.
107
+ const ariaSnapshot = await page.locator("body").ariaSnapshot();
108
+ expect(ariaSnapshot.trim().length).toBeGreaterThan(0);
109
+ writeFileSync(join(dir, "aria.snapshot.txt"), `${ariaSnapshot}\n`, "utf8");
110
+
111
+ // Floor evidence #2 — the "after" screenshot bound to the gated SHA.
112
+ await page.screenshot({ path: join(dir, "after.png"), fullPage: true });
113
+
114
+ writeFileSync(
115
+ join(dir, "meta.json"),
116
+ `${JSON.stringify(
117
+ {
118
+ surface: surface.slug,
119
+ path: surface.path,
120
+ covers: surface.covers,
121
+ sha: SHA,
122
+ capturedAt: new Date().toISOString(),
123
+ baseURL: test.info().project.use.baseURL ?? null,
124
+ beforeBaseURL: BEFORE_BASE_URL ?? null,
125
+ ariaSnapshotBytes: Buffer.byteLength(ariaSnapshot, "utf8"),
126
+ evidenceKind: "playwright",
127
+ route: "ui-visual",
128
+ floor: ["aria-snapshot", "after-screenshot"],
129
+ beforeScreenshot: BEFORE_BASE_URL ? "before.png" : null,
130
+ },
131
+ null,
132
+ 2,
133
+ )}\n`,
134
+ "utf8",
135
+ );
136
+ });
137
+ }