@momentiq/dark-factory-cli 3.0.2 → 3.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.
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +3 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/mode.d.ts.map +1 -1
- package/dist/commands/mode.js +120 -22
- package/dist/commands/mode.js.map +1 -1
- package/dist/commands/skills.d.ts.map +1 -1
- package/dist/commands/skills.js +11 -1
- package/dist/commands/skills.js.map +1 -1
- package/dist/commands/verify.d.ts.map +1 -1
- package/dist/commands/verify.js +113 -8
- package/dist/commands/verify.js.map +1 -1
- package/dist/mcp/tools/skills-install.d.ts.map +1 -1
- package/dist/mcp/tools/skills-install.js +8 -3
- package/dist/mcp/tools/skills-install.js.map +1 -1
- package/dist/mode/critic-registration.d.ts +30 -10
- package/dist/mode/critic-registration.d.ts.map +1 -1
- package/dist/mode/critic-registration.js +31 -10
- package/dist/mode/critic-registration.js.map +1 -1
- package/dist/mode/guard.d.ts.map +1 -1
- package/dist/mode/guard.js +30 -2
- package/dist/mode/guard.js.map +1 -1
- package/dist/mode/init.d.ts +8 -4
- package/dist/mode/init.d.ts.map +1 -1
- package/dist/mode/init.js +148 -12
- package/dist/mode/init.js.map +1 -1
- package/dist/mode/preview-capture.d.ts +3 -0
- package/dist/mode/preview-capture.d.ts.map +1 -0
- package/dist/mode/preview-capture.js +137 -0
- package/dist/mode/preview-capture.js.map +1 -0
- package/dist/mode/preview.d.ts +227 -0
- package/dist/mode/preview.d.ts.map +1 -0
- package/dist/mode/preview.js +802 -0
- package/dist/mode/preview.js.map +1 -0
- package/dist/onboard/seeders/design-md.d.ts.map +1 -1
- package/dist/onboard/seeders/design-md.js +512 -17
- package/dist/onboard/seeders/design-md.js.map +1 -1
- package/dist/skills/install.d.ts +25 -5
- package/dist/skills/install.d.ts.map +1 -1
- package/dist/skills/install.js +34 -17
- package/dist/skills/install.js.map +1 -1
- package/dist/skills/known-skills.d.ts +2 -0
- package/dist/skills/known-skills.d.ts.map +1 -0
- package/dist/skills/known-skills.js +25 -0
- package/dist/skills/known-skills.js.map +1 -0
- package/package.json +2 -2
- package/skills/designer-build/SKILL.md.tmpl +14 -0
- package/skills/designer-handoff/SKILL.md.tmpl +28 -1
- package/skills/verify/producer/README.md +29 -4
- package/skills/verify/producer/playwright-route.sh +10 -0
- package/skills/verify/producer/surface-declaration.ts +373 -0
- package/skills/verify/producer/ui-route.producer.spec.ts +44 -11
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
// `df mode preview <component-path>` — component-isolation visual evidence
|
|
2
|
+
// (Closes momentiq-ai/dark-factory#357).
|
|
3
|
+
//
|
|
4
|
+
// THE PAIN. Designer Mode's `evidence.visual: required` mandates a visual loop:
|
|
5
|
+
// boot the app, navigate to the changed surface, screenshot it (the `ui-visual`
|
|
6
|
+
// route). On a container-first / k3d repo the app needs auth + seeded data to
|
|
7
|
+
// boot, and a bare component (a `components/Widget.tsx` with no page that renders
|
|
8
|
+
// it) maps to NO navigable route — so the `ui-visual` route FAILS CLOSED
|
|
9
|
+
// (`planCapture` → `uncovered`) and a NON-ENGINEER cannot produce a screenshot.
|
|
10
|
+
// In the #357 dogfood ~5 avenues to a screenshot all dead-ended here.
|
|
11
|
+
//
|
|
12
|
+
// THE FIX (bounded, first version). Render the ONE component in an isolated
|
|
13
|
+
// harness — no full app, no auth, no data — and capture the SAME evidence shape
|
|
14
|
+
// the `ui-visual` route already emits (`agent-reviews/quality-gates/ui/<sha>/
|
|
15
|
+
// <slug>/{aria.snapshot.txt, after.png, meta.json}`, `evidenceKind:"playwright"`).
|
|
16
|
+
// The existing embed/publish path (`discoverUiEvidence` / `buildScreenshotEmbed`
|
|
17
|
+
// / `readUiArtifacts`) consumes it unchanged. This does NOT by itself create a
|
|
18
|
+
// `QualityGateEvidence` gate result — it produces the artifacts that path renders
|
|
19
|
+
// for a human and uploads.
|
|
20
|
+
//
|
|
21
|
+
// This module is the PURE + orchestration half (no browser, no bundler): harness
|
|
22
|
+
// generation and framework detection are pure and unit-tested; `runPreview` takes
|
|
23
|
+
// an injected `CaptureDeps` so the Vite-server + Playwright wiring
|
|
24
|
+
// (`preview-capture.ts`) is never imported by tests. It degrades gracefully —
|
|
25
|
+
// never crashes — when the component genuinely cannot be rendered (no React / no
|
|
26
|
+
// Vite / a server component) or when the local capture tooling is unavailable.
|
|
27
|
+
//
|
|
28
|
+
// FRAMEWORK BOUNDARY (see the PR body — this is the design edge for PJ + the
|
|
29
|
+
// parallel role-redesign): v1 targets the common case, a React client component
|
|
30
|
+
// rendered through Vite. Vue/Svelte/Angular, Webpack/CRA transform, React Server
|
|
31
|
+
// Components, and a prop-fixture/args model are DEFERRED — each degrades with a
|
|
32
|
+
// clear message rather than a wrong render.
|
|
33
|
+
import { mkdtempSync, readFileSync, existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
34
|
+
import { isAbsolute, join, relative, resolve, sep, basename, dirname } from "node:path";
|
|
35
|
+
import { resolveCommit } from "../git.js";
|
|
36
|
+
import { FRONTEND_ROOT_DIRS } from "./init.js";
|
|
37
|
+
function packageDeclaresReact(dir) {
|
|
38
|
+
const deps = readPackageDeps(dir);
|
|
39
|
+
return !!deps && "react" in deps;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the frontend workspace for a repo rooted at `cwd`. Mirrors
|
|
43
|
+
* `df mode init`'s detection (same FRONTEND_ROOT_DIRS): the first of
|
|
44
|
+
* `web/`,`frontend/`,`client/` whose package.json declares react wins; else the
|
|
45
|
+
* repo root itself when ITS package.json declares react. Returns `null` when no
|
|
46
|
+
* react is found anywhere — the caller degrades with the existing no-react
|
|
47
|
+
* blocker (which keeps the honest "install react" message + exit code). PURE.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveFrontendWorkspace(cwd) {
|
|
50
|
+
for (const root of FRONTEND_ROOT_DIRS) {
|
|
51
|
+
const dir = join(cwd, root);
|
|
52
|
+
if (packageDeclaresReact(dir))
|
|
53
|
+
return { workspaceDir: dir, relLabel: root };
|
|
54
|
+
}
|
|
55
|
+
if (packageDeclaresReact(cwd))
|
|
56
|
+
return { workspaceDir: cwd, relLabel: "." };
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Strip line (`//`) and block comments from JSONC, STRING-AWARE: a naive
|
|
61
|
+
* stripper corrupts a value like `"./src/*"` (which contains a literal
|
|
62
|
+
* slash-star) by treating it as the start of a block comment. Tracks string
|
|
63
|
+
* state (and escapes) so only real comments are removed. PURE.
|
|
64
|
+
*/
|
|
65
|
+
export function stripJsonComments(text) {
|
|
66
|
+
let out = "";
|
|
67
|
+
let inString = false;
|
|
68
|
+
let inLine = false;
|
|
69
|
+
let inBlock = false;
|
|
70
|
+
for (let i = 0; i < text.length; i++) {
|
|
71
|
+
const c = text[i];
|
|
72
|
+
const next = text[i + 1];
|
|
73
|
+
if (inLine) {
|
|
74
|
+
if (c === "\n") {
|
|
75
|
+
inLine = false;
|
|
76
|
+
out += c;
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (inBlock) {
|
|
81
|
+
if (c === "*" && next === "/") {
|
|
82
|
+
inBlock = false;
|
|
83
|
+
i++;
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (inString) {
|
|
88
|
+
out += c;
|
|
89
|
+
if (c === "\\") {
|
|
90
|
+
// Preserve the escaped char verbatim (e.g. \" \\ ) — it can't close the string.
|
|
91
|
+
if (next !== undefined) {
|
|
92
|
+
out += next;
|
|
93
|
+
i++;
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (c === '"')
|
|
98
|
+
inString = false;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// Outside string + comment.
|
|
102
|
+
if (c === '"') {
|
|
103
|
+
inString = true;
|
|
104
|
+
out += c;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (c === "/" && next === "/") {
|
|
108
|
+
inLine = true;
|
|
109
|
+
i++;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (c === "/" && next === "*") {
|
|
113
|
+
inBlock = true;
|
|
114
|
+
i++;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
out += c;
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
/** Tolerant JSONC parse: strip comments (string-aware) + trailing commas, then
|
|
122
|
+
* JSON.parse. Returns `null` on any failure (caller proceeds with no alias). */
|
|
123
|
+
export function parseJsonc(text) {
|
|
124
|
+
try {
|
|
125
|
+
const noComments = stripJsonComments(text);
|
|
126
|
+
// Drop a trailing comma before `}`/`]`. Applied to comment-free text; tsconfig
|
|
127
|
+
// `paths` values are globs (`./src/*`), never `, }`, so this is safe here.
|
|
128
|
+
const noTrailingCommas = noComments.replace(/,(\s*[}\]])/g, "$1");
|
|
129
|
+
return JSON.parse(noTrailingCommas);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function escapeRegexLiteral(s) {
|
|
136
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Classify ONE tsconfig `paths` string (a key OR a target) into the shapes an
|
|
140
|
+
* alias can faithfully represent. ONE classifier for BOTH sides — so key and
|
|
141
|
+
* target are judged by the identical rule and there is no third, un-handled case.
|
|
142
|
+
* - no `*` → `exact` (prefix = the whole string).
|
|
143
|
+
* - a single trailing `/*` with a non-empty segment before the `/*`
|
|
144
|
+
* (`"@/*"`, `"./src/*"`, `"./*"`) → `prefix` (prefix = string minus the
|
|
145
|
+
* trailing `/*`, e.g. `"@/*"` → `"@"`, `"./src/*"` → `"./src"`).
|
|
146
|
+
* - everything else — a mid-pattern wildcard (a `*` with more path segments
|
|
147
|
+
* after it, e.g. a `@foo` key with a `bar` suffix, or a monorepo
|
|
148
|
+
* `packages`-style target), a bare prefix (`"@*"`), a bare `"*"` catch-all,
|
|
149
|
+
* or a degenerate `"/*"` (empty prefix) → `unsupported`.
|
|
150
|
+
*/
|
|
151
|
+
function classifyGlob(str) {
|
|
152
|
+
const starIdx = str.indexOf("*");
|
|
153
|
+
if (starIdx === -1)
|
|
154
|
+
return { kind: "exact", prefix: str };
|
|
155
|
+
const single = starIdx === str.lastIndexOf("*");
|
|
156
|
+
// Trailing "/*" (str ends with "/*") AND a non-empty segment before it
|
|
157
|
+
// (str.length > 2 rules out the degenerate "/*").
|
|
158
|
+
if (single && str.endsWith("/*") && str.length > 2) {
|
|
159
|
+
return { kind: "prefix", prefix: str.slice(0, -2) };
|
|
160
|
+
}
|
|
161
|
+
return { kind: "unsupported", prefix: "" };
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Convert tsconfig `compilerOptions.paths` (+ `baseUrl`) into Vite alias entries.
|
|
165
|
+
* `paths` targets resolve relative to `baseUrl` (default `.` = tsconfigDir).
|
|
166
|
+
* Only the FIRST target is used (Vite alias has no fallback array). PURE.
|
|
167
|
+
*
|
|
168
|
+
* A single `classifyGlob` is applied to BOTH the key and the target, and an entry
|
|
169
|
+
* is emitted ONLY when the two AGREE — so mis-shapen mappings can't leak through
|
|
170
|
+
* on either side:
|
|
171
|
+
* - both `exact` → exact alias (`^<key>$` → resolved target).
|
|
172
|
+
* - both `prefix` → prefix alias (`^<keyPrefix>/` → `<resolvedTargetPrefix>/`).
|
|
173
|
+
* The find RE-ADDS the boundary `/` so it anchors on a path separator: `"@/*"`
|
|
174
|
+
* → `^@/`, which never matches `@scope/pkg` (a bare `^@` WOULD shadow every
|
|
175
|
+
* scoped npm package — the bug this guards).
|
|
176
|
+
* - ANY other combination — either side `unsupported`, or a KIND MISMATCH like a
|
|
177
|
+
* `prefix` key with an `exact` target (`"@/*": ["./src/index.ts"]`, invalid
|
|
178
|
+
* TS) — is SKIPPED: the key is returned in `unsupported`, no alias, no throw.
|
|
179
|
+
*
|
|
180
|
+
* The common shadcn/Next shapes (`"@/*": ["./src/*"]`, `["./*"]`, exact keys) are
|
|
181
|
+
* unaffected; monorepo mid-glob mappings (a `@pkg` key mapped to a
|
|
182
|
+
* `packages`-style target with an inner wildcard) skip.
|
|
183
|
+
*/
|
|
184
|
+
export function tsconfigPathsToAlias(input) {
|
|
185
|
+
const { paths, tsconfigDir } = input;
|
|
186
|
+
if (!paths || typeof paths !== "object")
|
|
187
|
+
return { alias: [], unsupported: [] };
|
|
188
|
+
const baseAbs = resolve(tsconfigDir, input.baseUrl ?? ".");
|
|
189
|
+
const alias = [];
|
|
190
|
+
const unsupported = [];
|
|
191
|
+
for (const [key, targets] of Object.entries(paths)) {
|
|
192
|
+
if (!Array.isArray(targets) || targets.length === 0)
|
|
193
|
+
continue;
|
|
194
|
+
const target = targets[0];
|
|
195
|
+
if (typeof target !== "string")
|
|
196
|
+
continue;
|
|
197
|
+
const k = classifyGlob(key);
|
|
198
|
+
const t = classifyGlob(target);
|
|
199
|
+
if (k.kind === "exact" && t.kind === "exact") {
|
|
200
|
+
// Exact key → exact-match alias to the resolved (file/dir) target.
|
|
201
|
+
alias.push({
|
|
202
|
+
find: new RegExp(`^${escapeRegexLiteral(k.prefix)}$`),
|
|
203
|
+
replacement: resolve(baseAbs, t.prefix),
|
|
204
|
+
});
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (k.kind === "prefix" && t.kind === "prefix") {
|
|
208
|
+
// Re-add the boundary "/" to the KEY find (anchors on a path separator so
|
|
209
|
+
// `^@/` never matches `@scope/pkg`) and a trailing sep to the resolved
|
|
210
|
+
// TARGET prefix (the find consumes the source's boundary "/").
|
|
211
|
+
let replacement = resolve(baseAbs, t.prefix); // strips any trailing sep
|
|
212
|
+
if (!replacement.endsWith(sep))
|
|
213
|
+
replacement += sep;
|
|
214
|
+
alias.push({ find: new RegExp(`^${escapeRegexLiteral(k.prefix)}/`), replacement });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
// Either side unsupported, OR a key/target kind mismatch → skip + surface.
|
|
218
|
+
unsupported.push(key);
|
|
219
|
+
}
|
|
220
|
+
return { alias, unsupported };
|
|
221
|
+
}
|
|
222
|
+
/** Read compilerOptions.{paths,baseUrl} + `extends` from a tsconfig/jsconfig
|
|
223
|
+
* file (JSONC-tolerant). Returns null when the file is unreadable/unparseable. */
|
|
224
|
+
function readTsconfigFile(filePath) {
|
|
225
|
+
if (!existsSync(filePath))
|
|
226
|
+
return null;
|
|
227
|
+
let raw;
|
|
228
|
+
try {
|
|
229
|
+
raw = readFileSync(filePath, "utf8");
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
const parsed = parseJsonc(raw);
|
|
235
|
+
if (!parsed || typeof parsed !== "object")
|
|
236
|
+
return null;
|
|
237
|
+
const co = parsed.compilerOptions ?? {};
|
|
238
|
+
return {
|
|
239
|
+
...(co.paths ? { paths: co.paths } : {}),
|
|
240
|
+
...(typeof co.baseUrl === "string" ? { baseUrl: co.baseUrl } : {}),
|
|
241
|
+
...(typeof parsed.extends === "string" ? { extends: parsed.extends } : {}),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
function toAliasResolution(r) {
|
|
245
|
+
return {
|
|
246
|
+
alias: r.alias,
|
|
247
|
+
warnings: r.unsupported.map((k) => `path alias "${k}" not wired (unsupported glob shape)`),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Read the workspace's tsconfig (jsconfig fallback) and return Vite alias
|
|
252
|
+
* entries for its path map, plus notes for any keys skipped as unsupported. When
|
|
253
|
+
* the top file has no `paths` but a TRIVIAL relative `extends` (a `./…json` path,
|
|
254
|
+
* one level), the base file's `paths` are used, resolved relative to the base
|
|
255
|
+
* file's dir. Non-trivial extends (a bare package specifier, an array of extends,
|
|
256
|
+
* or multi-level chains) are DEFERRED — proceed with no alias rather than
|
|
257
|
+
* half-resolve. Missing tsconfig / no `paths` → empty (unchanged behavior; the
|
|
258
|
+
* harness just has no extra aliases). Impure (fs); the conversion it delegates to
|
|
259
|
+
* is pure + unit-tested.
|
|
260
|
+
*/
|
|
261
|
+
function readAliasEntries(workspaceDir) {
|
|
262
|
+
const none = { alias: [], warnings: [] };
|
|
263
|
+
for (const name of ["tsconfig.json", "jsconfig.json"]) {
|
|
264
|
+
const filePath = join(workspaceDir, name);
|
|
265
|
+
const cfg = readTsconfigFile(filePath);
|
|
266
|
+
if (!cfg)
|
|
267
|
+
continue;
|
|
268
|
+
if (cfg.paths) {
|
|
269
|
+
return toAliasResolution(tsconfigPathsToAlias({ paths: cfg.paths, baseUrl: cfg.baseUrl, tsconfigDir: workspaceDir }));
|
|
270
|
+
}
|
|
271
|
+
// One level of trivial relative extends (the common "extends ./tsconfig.base.json").
|
|
272
|
+
if (typeof cfg.extends === "string" && cfg.extends.startsWith(".")) {
|
|
273
|
+
let ext = cfg.extends;
|
|
274
|
+
if (!ext.endsWith(".json"))
|
|
275
|
+
ext += ".json";
|
|
276
|
+
const extPath = resolve(workspaceDir, ext);
|
|
277
|
+
const base = readTsconfigFile(extPath);
|
|
278
|
+
if (base?.paths) {
|
|
279
|
+
return toAliasResolution(tsconfigPathsToAlias({
|
|
280
|
+
paths: base.paths,
|
|
281
|
+
baseUrl: base.baseUrl,
|
|
282
|
+
tsconfigDir: dirname(extPath),
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return none;
|
|
287
|
+
}
|
|
288
|
+
return none;
|
|
289
|
+
}
|
|
290
|
+
export const ENTRY_BASENAME = "df-preview-entry.tsx";
|
|
291
|
+
export const INDEX_HTML_BASENAME = "index.html";
|
|
292
|
+
/** POSIX-normalize a relative fs path for use as an ES import specifier. */
|
|
293
|
+
function toImportSpecifier(from, to) {
|
|
294
|
+
let spec = relative(from, to).split(sep).join("/");
|
|
295
|
+
if (!spec.startsWith("."))
|
|
296
|
+
spec = `./${spec}`;
|
|
297
|
+
return spec;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Generate the minimal isolated-render harness for a React component: an entry
|
|
301
|
+
* that mounts JUST that component (placeholder/default props) inside a `<main>`
|
|
302
|
+
* landmark, plus an `index.html`. The `<main>` wrapper guarantees the same
|
|
303
|
+
* structural render floor the `ui-visual` producer asserts (`getByRole("main")`),
|
|
304
|
+
* regardless of what the component itself renders. PURE — returns file contents;
|
|
305
|
+
* the caller writes them.
|
|
306
|
+
*/
|
|
307
|
+
export function generateHarness(input) {
|
|
308
|
+
const { componentAbsPath, harnessDir, componentName, exportName, reactMajor } = input;
|
|
309
|
+
const importSpec = toImportSpecifier(harnessDir, componentAbsPath);
|
|
310
|
+
const importLine = exportName === undefined
|
|
311
|
+
? `import Target from ${JSON.stringify(importSpec)};`
|
|
312
|
+
: `import { ${exportName} as Target } from ${JSON.stringify(importSpec)};`;
|
|
313
|
+
// React major picks the mount API. `import React` is inert under the automatic
|
|
314
|
+
// JSX runtime (plugin-react's default) and covers a classic-runtime consumer.
|
|
315
|
+
const mount = reactMajor >= 18
|
|
316
|
+
? [
|
|
317
|
+
`import { createRoot } from "react-dom/client";`,
|
|
318
|
+
`const el = document.getElementById("df-preview-root");`,
|
|
319
|
+
`if (el) {`,
|
|
320
|
+
` createRoot(el).render(`,
|
|
321
|
+
` <main aria-label=${JSON.stringify(`df preview: ${componentName}`)}>`,
|
|
322
|
+
` <Target />`,
|
|
323
|
+
` </main>,`,
|
|
324
|
+
` );`,
|
|
325
|
+
`}`,
|
|
326
|
+
]
|
|
327
|
+
: [
|
|
328
|
+
`import { render } from "react-dom";`,
|
|
329
|
+
`const el = document.getElementById("df-preview-root");`,
|
|
330
|
+
`if (el) {`,
|
|
331
|
+
` render(`,
|
|
332
|
+
` <main aria-label=${JSON.stringify(`df preview: ${componentName}`)}>`,
|
|
333
|
+
` <Target />`,
|
|
334
|
+
` </main>,`,
|
|
335
|
+
` el,`,
|
|
336
|
+
` );`,
|
|
337
|
+
`}`,
|
|
338
|
+
];
|
|
339
|
+
const entry = [
|
|
340
|
+
`// AUTO-GENERATED by \`df mode preview\` — isolated render harness for`,
|
|
341
|
+
`// ${componentName}. Ephemeral; the command removes this dir after capture.`,
|
|
342
|
+
`import React from "react";`,
|
|
343
|
+
importLine,
|
|
344
|
+
"",
|
|
345
|
+
...mount,
|
|
346
|
+
"",
|
|
347
|
+
].join("\n");
|
|
348
|
+
const html = [
|
|
349
|
+
`<!doctype html>`,
|
|
350
|
+
`<html lang="en">`,
|
|
351
|
+
` <head>`,
|
|
352
|
+
` <meta charset="utf-8" />`,
|
|
353
|
+
` <meta name="viewport" content="width=device-width, initial-scale=1" />`,
|
|
354
|
+
` <title>df preview: ${escapeHtml(componentName)}</title>`,
|
|
355
|
+
` </head>`,
|
|
356
|
+
` <body>`,
|
|
357
|
+
` <div id="df-preview-root"></div>`,
|
|
358
|
+
` <script type="module" src="/${ENTRY_BASENAME}"></script>`,
|
|
359
|
+
` </body>`,
|
|
360
|
+
`</html>`,
|
|
361
|
+
"",
|
|
362
|
+
].join("\n");
|
|
363
|
+
return [
|
|
364
|
+
{ path: ENTRY_BASENAME, contents: entry },
|
|
365
|
+
{ path: INDEX_HTML_BASENAME, contents: html },
|
|
366
|
+
];
|
|
367
|
+
}
|
|
368
|
+
function escapeHtml(s) {
|
|
369
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
370
|
+
}
|
|
371
|
+
const COMPONENT_EXT_RE = /\.(tsx|jsx)$/;
|
|
372
|
+
function parseReactMajor(deps) {
|
|
373
|
+
const raw = deps?.["react"];
|
|
374
|
+
if (!raw)
|
|
375
|
+
return null;
|
|
376
|
+
const m = raw.match(/(\d+)/);
|
|
377
|
+
return m ? Number(m[1]) : null;
|
|
378
|
+
}
|
|
379
|
+
/** Best-effort PascalCase component name from a filename (`user-card.tsx` →
|
|
380
|
+
* `UserCard`, `Button.tsx` → `Button`). */
|
|
381
|
+
export function componentNameFromPath(componentAbsPath) {
|
|
382
|
+
const base = basename(componentAbsPath).replace(COMPONENT_EXT_RE, "");
|
|
383
|
+
const parts = base.split(/[-_.\s]+/).filter(Boolean);
|
|
384
|
+
if (parts.length === 0)
|
|
385
|
+
return "Component";
|
|
386
|
+
return parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Resolve which export the harness should mount. Preference order:
|
|
390
|
+
* 1. `export default` present → default import (undefined exportName).
|
|
391
|
+
* 2. a named export matching the PascalCase filename.
|
|
392
|
+
* 3. the first `export function|const <PascalCaseName>`.
|
|
393
|
+
* Returns `{ kind: "none" }` when no export is found (a blocker).
|
|
394
|
+
*/
|
|
395
|
+
export function resolveExport(source, componentName) {
|
|
396
|
+
if (/\bexport\s+default\b/.test(source))
|
|
397
|
+
return { kind: "default" };
|
|
398
|
+
const named = new Set();
|
|
399
|
+
const re = /\bexport\s+(?:async\s+)?(?:function|const|class)\s+([A-Za-z_$][\w$]*)/g;
|
|
400
|
+
let m;
|
|
401
|
+
while ((m = re.exec(source)) !== null)
|
|
402
|
+
named.add(m[1]);
|
|
403
|
+
// `export { Foo, Bar as Baz }`
|
|
404
|
+
const reBlock = /\bexport\s*\{([^}]*)\}/g;
|
|
405
|
+
while ((m = reBlock.exec(source)) !== null) {
|
|
406
|
+
for (const piece of m[1].split(",")) {
|
|
407
|
+
const asMatch = piece.trim().match(/(?:\bas\s+([A-Za-z_$][\w$]*))$/);
|
|
408
|
+
const name = asMatch ? asMatch[1] : piece.trim().split(/\s+/)[0];
|
|
409
|
+
if (name)
|
|
410
|
+
named.add(name);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (named.has(componentName))
|
|
414
|
+
return { kind: "named", name: componentName };
|
|
415
|
+
// First PascalCase named export (React components are PascalCase by convention).
|
|
416
|
+
for (const n of named) {
|
|
417
|
+
if (/^[A-Z]/.test(n))
|
|
418
|
+
return { kind: "named", name: n };
|
|
419
|
+
}
|
|
420
|
+
return { kind: "none" };
|
|
421
|
+
}
|
|
422
|
+
/** Strong-signal heuristic: is this a React Server Component (unrenderable in a
|
|
423
|
+
* client-only harness)? Conservative — only fires on unambiguous signals so an
|
|
424
|
+
* ordinary client component is never false-blocked. */
|
|
425
|
+
function looksLikeServerComponent(source) {
|
|
426
|
+
const hasUseClient = /^\s*['"]use client['"]\s*;?/m.test(source);
|
|
427
|
+
if (hasUseClient)
|
|
428
|
+
return false;
|
|
429
|
+
// Catches both `import 'server-only'` (side-effect) and `... from 'server-only'`.
|
|
430
|
+
if (/import[^;\n]*['"]server-only['"]/.test(source))
|
|
431
|
+
return true;
|
|
432
|
+
// An `async` default component only makes sense as an RSC.
|
|
433
|
+
if (/\bexport\s+default\s+async\s+function\b/.test(source))
|
|
434
|
+
return true;
|
|
435
|
+
if (/\bexport\s+async\s+function\s+[A-Z]/.test(source))
|
|
436
|
+
return true;
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Decide whether the component can be rendered via the generated Vite harness,
|
|
441
|
+
* and gather advisories. PURE — no fs, no network.
|
|
442
|
+
*/
|
|
443
|
+
export function detectStrategy(input) {
|
|
444
|
+
const { componentAbsPath, source, packageDeps } = input;
|
|
445
|
+
const componentName = componentNameFromPath(componentAbsPath);
|
|
446
|
+
const reactMajor = parseReactMajor(packageDeps) ?? 18;
|
|
447
|
+
const storybook = !!packageDeps && Object.keys(packageDeps).some((d) => d.startsWith("@storybook/"));
|
|
448
|
+
const ladle = !!packageDeps && "@ladle/react" in packageDeps;
|
|
449
|
+
const warnings = [];
|
|
450
|
+
if (storybook || ladle) {
|
|
451
|
+
warnings.push(`${storybook ? "Storybook" : "Ladle"} detected — v1 renders via the generated ` +
|
|
452
|
+
`Vite harness; native story capture is deferred (#357 follow-up).`);
|
|
453
|
+
}
|
|
454
|
+
const base = (blocker) => ({
|
|
455
|
+
engine: blocker ? "unsupported" : "vite-harness",
|
|
456
|
+
componentName,
|
|
457
|
+
reactMajor,
|
|
458
|
+
storybook,
|
|
459
|
+
ladle,
|
|
460
|
+
warnings,
|
|
461
|
+
...(blocker ? { blocker } : {}),
|
|
462
|
+
});
|
|
463
|
+
if (!COMPONENT_EXT_RE.test(componentAbsPath)) {
|
|
464
|
+
return base({
|
|
465
|
+
code: "not-a-component-file",
|
|
466
|
+
message: `df mode preview targets a React component file (.tsx/.jsx). "${basename(componentAbsPath)}" ` +
|
|
467
|
+
`is not one. Point at the component's .tsx/.jsx source.`,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
const react = parseReactMajor(packageDeps);
|
|
471
|
+
if (react === null) {
|
|
472
|
+
return base({
|
|
473
|
+
code: "no-react",
|
|
474
|
+
message: "No `react` dependency found in this repo's package.json. v1 previews React " +
|
|
475
|
+
"components only (Vue/Svelte/Angular are deferred — see #357). Install react, or " +
|
|
476
|
+
"use your framework's own component sandbox.",
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
const hasVite = !!packageDeps && ("vite" in packageDeps || "@vitejs/plugin-react" in packageDeps);
|
|
480
|
+
if (!hasVite) {
|
|
481
|
+
return base({
|
|
482
|
+
code: "no-vite",
|
|
483
|
+
message: "No `vite` / `@vitejs/plugin-react` found. v1's generated harness transforms the " +
|
|
484
|
+
"component with Vite (the common React/Vite/Next-client case). Webpack/CRA transform " +
|
|
485
|
+
"is deferred (#357). Add vite + @vitejs/plugin-react as devDependencies, or add a " +
|
|
486
|
+
"Storybook/Ladle setup.",
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
if (looksLikeServerComponent(source)) {
|
|
490
|
+
return base({
|
|
491
|
+
code: "server-component",
|
|
492
|
+
message: "This looks like a React Server Component (no `\"use client\"`, and an async/server-only " +
|
|
493
|
+
"component). It cannot mount in a client-only harness. Add `\"use client\"` if it is " +
|
|
494
|
+
"actually a client component, or preview a client component it renders. RSC capture is " +
|
|
495
|
+
"deferred (#357).",
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
const exp = resolveExport(source, componentName);
|
|
499
|
+
if (exp.kind === "none") {
|
|
500
|
+
return base({
|
|
501
|
+
code: "no-export",
|
|
502
|
+
message: `No exported component found in ${basename(componentAbsPath)}. v1 mounts the default export, ` +
|
|
503
|
+
`a named export matching the filename, or the first PascalCase named export.`,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
const result = base();
|
|
507
|
+
if (exp.kind === "named")
|
|
508
|
+
result.exportName = exp.name;
|
|
509
|
+
return result;
|
|
510
|
+
}
|
|
511
|
+
/** Thrown by a `CaptureDeps.capture` when the browser cannot be resolved/launched
|
|
512
|
+
* (Playwright not installed, Chromium binary absent, missing system deps). This
|
|
513
|
+
* is the ONLY capture failure that degrades softly (exit 2, "reached the capture
|
|
514
|
+
* step") — a Vite transform error, a component runtime exception, missing props,
|
|
515
|
+
* or an `fs.allow`-blocked import is a REAL failure and surfaces loudly (exit 1). */
|
|
516
|
+
export class BrowserUnavailableError extends Error {
|
|
517
|
+
constructor(message) {
|
|
518
|
+
super(message);
|
|
519
|
+
this.name = "BrowserUnavailableError";
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
/** Secondary heuristic (belt-and-suspenders alongside `BrowserUnavailableError`):
|
|
523
|
+
* recognize a genuine missing-browser / launch / install failure from its message
|
|
524
|
+
* so a raw Playwright error is still classified as degrade-soft, while a render or
|
|
525
|
+
* transform error is NOT. Kept deliberately narrow. */
|
|
526
|
+
export function isBrowserUnavailable(err) {
|
|
527
|
+
if (err instanceof BrowserUnavailableError)
|
|
528
|
+
return true;
|
|
529
|
+
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
|
530
|
+
return (/executable doesn'?t exist/.test(msg) ||
|
|
531
|
+
/playwright install/.test(msg) ||
|
|
532
|
+
/host system is missing dependencies/.test(msg) ||
|
|
533
|
+
/missing dependencies to run browser/.test(msg) ||
|
|
534
|
+
/cannot resolve "(playwright|@playwright\/test)"/.test(msg) ||
|
|
535
|
+
/looks like playwright.* was just installed/.test(msg));
|
|
536
|
+
}
|
|
537
|
+
function sanitizeSlug(raw) {
|
|
538
|
+
const s = raw
|
|
539
|
+
.replace(COMPONENT_EXT_RE, "")
|
|
540
|
+
.replace(/[^a-z0-9-]+/gi, "-")
|
|
541
|
+
.replace(/-+/g, "-")
|
|
542
|
+
.replace(/^-|-$/g, "")
|
|
543
|
+
.toLowerCase();
|
|
544
|
+
return s || "component";
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* A safe evidence slug is a SINGLE path segment: filesystem-safe chars only, and
|
|
548
|
+
* never `.` / `..` / anything with a separator. The slug is interpolated into the
|
|
549
|
+
* evidence path (`agent-reviews/quality-gates/ui/<sha>/<slug>/…`), so an operator
|
|
550
|
+
* `--slug ../../..` would otherwise traverse OUT of the evidence tree and clobber
|
|
551
|
+
* arbitrary files. The auto-derived slug is already safe (`sanitizeSlug`); this
|
|
552
|
+
* fail-closes an explicit `--slug`. Exported for the traversal regression test.
|
|
553
|
+
*/
|
|
554
|
+
export function isSafeSlug(slug) {
|
|
555
|
+
if (slug === "" || slug === "." || slug === "..")
|
|
556
|
+
return false;
|
|
557
|
+
if (slug.includes("/") || slug.includes("\\"))
|
|
558
|
+
return false;
|
|
559
|
+
return /^[A-Za-z0-9._-]+$/.test(slug);
|
|
560
|
+
}
|
|
561
|
+
function mergedDeps(pkg) {
|
|
562
|
+
if (!pkg)
|
|
563
|
+
return null;
|
|
564
|
+
const out = {};
|
|
565
|
+
for (const key of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
566
|
+
const block = pkg[key];
|
|
567
|
+
if (block && typeof block === "object") {
|
|
568
|
+
for (const [k, v] of Object.entries(block)) {
|
|
569
|
+
if (typeof v === "string")
|
|
570
|
+
out[k] = v;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return out;
|
|
575
|
+
}
|
|
576
|
+
function readPackageDeps(cwd) {
|
|
577
|
+
const p = join(cwd, "package.json");
|
|
578
|
+
if (!existsSync(p))
|
|
579
|
+
return null;
|
|
580
|
+
try {
|
|
581
|
+
return mergedDeps(JSON.parse(readFileSync(p, "utf8")));
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Render one component in isolation and write `ui-visual`-shaped evidence.
|
|
589
|
+
* Never throws for an expected failure — returns a `degraded`/`error` result
|
|
590
|
+
* with an actionable message and the right exit code.
|
|
591
|
+
*/
|
|
592
|
+
export async function runPreview(opts, deps) {
|
|
593
|
+
const { cwd } = opts;
|
|
594
|
+
const port = opts.port ?? 5199;
|
|
595
|
+
const componentAbsPath = isAbsolute(opts.componentPath)
|
|
596
|
+
? opts.componentPath
|
|
597
|
+
: resolve(cwd, opts.componentPath);
|
|
598
|
+
const relForLog = relative(cwd, componentAbsPath).split(sep).join("/") || opts.componentPath;
|
|
599
|
+
const slug = opts.slug ?? sanitizeSlug(basename(componentAbsPath));
|
|
600
|
+
// Fail closed on an unsafe slug BEFORE it is interpolated into any path — an
|
|
601
|
+
// explicit `--slug ../../..` must not traverse out of the evidence tree.
|
|
602
|
+
if (!isSafeSlug(slug)) {
|
|
603
|
+
return {
|
|
604
|
+
status: "error",
|
|
605
|
+
exitCode: 1,
|
|
606
|
+
slug,
|
|
607
|
+
warnings: [],
|
|
608
|
+
message: `df mode preview: invalid --slug "${slug}". A slug must be a single path ` +
|
|
609
|
+
`segment matching [A-Za-z0-9._-] (no "/", "\\", "." or ".."). No evidence written.`,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
if (!existsSync(componentAbsPath)) {
|
|
613
|
+
return {
|
|
614
|
+
status: "error",
|
|
615
|
+
exitCode: 1,
|
|
616
|
+
slug,
|
|
617
|
+
warnings: [],
|
|
618
|
+
message: `df mode preview: component not found: ${opts.componentPath}`,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
const source = readFileSync(componentAbsPath, "utf8");
|
|
622
|
+
// Resolve the frontend workspace (#379): the module/config base is the
|
|
623
|
+
// workspace that actually holds react/vite/node_modules + the tsconfig path
|
|
624
|
+
// aliases — often a `web/` subdir — NOT the invocation cwd (the repo root,
|
|
625
|
+
// where the designer works and darkfactory.yaml lives). When no react is found
|
|
626
|
+
// anywhere, fall back to cwd so detectStrategy emits the existing honest
|
|
627
|
+
// no-react blocker. The invocation cwd stays the base for evidence, the SHA,
|
|
628
|
+
// and the component's display path.
|
|
629
|
+
const workspace = resolveFrontendWorkspace(cwd);
|
|
630
|
+
const workspaceDir = workspace?.workspaceDir ?? cwd;
|
|
631
|
+
// Note the shift only when it actually moved off the repo root, so the designer
|
|
632
|
+
// sees "resolved frontend workspace: web/" but a root-frontend repo stays quiet.
|
|
633
|
+
const workspaceNote = workspace && workspace.workspaceDir !== cwd
|
|
634
|
+
? `resolved frontend workspace: ${workspace.relLabel}/ (module + config base for this preview)`
|
|
635
|
+
: undefined;
|
|
636
|
+
const detect = detectStrategy({
|
|
637
|
+
cwd: workspaceDir,
|
|
638
|
+
componentAbsPath,
|
|
639
|
+
source,
|
|
640
|
+
packageDeps: readPackageDeps(workspaceDir),
|
|
641
|
+
});
|
|
642
|
+
const baseWarnings = workspaceNote ? [workspaceNote, ...detect.warnings] : detect.warnings;
|
|
643
|
+
if (detect.blocker) {
|
|
644
|
+
return {
|
|
645
|
+
status: "degraded",
|
|
646
|
+
exitCode: 2,
|
|
647
|
+
slug,
|
|
648
|
+
warnings: baseWarnings,
|
|
649
|
+
message: `df mode preview: cannot render ${relForLog} — ${detect.blocker.message}`,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
const sha = await resolveHeadOrWorktree(cwd);
|
|
653
|
+
// Read the workspace tsconfig `paths` → Vite alias; any unsupported-glob key is
|
|
654
|
+
// surfaced (not silently mis-resolved) via the operator notes channel.
|
|
655
|
+
const { alias, warnings: aliasWarnings } = readAliasEntries(workspaceDir);
|
|
656
|
+
const warnings = aliasWarnings.length > 0 ? [...baseWarnings, ...aliasWarnings] : baseWarnings;
|
|
657
|
+
// The harness dir lives INSIDE the resolved workspace so the entry's bare
|
|
658
|
+
// imports (`react`, `react-dom/client`) and the component's relative imports
|
|
659
|
+
// resolve up into the workspace's node_modules — identical to running from the
|
|
660
|
+
// workspace dir. Evidence still lands at the repo root (writeEvidence uses cwd).
|
|
661
|
+
const harnessDir = mkdtempSync(join(workspaceDir, ".df-preview-"));
|
|
662
|
+
let server;
|
|
663
|
+
try {
|
|
664
|
+
for (const f of generateHarness({
|
|
665
|
+
componentAbsPath,
|
|
666
|
+
harnessDir,
|
|
667
|
+
componentName: detect.componentName,
|
|
668
|
+
reactMajor: detect.reactMajor,
|
|
669
|
+
...(detect.exportName !== undefined ? { exportName: detect.exportName } : {}),
|
|
670
|
+
})) {
|
|
671
|
+
writeFileSync(join(harnessDir, f.path), f.contents, "utf8");
|
|
672
|
+
}
|
|
673
|
+
try {
|
|
674
|
+
server = await deps.startServer({ workspaceDir, harnessDir, port, alias });
|
|
675
|
+
}
|
|
676
|
+
catch (err) {
|
|
677
|
+
return {
|
|
678
|
+
status: "degraded",
|
|
679
|
+
exitCode: 2,
|
|
680
|
+
slug,
|
|
681
|
+
warnings,
|
|
682
|
+
message: `df mode preview: reached harness build but could NOT start the isolated Vite dev ` +
|
|
683
|
+
`server for ${relForLog} (${errMsg(err)}). Ensure \`vite\` + \`@vitejs/plugin-react\` ` +
|
|
684
|
+
`are installed in the frontend workspace. No evidence written.`,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
let captured;
|
|
688
|
+
try {
|
|
689
|
+
captured = await deps.capture({ url: server.url, workspaceDir });
|
|
690
|
+
}
|
|
691
|
+
catch (err) {
|
|
692
|
+
// Distinguish a genuinely-unavailable browser (soft-degrade — the render
|
|
693
|
+
// itself was fine, only the screenshot couldn't be taken HERE) from a REAL
|
|
694
|
+
// failure: a Vite transform error, a component runtime exception, missing
|
|
695
|
+
// required props, or a component import blocked by Vite `fs.allow`. The
|
|
696
|
+
// latter must surface LOUDLY (exit 1) — reporting it as "degraded
|
|
697
|
+
// gracefully" would hide a broken component behind a false soft-skip.
|
|
698
|
+
if (isBrowserUnavailable(err)) {
|
|
699
|
+
return {
|
|
700
|
+
status: "degraded",
|
|
701
|
+
exitCode: 2,
|
|
702
|
+
slug,
|
|
703
|
+
warnings,
|
|
704
|
+
message: `df mode preview: reached the capture step for ${relForLog} but the browser is ` +
|
|
705
|
+
`unavailable (${errMsg(err)}). Install it with \`npx playwright install chromium\` ` +
|
|
706
|
+
`(and \`npm i -D playwright\` if absent). The isolated render succeeded; only the ` +
|
|
707
|
+
`screenshot could not be taken here. No evidence written.`,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
return {
|
|
711
|
+
status: "error",
|
|
712
|
+
exitCode: 1,
|
|
713
|
+
slug,
|
|
714
|
+
warnings,
|
|
715
|
+
message: `df mode preview: the isolated render of ${relForLog} FAILED (${errMsg(err)}). This is ` +
|
|
716
|
+
`NOT a missing-browser problem — it is typically a Vite transform error, a component ` +
|
|
717
|
+
`runtime exception, missing required props, or an import outside the repo (Vite ` +
|
|
718
|
+
`fs.allow). Fix the component or provide props, then re-run. No evidence written.`,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
const evidenceDir = writeEvidence({
|
|
722
|
+
cwd,
|
|
723
|
+
sha,
|
|
724
|
+
slug,
|
|
725
|
+
componentRel: relForLog,
|
|
726
|
+
componentName: detect.componentName,
|
|
727
|
+
baseURL: server.url,
|
|
728
|
+
aria: captured.aria,
|
|
729
|
+
png: captured.png,
|
|
730
|
+
// Persist the FULL merged notes (workspace-resolution + any unsupported
|
|
731
|
+
// path-alias keys), not just render advisories, so the evidence records
|
|
732
|
+
// why an alias may not have wired — not only stdout.
|
|
733
|
+
warnings,
|
|
734
|
+
});
|
|
735
|
+
return {
|
|
736
|
+
status: "captured",
|
|
737
|
+
exitCode: 0,
|
|
738
|
+
slug,
|
|
739
|
+
warnings,
|
|
740
|
+
evidenceDir,
|
|
741
|
+
message: `df mode preview: captured isolated render of ${relForLog} → ${relative(cwd, evidenceDir)}/` +
|
|
742
|
+
` (after.png + aria.snapshot.txt + meta.json, evidenceKind=playwright).`,
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
finally {
|
|
746
|
+
if (server) {
|
|
747
|
+
try {
|
|
748
|
+
await server.close();
|
|
749
|
+
}
|
|
750
|
+
catch {
|
|
751
|
+
/* best-effort teardown */
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
rmSync(harnessDir, { recursive: true, force: true });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
async function resolveHeadOrWorktree(cwd) {
|
|
758
|
+
try {
|
|
759
|
+
return await resolveCommit("HEAD", cwd);
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
// No commit yet (fresh repo) — evidence is still useful for the designer loop.
|
|
763
|
+
return "working-tree";
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
function errMsg(err) {
|
|
767
|
+
return err instanceof Error ? err.message : String(err);
|
|
768
|
+
}
|
|
769
|
+
/** Write the exact `ui-visual` evidence shape so the existing embed/publish path
|
|
770
|
+
* (`discoverUiEvidence` / `buildScreenshotEmbed` / `readUiArtifacts`) consumes
|
|
771
|
+
* it unchanged. Returns the absolute evidence dir. */
|
|
772
|
+
function writeEvidence(input) {
|
|
773
|
+
const dir = join(input.cwd, "agent-reviews", "quality-gates", "ui", input.sha, input.slug);
|
|
774
|
+
mkdirSync(dir, { recursive: true });
|
|
775
|
+
writeFileSync(join(dir, "after.png"), input.png);
|
|
776
|
+
writeFileSync(join(dir, "aria.snapshot.txt"), `${input.aria.trim()}\n`, "utf8");
|
|
777
|
+
const meta = {
|
|
778
|
+
surface: input.slug,
|
|
779
|
+
// `path` is the embed's human title (buildScreenshotEmbed reads meta.path).
|
|
780
|
+
path: `component:${input.componentRel}`,
|
|
781
|
+
covers: input.componentRel,
|
|
782
|
+
sha: input.sha,
|
|
783
|
+
capturedAt: new Date().toISOString(),
|
|
784
|
+
baseURL: input.baseURL,
|
|
785
|
+
beforeBaseURL: null,
|
|
786
|
+
ariaSnapshotBytes: Buffer.byteLength(input.aria, "utf8"),
|
|
787
|
+
// Keep the same evidenceKind/route as the ui-visual producer so publish.ts
|
|
788
|
+
// uploads the file artifacts and the embed renders them identically.
|
|
789
|
+
evidenceKind: "playwright",
|
|
790
|
+
route: "ui-visual",
|
|
791
|
+
floor: ["aria-snapshot", "after-screenshot"],
|
|
792
|
+
beforeScreenshot: null,
|
|
793
|
+
// HONESTY marker: this is an isolated component render, not a navigated route.
|
|
794
|
+
capture: "component-isolation",
|
|
795
|
+
componentName: input.componentName,
|
|
796
|
+
engine: "vite-harness",
|
|
797
|
+
warnings: input.warnings,
|
|
798
|
+
};
|
|
799
|
+
writeFileSync(join(dir, "meta.json"), `${JSON.stringify(meta, null, 2)}\n`, "utf8");
|
|
800
|
+
return dir;
|
|
801
|
+
}
|
|
802
|
+
//# sourceMappingURL=preview.js.map
|