@brunosps00/dev-workflow 1.0.5 → 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 (55) hide show
  1. package/README.md +17 -13
  2. package/bin/dev-workflow.js +5 -0
  3. package/lib/constants.js +6 -4
  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/agent-instructions.md +7 -5
  8. package/scaffold/en/commands/dw-analyze-project.md +22 -0
  9. package/scaffold/en/commands/dw-autopilot.md +158 -275
  10. package/scaffold/en/commands/dw-bugfix.md +3 -2
  11. package/scaffold/en/commands/dw-functional-doc.md +11 -3
  12. package/scaffold/en/commands/dw-generate-pr.md +2 -2
  13. package/scaffold/en/commands/dw-goal.md +136 -0
  14. package/scaffold/en/commands/dw-help.md +9 -7
  15. package/scaffold/en/commands/dw-plan.md +9 -3
  16. package/scaffold/en/commands/dw-qa.md +1 -1
  17. package/scaffold/en/commands/dw-redesign-ui.md +2 -2
  18. package/scaffold/en/commands/dw-review.md +7 -5
  19. package/scaffold/en/commands/dw-secure-audit.md +72 -15
  20. package/scaffold/en/templates/constitution-template.md +8 -3
  21. package/scaffold/en/templates/functional-doc/playwright.flow.mjs.tpl +17 -0
  22. package/scaffold/pt-br/agent-instructions.md +7 -5
  23. package/scaffold/pt-br/commands/dw-analyze-project.md +22 -0
  24. package/scaffold/pt-br/commands/dw-autopilot.md +161 -278
  25. package/scaffold/pt-br/commands/dw-bugfix.md +3 -2
  26. package/scaffold/pt-br/commands/dw-functional-doc.md +11 -3
  27. package/scaffold/pt-br/commands/dw-generate-pr.md +2 -2
  28. package/scaffold/pt-br/commands/dw-goal.md +136 -0
  29. package/scaffold/pt-br/commands/dw-help.md +9 -7
  30. package/scaffold/pt-br/commands/dw-plan.md +9 -3
  31. package/scaffold/pt-br/commands/dw-qa.md +1 -1
  32. package/scaffold/pt-br/commands/dw-redesign-ui.md +2 -2
  33. package/scaffold/pt-br/commands/dw-review.md +7 -5
  34. package/scaffold/pt-br/commands/dw-secure-audit.md +72 -15
  35. package/scaffold/pt-br/templates/constitution-template.md +8 -3
  36. package/scaffold/pt-br/templates/functional-doc/playwright.flow.mjs.tpl +17 -0
  37. package/scaffold/scripts/cdp-relay/Cargo.toml +16 -0
  38. package/scaffold/scripts/cdp-relay/src/main.rs +49 -0
  39. package/scaffold/scripts/functional-doc/generate-dossier.mjs +7 -9
  40. package/scaffold/scripts/functional-doc/run-playwright-flow.mjs +192 -183
  41. package/scaffold/scripts/lib/capture-screenshots.mjs +133 -0
  42. package/scaffold/scripts/lib/resolve-browser.mjs +405 -0
  43. package/scaffold/scripts/lib/ui-slop-detect.mjs +102 -0
  44. package/scaffold/skills/dw-testing-discipline/references/playwright-recipes.md +50 -0
  45. package/scaffold/skills/dw-ui-discipline/SKILL.md +5 -0
  46. package/scaffold/skills/dw-ui-discipline/references/color-oklch.md +60 -0
  47. package/scaffold/skills/dw-ui-discipline/references/motion.md +52 -0
  48. package/scaffold/skills/dw-ui-discipline/references/responsive.md +52 -0
  49. package/scaffold/skills/dw-ui-discipline/references/type-scale.md +48 -0
  50. package/scaffold/skills/dw-ui-discipline/references/ux-writing.md +52 -0
  51. package/scaffold/skills/security-review/SKILL.md +19 -0
  52. package/scaffold/skills/security-review/references/sast.md +67 -0
  53. package/scaffold/skills/security-review/references/secrets.md +46 -0
  54. package/scaffold/en/templates/functional-doc/playwright.spec.ts.tpl +0 -19
  55. package/scaffold/pt-br/templates/functional-doc/playwright.spec.ts.tpl +0 -19
@@ -628,7 +628,7 @@ function toTitleCase(value) {
628
628
  function buildPlaywrightSteps(features, heading, s) {
629
629
  const safeHeading = heading.replace(/"/g, '\\"');
630
630
  const steps = [
631
- ` await test.step("${s.validateInitialLoad}", async () => {`,
631
+ ` await step("${s.validateInitialLoad}", async () => {`,
632
632
  ` await expect(page.getByText(/${escapeRegex(safeHeading)}/i).first()).toBeVisible();`,
633
633
  ` });`,
634
634
  ];
@@ -636,8 +636,8 @@ function buildPlaywrightSteps(features, heading, s) {
636
636
  const firstEdge = features.flatMap((feature) => feature.cases).find((item) => item.type === "edge-case");
637
637
  if (firstEdge) {
638
638
  steps.push(
639
- ` await test.step("${s.recordEdgeCase}", async () => {`,
640
- ` await page.screenshot({ path: "evidence-edge-case.png", fullPage: true });`,
639
+ ` await step("${s.recordEdgeCase}", async () => {`,
640
+ ` await shot("edge-case");`,
641
641
  ` });`,
642
642
  );
643
643
  }
@@ -645,7 +645,7 @@ function buildPlaywrightSteps(features, heading, s) {
645
645
  const firstError = features.flatMap((feature) => feature.cases).find((item) => item.type === "error");
646
646
  if (firstError) {
647
647
  steps.push(
648
- ` await test.step("${s.recordErrorCase}", async () => {`,
648
+ ` await step("${s.recordErrorCase}", async () => {`,
649
649
  ` await expect(page.locator("body")).toBeVisible();`,
650
650
  ` });`,
651
651
  );
@@ -715,7 +715,7 @@ function buildManifest({ projectRoot, target, targetType, routePath, baseUrl, ha
715
715
  features: path.join(outputRoot, "features.md"),
716
716
  caseMatrix: path.join(outputRoot, "case-matrix.md"),
717
717
  e2eRunbook: path.join(outputRoot, "e2e-runbook.md"),
718
- script: path.join(outputRoot, "scripts", `${slugify(routePath || target)}.spec.ts`),
718
+ script: path.join(outputRoot, "scripts", `${slugify(routePath || target)}.flow.mjs`),
719
719
  captions: path.join(outputRoot, "captions", `${slugify(routePath || target)}.srt`),
720
720
  },
721
721
  blockers: hasPlaywright ? [] : ["Playwright configuration not detected"],
@@ -786,10 +786,8 @@ function main() {
786
786
  );
787
787
 
788
788
  writeFile(
789
- path.join(outputRoot, "scripts", `${slugify(routePath || target)}.spec.ts`),
790
- renderTemplate(path.join(templatesRoot, "playwright.spec.ts.tpl"), {
791
- baseUrl,
792
- testTitle: s.flowTest(routePath || target),
789
+ path.join(outputRoot, "scripts", `${slugify(routePath || target)}.flow.mjs`),
790
+ renderTemplate(path.join(templatesRoot, "playwright.flow.mjs.tpl"), {
793
791
  routePath,
794
792
  routeRegex: escapeRegex(routePath || "/"),
795
793
  testSteps: buildPlaywrightSteps(features, heading, s),
@@ -1,8 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // Runs a functional-doc flow module through the Playwright Node API.
4
+ //
5
+ // Why the Node API (not `playwright test`): video must be captured with page.screencast,
6
+ // which is the only mechanism that works over connectOverCDP (recordVideo does not).
7
+ // Driving directly also removes the dependency on the project's playwright.config.*.
8
+ //
9
+ // Browser selection is delegated to ../lib/resolve-browser.mjs (WSL-resilient): a Windows
10
+ // browser over CDP when reachable, otherwise the full headless Chromium (desktop-faithful,
11
+ // no WSLg). The flow module is plain .mjs exporting `async function flow({ page, context,
12
+ // expect, baseURL, step, shot })`.
13
+
3
14
  import fs from "node:fs";
4
15
  import path from "node:path";
5
- import { spawnSync } from "node:child_process";
16
+ import { pathToFileURL } from "node:url";
17
+ import { resolveBrowser } from "../lib/resolve-browser.mjs";
6
18
 
7
19
  function parseArgs(argv) {
8
20
  const result = {};
@@ -11,174 +23,98 @@ function parseArgs(argv) {
11
23
  if (!token.startsWith("--")) {
12
24
  continue;
13
25
  }
14
-
15
26
  const [rawKey, inlineValue] = token.slice(2).split("=", 2);
16
27
  const key = rawKey.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
17
28
  if (inlineValue !== undefined) {
18
29
  result[key] = inlineValue;
19
30
  continue;
20
31
  }
21
-
22
32
  const next = argv[index + 1];
23
33
  if (!next || next.startsWith("--")) {
24
34
  result[key] = true;
25
35
  continue;
26
36
  }
27
-
28
37
  result[key] = next;
29
38
  index += 1;
30
39
  }
31
40
  return result;
32
41
  }
33
42
 
34
- function detectPackageManager(projectRoot) {
35
- if (fs.existsSync(path.join(projectRoot, "pnpm-lock.yaml"))) return "pnpm";
36
- if (fs.existsSync(path.join(projectRoot, "package-lock.json"))) return "npm";
37
- if (fs.existsSync(path.join(projectRoot, "yarn.lock"))) return "yarn";
38
- return "npm";
39
- }
40
-
41
43
  function updateManifest(manifestPath, patch) {
42
44
  const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
43
45
  const next = {
44
46
  ...manifest,
45
- execution: {
46
- ...(manifest.execution ?? {}),
47
- ...patch,
48
- },
47
+ execution: { ...(manifest.execution ?? {}), ...patch },
49
48
  };
50
49
  fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
51
50
  }
52
51
 
53
- function detectTestDir(playwrightConfig) {
54
- const content = fs.readFileSync(playwrightConfig, "utf8");
55
- const match = content.match(/testDir:\s*"([^"]+)"/);
56
- return match?.[1] ?? "e2e";
57
- }
58
-
59
52
  function ensureDir(dirPath) {
60
53
  fs.mkdirSync(dirPath, { recursive: true });
61
54
  }
62
55
 
63
- function escapeForTemplate(value) {
64
- return JSON.stringify(value);
65
- }
66
-
67
56
  function parseResolution(rawValue) {
68
57
  const normalized = String(rawValue ?? "").trim().toLowerCase();
69
58
  if (!normalized || normalized === "fullhd") {
70
59
  return { width: 1920, height: 1080, label: "1920x1080" };
71
60
  }
72
-
73
61
  const match = normalized.match(/^(\d{3,5})x(\d{3,5})$/);
74
62
  if (!match) {
75
63
  return { width: 1920, height: 1080, label: "1920x1080" };
76
64
  }
77
-
78
- return {
79
- width: Number(match[1]),
80
- height: Number(match[2]),
81
- label: `${Number(match[1])}x${Number(match[2])}`,
82
- };
65
+ return { width: Number(match[1]), height: Number(match[2]), label: `${Number(match[1])}x${Number(match[2])}` };
83
66
  }
84
67
 
85
- function createTemporaryPlaywrightConfig({
86
- generatedDir,
87
- flowDir,
88
- baseUrl,
89
- browserName,
90
- resolution,
91
- }) {
92
- const rawArtifactsDir = path.join(flowDir, "evidence", "playwright-artifacts");
93
- ensureDir(rawArtifactsDir);
94
-
95
- const configPath = path.join(generatedDir, "playwright.functional-doc.config.mjs");
96
- const content = `import { defineConfig } from "@playwright/test";
97
-
98
- const rawOutputDir = ${escapeForTemplate(rawArtifactsDir)};
99
- const baseURL = process.env.BASE_URL ?? ${escapeForTemplate(baseUrl)};
100
- const generatedDir = ${escapeForTemplate(generatedDir)};
101
-
102
- export default defineConfig({
103
- testDir: generatedDir,
104
- outputDir: rawOutputDir,
105
- fullyParallel: false,
106
- retries: 0,
107
- workers: 1,
108
- reporter: "line",
109
- use: {
110
- baseURL,
111
- video: {
112
- mode: "on",
113
- size: { width: ${resolution.width}, height: ${resolution.height} },
114
- },
115
- screenshot: "on",
116
- trace: "retain-on-failure",
117
- viewport: { width: ${resolution.width}, height: ${resolution.height} },
118
- },
119
- projects: [
120
- {
121
- name: ${escapeForTemplate(browserName)},
122
- use: { browserName: ${escapeForTemplate(browserName)} },
123
- },
124
- ],
125
- });
126
- `;
127
- fs.writeFileSync(configPath, content);
128
- return { configPath, rawArtifactsDir };
68
+ // Resolves Playwright from the target project (the runner lives under .dw/scripts, so a bare
69
+ // import walks up to <projectRoot>/node_modules). Returns null when not installed.
70
+ async function importPlaywright() {
71
+ try {
72
+ return await import("@playwright/test");
73
+ } catch {
74
+ try {
75
+ return await import("playwright");
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
129
80
  }
130
81
 
131
- function collectFilesByExtension(rootDir, extension) {
132
- if (!fs.existsSync(rootDir)) {
133
- return [];
82
+ async function openBrowser(chromium, descriptor) {
83
+ if (descriptor.mode === "cdp") {
84
+ const browser = await chromium.connectOverCDP(descriptor.endpoint);
85
+ return { browser, cleanup: descriptor.cleanup ?? (async () => {}) };
134
86
  }
135
-
136
- const found = [];
137
- const queue = [rootDir];
138
- while (queue.length > 0) {
139
- const current = queue.pop();
140
- for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
141
- const absolutePath = path.join(current, entry.name);
142
- if (entry.isDirectory()) {
143
- queue.push(absolutePath);
144
- continue;
145
- }
146
- if (absolutePath.toLowerCase().endsWith(extension.toLowerCase())) {
147
- found.push(absolutePath);
87
+ const browser = await chromium.launch(descriptor.launchOptions);
88
+ return {
89
+ browser,
90
+ cleanup: async () => {
91
+ try {
92
+ await browser.close();
93
+ } catch {
94
+ // ignore
148
95
  }
149
- }
150
- }
151
- return found.sort();
96
+ },
97
+ };
152
98
  }
153
99
 
154
- function copyArtifacts(flowDir, slug, rawArtifactsDir) {
155
- const videoDir = path.join(flowDir, "evidence", "videos");
156
- const screenshotDir = path.join(flowDir, "evidence", "screenshots");
157
- ensureDir(videoDir);
158
- ensureDir(screenshotDir);
159
-
160
- const videos = collectFilesByExtension(rawArtifactsDir, ".webm");
161
- const screenshots = collectFilesByExtension(rawArtifactsDir, ".png");
162
-
163
- const copiedVideos = videos.map((sourcePath, index) => {
164
- const destinationPath = path.join(videoDir, `${slug}${videos.length > 1 ? `-${index + 1}` : ""}.webm`);
165
- fs.copyFileSync(sourcePath, destinationPath);
166
- return destinationPath;
167
- });
168
-
169
- const copiedScreenshots = screenshots.map((sourcePath, index) => {
170
- const destinationPath = path.join(screenshotDir, `${slug}${screenshots.length > 1 ? `-${index + 1}` : ""}.png`);
171
- fs.copyFileSync(sourcePath, destinationPath);
172
- return destinationPath;
100
+ async function openContext(browser, descriptor, { resolution, baseURL }) {
101
+ // Over CDP the browser already owns a default context we must not close; for a launched
102
+ // browser we create (and own) a sized context.
103
+ if (descriptor.mode === "cdp") {
104
+ const existing = browser.contexts();
105
+ if (existing.length > 0) {
106
+ return { context: existing[0], owns: false };
107
+ }
108
+ return { context: await browser.newContext(), owns: true };
109
+ }
110
+ const context = await browser.newContext({
111
+ viewport: { width: resolution.width, height: resolution.height },
112
+ baseURL,
173
113
  });
174
-
175
- return {
176
- videos: copiedVideos,
177
- screenshots: copiedScreenshots,
178
- };
114
+ return { context, owns: true };
179
115
  }
180
116
 
181
- function main() {
117
+ async function main() {
182
118
  const args = parseArgs(process.argv.slice(2));
183
119
  if (!args.flowDir) {
184
120
  throw new Error("Missing required argument: --flow-dir");
@@ -189,87 +125,160 @@ function main() {
189
125
  const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
190
126
  const projectRoot = path.resolve(manifest.project);
191
127
  const scriptPath = path.resolve(manifest.outputs.script);
192
- const packageManager = detectPackageManager(projectRoot);
193
- const browserName = args.browserName || "chromium";
128
+ const baseURL = process.env.BASE_URL ?? manifest.baseUrl;
194
129
  const resolution = parseResolution(
195
- args.videoResolution
196
- ?? manifest.artifacts?.human_final?.resolution
197
- ?? manifest.videoResolution
198
- ?? "fullhd"
130
+ args.videoResolution ?? manifest.artifacts?.human_final?.resolution ?? manifest.videoResolution ?? "fullhd",
199
131
  );
200
- const playwrightConfig = ["playwright.config.ts", "playwright.config.js"]
201
- .map((file) => path.join(projectRoot, file))
202
- .find((file) => fs.existsSync(file));
203
132
 
204
- if (!playwrightConfig) {
133
+ const playwright = await importPlaywright();
134
+ if (!playwright) {
205
135
  updateManifest(manifestPath, {
206
136
  status: "blocked",
207
- reason: "Playwright configuration not found",
137
+ reason: "Playwright is not installed in the project. Run: npx playwright install --with-deps",
208
138
  });
209
- process.stdout.write("Playwright configuration not found.\n");
139
+ process.stdout.write("Playwright is not installed in the project.\n");
210
140
  process.exit(0);
211
141
  }
142
+ const { chromium, expect } = playwright;
212
143
 
213
- const testDir = detectTestDir(playwrightConfig);
214
- const generatedDir = path.join(projectRoot, testDir, ".functional-doc-generated");
215
- ensureDir(generatedDir);
216
- const materializedScriptPath = path.join(generatedDir, path.basename(scriptPath));
217
- fs.copyFileSync(scriptPath, materializedScriptPath);
218
- const slug = path.basename(scriptPath, path.extname(scriptPath));
219
- const { configPath: temporaryConfigPath, rawArtifactsDir } = createTemporaryPlaywrightConfig({
220
- generatedDir,
221
- flowDir,
222
- baseUrl: manifest.baseUrl,
223
- browserName,
224
- resolution,
225
- });
226
-
227
- const commandArgs = ["exec", "playwright", "test", materializedScriptPath, "--config", temporaryConfigPath];
228
144
  if (args.listOnly) {
229
- commandArgs.push("--list");
145
+ try {
146
+ await import(pathToFileURL(scriptPath).href);
147
+ updateManifest(manifestPath, { status: "listed", script: scriptPath });
148
+ process.stdout.write(`Flow module loads: ${scriptPath}\n`);
149
+ process.exit(0);
150
+ } catch (error) {
151
+ updateManifest(manifestPath, { status: "list_failed", error: error.message });
152
+ process.stderr.write(`${error.message}\n`);
153
+ process.exit(1);
154
+ }
230
155
  }
231
156
 
232
- const result = spawnSync(
233
- packageManager,
234
- commandArgs,
235
- {
236
- cwd: projectRoot,
237
- env: {
238
- ...process.env,
239
- BASE_URL: manifest.baseUrl,
240
- },
241
- encoding: "utf8",
242
- },
243
- );
244
-
157
+ const videoDir = path.join(flowDir, "evidence", "videos");
158
+ const screenshotDir = path.join(flowDir, "evidence", "screenshots");
245
159
  const logDir = path.join(flowDir, "evidence", "logs");
246
- fs.mkdirSync(logDir, { recursive: true });
247
- fs.writeFileSync(path.join(logDir, "playwright.stdout.log"), result.stdout ?? "");
248
- fs.writeFileSync(path.join(logDir, "playwright.stderr.log"), result.stderr ?? "");
160
+ [videoDir, screenshotDir, logDir].forEach(ensureDir);
161
+ const slug = path.basename(scriptPath).replace(/\.(flow\.mjs|mjs|spec\.ts)$/, "");
162
+ const videoPath = path.join(videoDir, `${slug}.webm`);
163
+
164
+ const logs = [];
165
+ const log = (line) => {
166
+ logs.push(line);
167
+ process.stdout.write(`${line}\n`);
168
+ };
169
+
170
+ const descriptor = await resolveBrowser({ projectRoot, headless: true });
171
+ log(`browser: mode=${descriptor.mode} source=${descriptor.source}`);
172
+ if (args.browserName && args.browserName !== "chromium") {
173
+ log(`note: --browser-name=${args.browserName} ignored; screencast/CDP require Chromium.`);
174
+ }
175
+
176
+ const { browser, cleanup: cleanupBrowser } = await openBrowser(chromium, descriptor);
177
+ const { context, owns: ownsContext } = await openContext(browser, descriptor, { resolution, baseURL });
178
+ const page = await context.newPage();
179
+ if (descriptor.mode === "cdp") {
180
+ try {
181
+ await page.setViewportSize({ width: resolution.width, height: resolution.height });
182
+ } catch {
183
+ // viewport may be fixed for an attached context; continue
184
+ }
185
+ }
186
+
187
+ let stepIndex = 0;
188
+ const stepLog = [];
189
+ const step = async (label, fn) => {
190
+ stepIndex += 1;
191
+ log(` step ${stepIndex}: ${label}`);
192
+ stepLog.push(label);
193
+ await fn();
194
+ // Pacing for a human-watchable tour; dw-functional-doc tunes longer holds as needed.
195
+ await page.waitForTimeout(1500);
196
+ };
197
+ const screenshots = [];
198
+ const shot = async (name) => {
199
+ const target = path.join(screenshotDir, `${slug}-${name}.png`);
200
+ await page.screenshot({ path: target, fullPage: true });
201
+ screenshots.push(target);
202
+ };
203
+
204
+ // Video: page.screencast (works for launched AND CDP-attached browsers). Falls back to a
205
+ // recorded context only for a launched browser; over CDP without screencast, video is skipped.
206
+ let recording = "none";
207
+ const screencastAvailable = typeof page.screencast?.start === "function";
208
+ if (screencastAvailable) {
209
+ try {
210
+ await page.screencast.start({ path: videoPath });
211
+ recording = "screencast";
212
+ } catch (error) {
213
+ log(`screencast.start failed (${error.message}); continuing without video.`);
214
+ }
215
+ } else {
216
+ log("page.screencast unavailable (Playwright < 1.60). Upgrade Playwright to record video.");
217
+ }
218
+
219
+ let status = "passed";
220
+ let errorMessage = null;
221
+ try {
222
+ const module = await import(pathToFileURL(scriptPath).href);
223
+ const flow = module.default ?? module.flow;
224
+ if (typeof flow !== "function") {
225
+ throw new Error(`Flow module ${scriptPath} has no default export function.`);
226
+ }
227
+ await flow({ page, context, expect, baseURL, step, shot });
228
+ } catch (error) {
229
+ status = "failed";
230
+ errorMessage = error.message;
231
+ log(`flow error: ${error.message}`);
232
+ try {
233
+ await shot("failure");
234
+ } catch {
235
+ // ignore
236
+ }
237
+ } finally {
238
+ if (recording === "screencast") {
239
+ try {
240
+ await page.screencast.stop();
241
+ } catch (error) {
242
+ log(`screencast.stop failed: ${error.message}`);
243
+ }
244
+ }
245
+ try {
246
+ await page.close();
247
+ } catch {
248
+ // ignore
249
+ }
250
+ if (ownsContext) {
251
+ try {
252
+ await context.close();
253
+ } catch {
254
+ // ignore
255
+ }
256
+ }
257
+ await cleanupBrowser();
258
+ }
249
259
 
250
- const copiedArtifacts = args.listOnly
251
- ? { videos: [], screenshots: [] }
252
- : copyArtifacts(flowDir, slug, rawArtifactsDir);
260
+ const logFile = path.join(logDir, "playwright.log");
261
+ fs.writeFileSync(logFile, `${logs.join("\n")}\n`);
262
+ const videoExists = recording === "screencast" && fs.existsSync(videoPath);
253
263
 
254
264
  updateManifest(manifestPath, {
255
- status: args.listOnly ? (result.status === 0 ? "listed" : "list_failed") : (result.status === 0 ? "passed" : "failed"),
256
- command: `${packageManager} ${commandArgs.join(" ")}`,
257
- exitCode: result.status ?? 1,
265
+ status,
266
+ browserMode: descriptor.mode,
267
+ browserSource: descriptor.source,
268
+ recording,
258
269
  videoResolution: resolution.label,
259
- materializedScript: materializedScriptPath,
260
- temporaryConfig: temporaryConfigPath,
261
- video: copiedArtifacts.videos[0] ?? null,
262
- videos: copiedArtifacts.videos,
263
- screenshots: copiedArtifacts.screenshots,
264
- logs: [
265
- path.join(logDir, "playwright.stdout.log"),
266
- path.join(logDir, "playwright.stderr.log"),
267
- ],
270
+ video: videoExists ? videoPath : null,
271
+ videos: videoExists ? [videoPath] : [],
272
+ screenshots,
273
+ steps: stepLog,
274
+ error: errorMessage,
275
+ logs: [logFile],
268
276
  });
269
277
 
270
- process.stdout.write(result.stdout ?? "");
271
- process.stderr.write(result.stderr ?? "");
272
- process.exit(result.status ?? 1);
278
+ process.exit(status === "passed" ? 0 : 1);
273
279
  }
274
280
 
275
- main();
281
+ main().catch((error) => {
282
+ process.stderr.write(`run-playwright-flow: ${error.message}\n`);
283
+ process.exit(1);
284
+ });
@@ -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
+ });