@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.
- package/README.md +2 -2
- package/bin/dev-workflow.js +5 -0
- package/lib/constants.js +2 -2
- package/lib/install-deps.js +88 -3
- package/lib/setup-wsl-browser.js +149 -0
- package/package.json +1 -1
- package/scaffold/en/commands/dw-analyze-project.md +22 -0
- package/scaffold/en/commands/dw-autopilot.md +3 -1
- package/scaffold/en/commands/dw-functional-doc.md +11 -3
- package/scaffold/en/commands/dw-generate-pr.md +2 -2
- package/scaffold/en/commands/dw-qa.md +1 -1
- package/scaffold/en/commands/dw-redesign-ui.md +2 -2
- package/scaffold/en/commands/dw-review.md +7 -5
- package/scaffold/en/commands/dw-secure-audit.md +72 -15
- package/scaffold/en/templates/constitution-template.md +8 -3
- package/scaffold/en/templates/functional-doc/playwright.flow.mjs.tpl +17 -0
- package/scaffold/pt-br/commands/dw-analyze-project.md +22 -0
- package/scaffold/pt-br/commands/dw-autopilot.md +3 -1
- package/scaffold/pt-br/commands/dw-functional-doc.md +11 -3
- package/scaffold/pt-br/commands/dw-generate-pr.md +2 -2
- package/scaffold/pt-br/commands/dw-qa.md +1 -1
- package/scaffold/pt-br/commands/dw-redesign-ui.md +2 -2
- package/scaffold/pt-br/commands/dw-review.md +7 -5
- package/scaffold/pt-br/commands/dw-secure-audit.md +72 -15
- package/scaffold/pt-br/templates/constitution-template.md +8 -3
- package/scaffold/pt-br/templates/functional-doc/playwright.flow.mjs.tpl +17 -0
- package/scaffold/scripts/cdp-relay/Cargo.toml +16 -0
- package/scaffold/scripts/cdp-relay/src/main.rs +49 -0
- package/scaffold/scripts/functional-doc/generate-dossier.mjs +7 -9
- package/scaffold/scripts/functional-doc/run-playwright-flow.mjs +192 -183
- package/scaffold/scripts/lib/capture-screenshots.mjs +133 -0
- package/scaffold/scripts/lib/resolve-browser.mjs +405 -0
- package/scaffold/scripts/lib/ui-slop-detect.mjs +102 -0
- package/scaffold/skills/dw-testing-discipline/references/playwright-recipes.md +50 -0
- package/scaffold/skills/dw-ui-discipline/SKILL.md +5 -0
- package/scaffold/skills/dw-ui-discipline/references/color-oklch.md +60 -0
- package/scaffold/skills/dw-ui-discipline/references/motion.md +52 -0
- package/scaffold/skills/dw-ui-discipline/references/responsive.md +52 -0
- package/scaffold/skills/dw-ui-discipline/references/type-scale.md +48 -0
- package/scaffold/skills/dw-ui-discipline/references/ux-writing.md +52 -0
- package/scaffold/skills/security-review/SKILL.md +19 -0
- package/scaffold/skills/security-review/references/sast.md +67 -0
- package/scaffold/skills/security-review/references/secrets.md +46 -0
- package/scaffold/en/templates/functional-doc/playwright.spec.ts.tpl +0 -19
- 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:
|