@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.
- package/README.md +17 -13
- package/bin/dev-workflow.js +5 -0
- package/lib/constants.js +6 -4
- package/lib/install-deps.js +88 -3
- package/lib/setup-wsl-browser.js +149 -0
- package/package.json +1 -1
- package/scaffold/en/agent-instructions.md +7 -5
- package/scaffold/en/commands/dw-analyze-project.md +22 -0
- package/scaffold/en/commands/dw-autopilot.md +158 -275
- package/scaffold/en/commands/dw-bugfix.md +3 -2
- 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-goal.md +136 -0
- package/scaffold/en/commands/dw-help.md +9 -7
- package/scaffold/en/commands/dw-plan.md +9 -3
- 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/agent-instructions.md +7 -5
- package/scaffold/pt-br/commands/dw-analyze-project.md +22 -0
- package/scaffold/pt-br/commands/dw-autopilot.md +161 -278
- package/scaffold/pt-br/commands/dw-bugfix.md +3 -2
- 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-goal.md +136 -0
- package/scaffold/pt-br/commands/dw-help.md +9 -7
- package/scaffold/pt-br/commands/dw-plan.md +9 -3
- 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
|
@@ -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
|
|
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
|
|
640
|
-
` await
|
|
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
|
|
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)}.
|
|
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)}.
|
|
790
|
-
renderTemplate(path.join(templatesRoot, "playwright.
|
|
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 {
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
132
|
-
if (
|
|
133
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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
|
-
|
|
133
|
+
const playwright = await importPlaywright();
|
|
134
|
+
if (!playwright) {
|
|
205
135
|
updateManifest(manifestPath, {
|
|
206
136
|
status: "blocked",
|
|
207
|
-
reason: "Playwright
|
|
137
|
+
reason: "Playwright is not installed in the project. Run: npx playwright install --with-deps",
|
|
208
138
|
});
|
|
209
|
-
process.stdout.write("Playwright
|
|
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
|
-
|
|
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
|
|
233
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
265
|
+
status,
|
|
266
|
+
browserMode: descriptor.mode,
|
|
267
|
+
browserSource: descriptor.source,
|
|
268
|
+
recording,
|
|
258
269
|
videoResolution: resolution.label,
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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.
|
|
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
|
+
});
|