@brunosps00/dev-workflow 1.0.6 → 1.1.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 (45) hide show
  1. package/README.md +2 -2
  2. package/bin/dev-workflow.js +5 -0
  3. package/lib/constants.js +2 -2
  4. package/lib/install-deps.js +88 -3
  5. package/lib/setup-wsl-browser.js +149 -0
  6. package/package.json +1 -1
  7. package/scaffold/en/commands/dw-analyze-project.md +22 -0
  8. package/scaffold/en/commands/dw-autopilot.md +3 -1
  9. package/scaffold/en/commands/dw-functional-doc.md +11 -3
  10. package/scaffold/en/commands/dw-generate-pr.md +2 -2
  11. package/scaffold/en/commands/dw-qa.md +1 -1
  12. package/scaffold/en/commands/dw-redesign-ui.md +2 -2
  13. package/scaffold/en/commands/dw-review.md +7 -5
  14. package/scaffold/en/commands/dw-secure-audit.md +72 -15
  15. package/scaffold/en/templates/constitution-template.md +8 -3
  16. package/scaffold/en/templates/functional-doc/playwright.flow.mjs.tpl +17 -0
  17. package/scaffold/pt-br/commands/dw-analyze-project.md +22 -0
  18. package/scaffold/pt-br/commands/dw-autopilot.md +3 -1
  19. package/scaffold/pt-br/commands/dw-functional-doc.md +11 -3
  20. package/scaffold/pt-br/commands/dw-generate-pr.md +2 -2
  21. package/scaffold/pt-br/commands/dw-qa.md +1 -1
  22. package/scaffold/pt-br/commands/dw-redesign-ui.md +2 -2
  23. package/scaffold/pt-br/commands/dw-review.md +7 -5
  24. package/scaffold/pt-br/commands/dw-secure-audit.md +72 -15
  25. package/scaffold/pt-br/templates/constitution-template.md +8 -3
  26. package/scaffold/pt-br/templates/functional-doc/playwright.flow.mjs.tpl +17 -0
  27. package/scaffold/scripts/cdp-relay/Cargo.toml +16 -0
  28. package/scaffold/scripts/cdp-relay/src/main.rs +49 -0
  29. package/scaffold/scripts/functional-doc/generate-dossier.mjs +7 -9
  30. package/scaffold/scripts/functional-doc/run-playwright-flow.mjs +192 -183
  31. package/scaffold/scripts/lib/capture-screenshots.mjs +133 -0
  32. package/scaffold/scripts/lib/resolve-browser.mjs +405 -0
  33. package/scaffold/scripts/lib/ui-slop-detect.mjs +102 -0
  34. package/scaffold/skills/dw-testing-discipline/references/playwright-recipes.md +50 -0
  35. package/scaffold/skills/dw-ui-discipline/SKILL.md +5 -0
  36. package/scaffold/skills/dw-ui-discipline/references/color-oklch.md +60 -0
  37. package/scaffold/skills/dw-ui-discipline/references/motion.md +52 -0
  38. package/scaffold/skills/dw-ui-discipline/references/responsive.md +52 -0
  39. package/scaffold/skills/dw-ui-discipline/references/type-scale.md +48 -0
  40. package/scaffold/skills/dw-ui-discipline/references/ux-writing.md +52 -0
  41. package/scaffold/skills/security-review/SKILL.md +19 -0
  42. package/scaffold/skills/security-review/references/sast.md +67 -0
  43. package/scaffold/skills/security-review/references/secrets.md +46 -0
  44. package/scaffold/en/templates/functional-doc/playwright.spec.ts.tpl +0 -19
  45. package/scaffold/pt-br/templates/functional-doc/playwright.spec.ts.tpl +0 -19
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Captures responsive screenshots of a URL via the Playwright Node API, using the
4
+ // WSL-resilient browser resolution from ./resolve-browser.mjs. This is the fallback for
5
+ // dw-qa / dw-redesign-ui when the Playwright MCP is unavailable or blocked.
6
+ //
7
+ // Usage:
8
+ // node .dw/scripts/lib/capture-screenshots.mjs --url http://localhost:3000/path \
9
+ // [--out ./evidence] [--widths 375,1440] [--height 900] [--slug home] [--full-page]
10
+ //
11
+ // Resolution is desktop-faithful (full Chromium, new headless — no WSLg). Prints a JSON
12
+ // summary { url, shots: [{ width, path }] } and exits non-zero on failure.
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { resolveBrowser } from "./resolve-browser.mjs";
17
+
18
+ function parseArgs(argv) {
19
+ const result = {};
20
+ for (let index = 0; index < argv.length; index += 1) {
21
+ const token = argv[index];
22
+ if (!token.startsWith("--")) {
23
+ continue;
24
+ }
25
+ const [rawKey, inlineValue] = token.slice(2).split("=", 2);
26
+ const key = rawKey.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
27
+ if (inlineValue !== undefined) {
28
+ result[key] = inlineValue;
29
+ continue;
30
+ }
31
+ const next = argv[index + 1];
32
+ if (!next || next.startsWith("--")) {
33
+ result[key] = true;
34
+ continue;
35
+ }
36
+ result[key] = next;
37
+ index += 1;
38
+ }
39
+ return result;
40
+ }
41
+
42
+ async function importChromium() {
43
+ try {
44
+ return (await import("@playwright/test")).chromium;
45
+ } catch {
46
+ try {
47
+ return (await import("playwright")).chromium;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+ }
53
+
54
+ function slugify(value) {
55
+ return (
56
+ String(value)
57
+ .toLowerCase()
58
+ .replace(/^https?:\/\//, "")
59
+ .replace(/[^a-z0-9]+/g, "-")
60
+ .replace(/^-+|-+$/g, "")
61
+ .slice(0, 60) || "page"
62
+ );
63
+ }
64
+
65
+ async function main() {
66
+ const args = parseArgs(process.argv.slice(2));
67
+ if (!args.url) {
68
+ throw new Error("Missing required argument: --url");
69
+ }
70
+
71
+ const url = String(args.url);
72
+ const outDir = path.resolve(args.out ? String(args.out) : "evidence/screenshots");
73
+ const widths = String(args.widths ?? "375,1440")
74
+ .split(",")
75
+ .map((value) => Number(value.trim()))
76
+ .filter((value) => Number.isFinite(value) && value > 0);
77
+ const height = Number(args.height) || 900;
78
+ const slug = args.slug ? slugify(args.slug) : slugify(url);
79
+ const fullPage = Boolean(args.fullPage);
80
+
81
+ const chromium = await importChromium();
82
+ if (!chromium) {
83
+ throw new Error("Playwright is not installed in the project. Run: npx playwright install --with-deps");
84
+ }
85
+
86
+ fs.mkdirSync(outDir, { recursive: true });
87
+ const descriptor = await resolveBrowser({ projectRoot: process.cwd(), headless: true });
88
+ process.stderr.write(`browser: mode=${descriptor.mode} source=${descriptor.source}\n`);
89
+
90
+ let browser;
91
+ let cleanup = async () => {};
92
+ if (descriptor.mode === "cdp") {
93
+ browser = await chromium.connectOverCDP(descriptor.endpoint);
94
+ cleanup = descriptor.cleanup ?? (async () => {});
95
+ } else {
96
+ browser = await chromium.launch(descriptor.launchOptions);
97
+ cleanup = async () => {
98
+ try {
99
+ await browser.close();
100
+ } catch {
101
+ // ignore
102
+ }
103
+ };
104
+ }
105
+
106
+ const shots = [];
107
+ try {
108
+ const existing = descriptor.mode === "cdp" ? browser.contexts()[0] : null;
109
+ const context = existing ?? (await browser.newContext());
110
+ const ownsContext = !existing;
111
+ const page = await context.newPage();
112
+ for (const width of widths) {
113
+ await page.setViewportSize({ width, height });
114
+ await page.goto(url, { waitUntil: "networkidle" }).catch(() => page.goto(url));
115
+ const target = path.join(outDir, `${slug}-${width}.png`);
116
+ await page.screenshot({ path: target, fullPage });
117
+ shots.push({ width, path: target });
118
+ }
119
+ await page.close();
120
+ if (ownsContext) {
121
+ await context.close();
122
+ }
123
+ } finally {
124
+ await cleanup();
125
+ }
126
+
127
+ process.stdout.write(`${JSON.stringify({ url, shots }, null, 2)}\n`);
128
+ }
129
+
130
+ main().catch((error) => {
131
+ process.stderr.write(`capture-screenshots: ${error.message}\n`);
132
+ process.exit(1);
133
+ });
@@ -0,0 +1,405 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Resolves which browser to drive for dev-workflow's browser-automation flows
4
+ // (dw-qa, dw-functional-doc, dw-redesign-ui), with WSL resilience.
5
+ //
6
+ // Resolution order: env BROWSER_TEST -> .dw/config.json (browserTest) -> auto-detect.
7
+ // On WSL with no explicit config, auto-detects a Windows browser (Edge first, then
8
+ // Chrome) and launches it in remote-debugging mode so flows connect over CDP instead
9
+ // of opening WSLg / losing layout fidelity in the bundled Linux Chromium.
10
+ //
11
+ // Returns one of:
12
+ // { mode: "cdp", endpoint, source, cleanup } // connect over CDP (browser ready)
13
+ // { mode: "launch", launchOptions, source } // let Playwright launch the browser
14
+ //
15
+ // DEFAULT (no BROWSER_TEST): the full Playwright Chromium in headless via
16
+ // channel:"chromium", which uses the new headless mode (desktop-faithful rendering,
17
+ // not the lighter headless-shell) and never opens WSLg. This is the robust default on
18
+ // WSL. Set BROWSER_TEST to opt into a Windows browser.
19
+ //
20
+ // NOTE on WSL networking: the Chromium debug port binds Windows loopback only. In mirrored
21
+ // networking mode WSL shares that loopback, so 127.0.0.1 connects directly. In NAT mode the
22
+ // helper starts the prebuilt cdp-relay.exe on Windows (0.0.0.0:<fixed port> -> 127.0.0.1:debug)
23
+ // and connects via the Windows host gateway. That needs the one-time setup performed by
24
+ // `npx @brunosps00/dev-workflow setup-wsl-browser` (builds the relay + adds the Hyper-V firewall
25
+ // rule). Without it, the helper falls back to the local headless Chromium.
26
+
27
+ import fs from "node:fs";
28
+ import net from "node:net";
29
+ import http from "node:http";
30
+ import path from "node:path";
31
+ import { spawn, spawnSync } from "node:child_process";
32
+
33
+ // Full Chromium in new-headless mode: desktop-faithful rendering, no WSLg.
34
+ const BUNDLED_LAUNCH = { channel: "chromium" };
35
+
36
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
37
+ const dedupe = (items) => [...new Set(items.filter(Boolean))];
38
+
39
+ export function detectWsl() {
40
+ if (process.env.WSL_DISTRO_NAME) {
41
+ return true;
42
+ }
43
+ try {
44
+ return /microsoft/i.test(fs.readFileSync("/proc/version", "utf8"));
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ // WSL2 mirrored networking creates a `loopback0` interface and shares Windows' loopback, so
51
+ // 127.0.0.1 reaches a Windows browser's debug port directly. NAT mode has no loopback0 and
52
+ // needs the Windows-side relay instead. Used to choose between the two connection strategies.
53
+ export function isMirroredNetworking() {
54
+ return fs.existsSync("/sys/class/net/loopback0");
55
+ }
56
+
57
+ function readConfigValue(projectRoot) {
58
+ if (process.env.BROWSER_TEST && process.env.BROWSER_TEST.trim()) {
59
+ return { value: process.env.BROWSER_TEST.trim(), source: "env:BROWSER_TEST" };
60
+ }
61
+ if (projectRoot) {
62
+ try {
63
+ const cfg = JSON.parse(fs.readFileSync(path.join(projectRoot, ".dw", "config.json"), "utf8"));
64
+ if (cfg && typeof cfg.browserTest === "string" && cfg.browserTest.trim()) {
65
+ return { value: cfg.browserTest.trim(), source: ".dw/config.json" };
66
+ }
67
+ } catch {
68
+ // no config / unreadable -> fall through to auto-detect
69
+ }
70
+ }
71
+ return { value: undefined, source: null };
72
+ }
73
+
74
+ function classifyValue(value) {
75
+ if (/^(https?:\/\/|cdp:\/\/)/i.test(value)) {
76
+ return "cdp";
77
+ }
78
+ if (/\.exe$/i.test(value) || value.startsWith("/mnt/")) {
79
+ return "exe";
80
+ }
81
+ if (["chrome", "msedge", "chromium"].includes(value.toLowerCase())) {
82
+ return "channel";
83
+ }
84
+ return value.includes("/") ? "exe" : "channel";
85
+ }
86
+
87
+ function getFreePort() {
88
+ return new Promise((resolve, reject) => {
89
+ const server = net.createServer();
90
+ server.unref();
91
+ server.on("error", reject);
92
+ server.listen(0, "127.0.0.1", () => {
93
+ const { port } = server.address();
94
+ server.close(() => resolve(port));
95
+ });
96
+ });
97
+ }
98
+
99
+ function windowsHostIp() {
100
+ // In WSL2 NAT mode the Windows host is the default-route gateway (the vEthernet
101
+ // adapter), NOT the /etc/resolv.conf nameserver (which can be a DNS-tunneling proxy
102
+ // like 10.255.255.254 that does not forward arbitrary TCP ports). Prefer the gateway.
103
+ try {
104
+ const out = spawnSync("ip", ["route", "show", "default"], { encoding: "utf8" });
105
+ const match = out.stdout?.match(/default via ([0-9.]+)/);
106
+ if (match) {
107
+ return match[1];
108
+ }
109
+ } catch {
110
+ // ignore
111
+ }
112
+ try {
113
+ const match = fs.readFileSync("/etc/resolv.conf", "utf8").match(/nameserver\s+([0-9.]+)/);
114
+ if (match) {
115
+ return match[1];
116
+ }
117
+ } catch {
118
+ // ignore
119
+ }
120
+ return null;
121
+ }
122
+
123
+ function httpGetJson(url, timeoutMs = 1000) {
124
+ return new Promise((resolve) => {
125
+ const request = http.get(url, { timeout: timeoutMs }, (response) => {
126
+ let data = "";
127
+ response.on("data", (chunk) => (data += chunk));
128
+ response.on("end", () => {
129
+ try {
130
+ resolve(JSON.parse(data));
131
+ } catch {
132
+ resolve(null);
133
+ }
134
+ });
135
+ });
136
+ request.on("error", () => resolve(null));
137
+ request.on("timeout", () => {
138
+ request.destroy();
139
+ resolve(null);
140
+ });
141
+ });
142
+ }
143
+
144
+ async function findReachableCdp(port, extraHosts = []) {
145
+ const hosts = dedupe(["127.0.0.1", "localhost", ...extraHosts, windowsHostIp()]);
146
+ for (const host of hosts) {
147
+ const base = `http://${host}:${port}`;
148
+ const info = await httpGetJson(`${base}/json/version`);
149
+ if (info) {
150
+ return { base, host, info };
151
+ }
152
+ }
153
+ return null;
154
+ }
155
+
156
+ async function waitForCdp(hosts, port, { timeoutMs = 12000, intervalMs = 250 } = {}) {
157
+ const deadline = Date.now() + timeoutMs;
158
+ while (Date.now() < deadline) {
159
+ for (const host of dedupe(hosts)) {
160
+ const base = `http://${host}:${port}`;
161
+ const info = await httpGetJson(`${base}/json/version`);
162
+ if (info) {
163
+ return { base, host, info };
164
+ }
165
+ }
166
+ await sleep(intervalMs);
167
+ }
168
+ return null;
169
+ }
170
+
171
+ // A Windows path for the launched browser (it runs on Windows). Returns { winPath, wslPath }.
172
+ function windowsTempDir() {
173
+ const rand = `dw-browser-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
174
+ let winTemp = null;
175
+ try {
176
+ winTemp = spawnSync("cmd.exe", ["/c", "echo %TEMP%"], { encoding: "utf8" }).stdout?.split(/\r?\n/)[0]?.trim() || null;
177
+ } catch {
178
+ winTemp = null;
179
+ }
180
+ if (!winTemp || !/^[A-Za-z]:\\/.test(winTemp)) {
181
+ return null;
182
+ }
183
+ const winPath = `${winTemp}\\${rand}`;
184
+ let wslPath = null;
185
+ try {
186
+ wslPath = spawnSync("wslpath", ["-u", winPath], { encoding: "utf8" }).stdout?.trim() || null;
187
+ } catch {
188
+ wslPath = null;
189
+ }
190
+ return wslPath ? { winPath, wslPath } : null;
191
+ }
192
+
193
+ // Fixed port the Windows-side relay listens on, so a single Hyper-V firewall rule covers it.
194
+ const DEFAULT_RELAY_PORT = 39222;
195
+
196
+ function relayPort(projectRoot) {
197
+ const fromEnv = Number(process.env.DW_RELAY_PORT);
198
+ if (fromEnv) return fromEnv;
199
+ if (projectRoot) {
200
+ try {
201
+ const cfg = JSON.parse(fs.readFileSync(path.join(projectRoot, ".dw", "config.json"), "utf8"));
202
+ if (Number(cfg?.relayPort)) return Number(cfg.relayPort);
203
+ } catch {
204
+ // ignore
205
+ }
206
+ }
207
+ return DEFAULT_RELAY_PORT;
208
+ }
209
+
210
+ // The prebuilt Rust relay (cdp-relay.exe), built on Windows by `setup-wsl-browser`. Order:
211
+ // .dw/config.json relayExe -> %LOCALAPPDATA%\dev-workflow\cdp-relay.exe. Returns { winPath, wslPath } or null.
212
+ function findRelayExe(projectRoot) {
213
+ const candidates = [];
214
+ if (projectRoot) {
215
+ try {
216
+ const cfg = JSON.parse(fs.readFileSync(path.join(projectRoot, ".dw", "config.json"), "utf8"));
217
+ if (typeof cfg?.relayExe === "string" && cfg.relayExe.trim()) candidates.push(cfg.relayExe.trim());
218
+ } catch {
219
+ // ignore
220
+ }
221
+ }
222
+ try {
223
+ const lad = spawnSync("cmd.exe", ["/c", "echo %LOCALAPPDATA%"], { encoding: "utf8" }).stdout?.split(/\r?\n/)[0]?.trim();
224
+ if (lad && /^[A-Za-z]:\\/.test(lad)) candidates.push(`${lad}\\dev-workflow\\cdp-relay.exe`);
225
+ } catch {
226
+ // ignore
227
+ }
228
+ for (const winPath of candidates) {
229
+ let wslPath = null;
230
+ try {
231
+ wslPath = spawnSync("wslpath", ["-u", winPath], { encoding: "utf8" }).stdout?.trim() || null;
232
+ } catch {
233
+ // ignore
234
+ }
235
+ if (wslPath && fs.existsSync(wslPath)) return { winPath, wslPath };
236
+ }
237
+ return null;
238
+ }
239
+
240
+ // Launches the Windows browser in remote-debugging mode and returns a CDP descriptor.
241
+ // - mirrored networking: WSL shares the Windows loopback (Hyper-V LoopbackEnabled), connect 127.0.0.1.
242
+ // - NAT networking: the prebuilt cdp-relay.exe bridges 0.0.0.0:<relayPort> -> 127.0.0.1:<debug> on the
243
+ // Windows side, and WSL connects via the Windows host gateway. This needs a one-time Hyper-V firewall
244
+ // inbound rule for <relayPort> (added by `setup-wsl-browser`), because WSL's Hyper-V firewall defaults
245
+ // inbound to Block. The browser echoes the request Host into webSocketDebuggerUrl, so the ws stays
246
+ // reachable through the relay.
247
+ // --remote-allow-origins=* is required or Chromium drops the DevTools ws (anti-DNS-rebinding).
248
+ async function launchWindowsBrowser(exePath, { headless, projectRoot }) {
249
+ const mirrored = isMirroredNetworking();
250
+ const tmp = windowsTempDir();
251
+ if (tmp) {
252
+ try {
253
+ fs.mkdirSync(tmp.wslPath, { recursive: true });
254
+ } catch {
255
+ // best effort
256
+ }
257
+ }
258
+
259
+ const debugPort = await getFreePort();
260
+ const args = [
261
+ `--remote-debugging-port=${debugPort}`,
262
+ "--remote-allow-origins=*",
263
+ "--no-first-run",
264
+ "--no-default-browser-check",
265
+ ];
266
+ if (tmp) args.push(`--user-data-dir=${tmp.winPath}\\profile`);
267
+ if (headless) args.push("--headless=new");
268
+ args.push("about:blank");
269
+
270
+ const browserChild = spawn(exePath, args, { detached: true, stdio: "ignore" });
271
+ browserChild.unref();
272
+
273
+ const cleanups = [() => browserChild.kill()];
274
+ if (tmp) cleanups.push(() => fs.rmSync(tmp.wslPath, { recursive: true, force: true }));
275
+ const cleanup = async () => {
276
+ for (const fn of cleanups) {
277
+ try {
278
+ await fn();
279
+ } catch {
280
+ // ignore
281
+ }
282
+ }
283
+ };
284
+
285
+ let hosts;
286
+ let port;
287
+ if (mirrored) {
288
+ hosts = ["127.0.0.1", "localhost"];
289
+ port = debugPort;
290
+ } else {
291
+ const relay = findRelayExe(projectRoot);
292
+ if (!relay) {
293
+ await cleanup();
294
+ throw new Error(
295
+ "WSL is in NAT mode; reaching the Windows browser needs cdp-relay.exe. Run " +
296
+ "`npx @brunosps00/dev-workflow setup-wsl-browser` (builds the relay on Windows and adds the " +
297
+ "Hyper-V firewall rule). Alternatively enable mirrored networking or set BROWSER_TEST to a CDP URL.",
298
+ );
299
+ }
300
+ const rport = relayPort(projectRoot);
301
+ const relayChild = spawn(relay.wslPath, [String(rport), String(debugPort)], { detached: true, stdio: "ignore" });
302
+ relayChild.unref();
303
+ cleanups.unshift(() => relayChild.kill());
304
+ hosts = [windowsHostIp(), "127.0.0.1"].filter(Boolean);
305
+ port = rport;
306
+ }
307
+
308
+ const found = await waitForCdp(hosts, port);
309
+ if (!found) {
310
+ await cleanup();
311
+ throw new Error(
312
+ `Launched ${path.basename(exePath)} but its CDP endpoint was not reachable (${hosts.join("/")}:${port}). ` +
313
+ "The Hyper-V firewall rule may be missing — run `npx @brunosps00/dev-workflow setup-wsl-browser`.",
314
+ );
315
+ }
316
+
317
+ return { mode: "cdp", endpoint: found.base, source: `launched:${path.basename(exePath)}${mirrored ? "" : "+relay"}`, cleanup };
318
+ }
319
+
320
+ const noopCleanup = async () => {};
321
+
322
+ function bundledDescriptor(headless, source = "bundled") {
323
+ return { mode: "launch", launchOptions: { ...BUNDLED_LAUNCH, headless }, source };
324
+ }
325
+
326
+ // When an explicit BROWSER_TEST cannot be honored, degrade to the headless default
327
+ // (resilient) unless allowFallback is false (then throw, e.g. for diagnostics).
328
+ function degradeOrThrow(message, { headless, allowFallback }) {
329
+ if (allowFallback) {
330
+ process.stderr.write(`resolve-browser: ${message} Falling back to headless Chromium.\n`);
331
+ return bundledDescriptor(headless, "fallback");
332
+ }
333
+ throw new Error(message);
334
+ }
335
+
336
+ export async function resolveBrowser({ projectRoot, headless = true, allowFallback = true } = {}) {
337
+ const { value, source } = readConfigValue(projectRoot);
338
+
339
+ if (!value) {
340
+ return bundledDescriptor(headless);
341
+ }
342
+
343
+ const kind = classifyValue(value);
344
+
345
+ if (kind === "cdp") {
346
+ const url = new URL(value.replace(/^cdp:\/\//i, "http://"));
347
+ const port = Number(url.port) || 9222;
348
+ const found = await findReachableCdp(port, [url.hostname]);
349
+ if (!found) {
350
+ return degradeOrThrow(
351
+ `BROWSER_TEST endpoint '${value}' is not reachable (start the browser with --remote-debugging-port=${port}).`,
352
+ { headless, allowFallback },
353
+ );
354
+ }
355
+ return { mode: "cdp", endpoint: found.base, source, cleanup: noopCleanup };
356
+ }
357
+
358
+ if (kind === "exe") {
359
+ if (!fs.existsSync(value)) {
360
+ return degradeOrThrow(`BROWSER_TEST points to '${value}', but that file does not exist.`, {
361
+ headless,
362
+ allowFallback,
363
+ });
364
+ }
365
+ try {
366
+ return await launchWindowsBrowser(value, { headless, projectRoot });
367
+ } catch (error) {
368
+ return degradeOrThrow(error.message, { headless, allowFallback });
369
+ }
370
+ }
371
+
372
+ // channel: chrome | msedge | chromium
373
+ const channel = value.toLowerCase();
374
+ return { mode: "launch", launchOptions: { channel, headless }, source };
375
+ }
376
+
377
+ // CLI probe: `node resolve-browser.mjs [--project-root <dir>] [--headed] [--keep-open]`
378
+ // Prints the resolved descriptor as JSON (cleans up any launched browser unless --keep-open).
379
+ function parseProbeArgs(argv) {
380
+ const out = {};
381
+ for (let i = 0; i < argv.length; i += 1) {
382
+ const token = argv[i];
383
+ if (token === "--headed") out.headed = true;
384
+ else if (token === "--keep-open") out.keepOpen = true;
385
+ else if (token === "--project-root") out.projectRoot = argv[(i += 1)];
386
+ }
387
+ return out;
388
+ }
389
+
390
+ const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]).endsWith("resolve-browser.mjs");
391
+ if (invokedDirectly) {
392
+ const args = parseProbeArgs(process.argv.slice(2));
393
+ resolveBrowser({ projectRoot: args.projectRoot ?? process.cwd(), headless: !args.headed })
394
+ .then(async (descriptor) => {
395
+ const { cleanup, ...printable } = descriptor;
396
+ process.stdout.write(`${JSON.stringify(printable, null, 2)}\n`);
397
+ if (typeof cleanup === "function" && !args.keepOpen) {
398
+ await cleanup();
399
+ }
400
+ })
401
+ .catch((error) => {
402
+ process.stderr.write(`resolve-browser: ${error.message}\n`);
403
+ process.exitCode = 1;
404
+ });
405
+ }
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Thin wrapper around `impeccable detect` — the deterministic UI design-slop detector by
4
+ // Paul Bakaus (https://github.com/pbakaus/impeccable, Apache-2.0, derived from Anthropic's
5
+ // frontend-design skill). dev-workflow does NOT vendor it; npx fetches it on demand, so
6
+ // there is no extra dependency to install.
7
+ //
8
+ // Purpose: turn "the LLM might notice slop" into a deterministic gate. /dw-review (UI diffs)
9
+ // and /dw-verify run this and block on findings at or above --fail-on severity.
10
+ //
11
+ // Usage:
12
+ // node .dw/scripts/lib/ui-slop-detect.mjs [paths...] [--fail-on error] [--fast]
13
+ // (defaults: paths=".", --fail-on=error)
14
+ //
15
+ // Output: normalized JSON { failOn, summary: { bySeverity }, blocking, findings }.
16
+ // Exit: 1 if any finding >= failOn; 0 otherwise (including when impeccable cannot run — a
17
+ // missing detector should not hard-fail a review, only warn).
18
+
19
+ import { spawnSync } from "node:child_process";
20
+
21
+ const SEVERITY_RANK = { info: 0, warning: 1, error: 2, critical: 3 };
22
+
23
+ function parseArgs(argv) {
24
+ const paths = [];
25
+ const flags = { failOn: "error", fast: false };
26
+ for (let index = 0; index < argv.length; index += 1) {
27
+ const token = argv[index];
28
+ if (token === "--fast") {
29
+ flags.fast = true;
30
+ } else if (token === "--fail-on") {
31
+ flags.failOn = String(argv[(index += 1)] ?? "error").toLowerCase();
32
+ } else if (token.startsWith("--fail-on=")) {
33
+ flags.failOn = token.slice("--fail-on=".length).toLowerCase();
34
+ } else if (!token.startsWith("--")) {
35
+ paths.push(token);
36
+ }
37
+ }
38
+ if (paths.length === 0) {
39
+ paths.push(".");
40
+ }
41
+ return { paths, flags };
42
+ }
43
+
44
+ function rank(severity) {
45
+ return SEVERITY_RANK[String(severity).toLowerCase()] ?? 1;
46
+ }
47
+
48
+ function main() {
49
+ const { paths, flags } = parseArgs(process.argv.slice(2));
50
+ const detectArgs = ["-y", "impeccable@latest", "detect", "--json"];
51
+ if (flags.fast) {
52
+ detectArgs.push("--fast");
53
+ }
54
+ detectArgs.push(...paths);
55
+
56
+ const result = spawnSync("npx", detectArgs, { encoding: "utf8", timeout: 180000 });
57
+ if (result.error || typeof result.stdout !== "string") {
58
+ process.stderr.write(
59
+ `ui-slop-detect: could not run impeccable (${result.error?.message ?? "no output"}). ` +
60
+ "Skipping the deterministic UI gate — install Node/npx or run `npx impeccable detect` manually.\n",
61
+ );
62
+ process.exit(0);
63
+ }
64
+
65
+ let findings;
66
+ try {
67
+ findings = JSON.parse(result.stdout);
68
+ if (!Array.isArray(findings)) {
69
+ throw new Error("unexpected output shape");
70
+ }
71
+ } catch (error) {
72
+ process.stderr.write(`ui-slop-detect: could not parse impeccable output (${error.message}).\n`);
73
+ process.stdout.write(result.stdout);
74
+ process.exit(0);
75
+ }
76
+
77
+ const bySeverity = {};
78
+ for (const finding of findings) {
79
+ const severity = String(finding.severity ?? "warning").toLowerCase();
80
+ bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;
81
+ }
82
+
83
+ const threshold = rank(flags.failOn);
84
+ const blocking = findings.filter((finding) => rank(finding.severity) >= threshold);
85
+
86
+ process.stdout.write(
87
+ `${JSON.stringify(
88
+ {
89
+ failOn: flags.failOn,
90
+ summary: { total: findings.length, bySeverity },
91
+ blocking: blocking.length,
92
+ findings,
93
+ },
94
+ null,
95
+ 2,
96
+ )}\n`,
97
+ );
98
+
99
+ process.exit(blocking.length > 0 ? 1 : 0);
100
+ }
101
+
102
+ main();
@@ -272,6 +272,56 @@ Tests now start authenticated. No login loop in every test.
272
272
  - Some authentication flows (Google Sign-In, MFA hardware keys) cannot be automated; use test-mode bypasses with a dedicated test account.
273
273
  - Visual regression tests are sensitive to font rendering across OSes; pin to a CI runner OS.
274
274
 
275
+ ## Browser on WSL (resilient resolution)
276
+
277
+ WSL makes browser automation fragile: the Playwright MCP may be blocked, headed runs open
278
+ WSLg, and the bundled headless can lose fidelity. dev-workflow ships `.dw/scripts/lib/resolve-browser.mjs`
279
+ to pick a browser deterministically. Both `.dw/scripts/functional-doc/run-playwright-flow.mjs`
280
+ (video, via `page.screencast`) and `.dw/scripts/lib/capture-screenshots.mjs` (responsive shots)
281
+ use it, so the same rules apply everywhere.
282
+
283
+ Resolution order: `BROWSER_TEST` env → `.dw/config.json` (`browserTest`) → default.
284
+
285
+ - **Default (no config):** full Chromium in new-headless mode (`channel: "chromium"`) — desktop-faithful
286
+ rendering, never opens WSLg. This is the recommended default on WSL.
287
+ - **`BROWSER_TEST` = CDP URL** (e.g. `http://localhost:9222`): connects with `chromium.connectOverCDP`.
288
+ Tries `127.0.0.1` first, then the Windows host (default-route gateway) for NAT.
289
+ - **`BROWSER_TEST` = Windows exe** (e.g. `/mnt/c/.../msedge.exe`): launches it with a temp profile in
290
+ remote-debugging mode (`--remote-allow-origins=*`) and connects over CDP. **Works in both WSL2 modes:**
291
+ in mirrored networking it connects to `127.0.0.1` directly; in NAT mode it starts the prebuilt
292
+ **`cdp-relay.exe`** (a tiny Rust binary, no runtime) on Windows — `0.0.0.0:39222` → `127.0.0.1:<debug>` —
293
+ and connects via the Windows host gateway. Falls back to headless Chromium if the relay/rule is absent.
294
+ - **`BROWSER_TEST` = channel** (`chrome` | `msedge` | `chromium`): launches that channel locally.
295
+
296
+ NAT mode needs a one-time setup (the WSL **Hyper-V firewall** defaults inbound to Block, and Chromium
297
+ binds its debug port to loopback only):
298
+
299
+ ```bash
300
+ npx @brunosps00/dev-workflow setup-wsl-browser
301
+ ```
302
+
303
+ This builds `cdp-relay.exe` with the Windows Rust toolchain (`rustup` on Windows) and adds a Hyper-V
304
+ firewall inbound allow rule for TCP 39222 (one UAC prompt). After that, the *real* Windows browser is
305
+ driven from WSL — including `page.screencast` video — under NAT. The launched browser and relay use a
306
+ throwaway profile and are killed on exit.
307
+
308
+ Two env vars, two jobs — keep them distinct:
309
+ - `BROWSER` — opens URLs for a human (used by `/dw-generate-pr`).
310
+ - `BROWSER_TEST` — the automation target for QA / functional-doc / redesign capture.
311
+
312
+ To drive the real Windows browser, set `BROWSER_TEST` to its exe path (NAT works as long as `node` is on
313
+ the Windows PATH; mirrored works too). To force the faithful local-headless path instead, leave
314
+ `BROWSER_TEST` unset or set it to `chromium`.
315
+
316
+ ```bash
317
+ # Probe what would be used (cleans up any launched browser):
318
+ node .dw/scripts/lib/resolve-browser.mjs --project-root .
319
+
320
+ # Responsive screenshots fallback when the Playwright MCP is unavailable:
321
+ node .dw/scripts/lib/capture-screenshots.mjs --url http://localhost:3000/path \
322
+ --out QA/evidence/ui --widths 375,1440 --slug home
323
+ ```
324
+
275
325
  ## Cross-skill integration
276
326
 
277
327
  When running these recipes, the doctrine in this skill applies: