Skip to content

Commit 2d42458

Browse files
feat: add Smart Capture Layer + CI hardening
Smart Capture Layer: - Role-aware baselines: filenames include auth profile (dashboard-admin-desktop.png) - Readiness checks: waitForSelector/waitForText per route before capture - Bot protection detection: Cloudflare, hCaptcha, reCAPTCHA auto-detected with warnings - Flaky capture detection: N attempts with hash comparison, majority vote - SPA hydration wait: framework-agnostic hydration signal detection - Structured warnings: bot_protection, flaky_capture, readiness_timeout, etc. CI Hardening: - Playwright browser caching across runs (actions/cache@v4) - Pinned fonts for deterministic cross-environment screenshots - Job timeout (10 min), concurrency control with cancel-in-progress - Node.js 24 compatibility (FORCE_JAVASCRIPT_ACTIONS_TO_NODE24) - Consumer template: diff artifact upload, separate regression vs infra failures 46 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 482ac91 commit 2d42458

File tree

7 files changed

+383
-33
lines changed

7 files changed

+383
-33
lines changed

.github/workflows/ci.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,18 @@ on:
66
pull_request:
77
branches: [main]
88

9+
concurrency:
10+
group: ci-${{ github.ref }}
11+
cancel-in-progress: true
12+
13+
env:
14+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
15+
916
jobs:
1017
check:
1118
runs-on: ubuntu-latest
19+
timeout-minutes: 10
20+
1221
steps:
1322
- uses: actions/checkout@v4
1423

@@ -19,9 +28,30 @@ jobs:
1928

2029
- run: npm ci
2130

31+
# Cache Playwright browsers across runs
32+
- name: Cache Playwright browsers
33+
id: playwright-cache
34+
uses: actions/cache@v4
35+
with:
36+
path: ~/.cache/ms-playwright
37+
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
38+
restore-keys: |
39+
playwright-${{ runner.os }}-
40+
2241
- name: Install Playwright
42+
if: steps.playwright-cache.outputs.cache-hit != 'true'
2343
run: npx playwright install --with-deps chromium
2444

45+
- name: Install Playwright deps (cached browsers)
46+
if: steps.playwright-cache.outputs.cache-hit == 'true'
47+
run: npx playwright install-deps chromium
48+
49+
# Pin fonts for deterministic screenshots
50+
- name: Install fonts
51+
run: |
52+
sudo apt-get update -qq
53+
sudo apt-get install -y -qq fonts-noto fonts-noto-cjk fonts-liberation > /dev/null 2>&1
54+
2555
- name: Type check
2656
run: npm run typecheck
2757

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,22 @@
1010
- Routes grouped by auth profile to minimize browser context creation
1111
- Generate auth state: `npx playwright codegen --save-storage=.dojowatch/auth.json`
1212
- Auth state files (`.dojowatch/auth*.json`) added to `.gitignore`
13+
- **Smart Capture Layer** (`smart` config): Intelligent capture with readiness, retry, and detection
14+
- **Role-aware baselines**: Filenames include auth profile (`dashboard-admin-desktop.png` vs `dashboard-student-desktop.png`)
15+
- **Readiness checks**: `waitForSelector` and `waitForText` — wait for app-specific signals before capture
16+
- **Per-route readiness**: Override readiness config for specific routes (e.g., longer timeout for `/dashboard`)
17+
- **Bot protection detection**: Auto-detects Cloudflare, hCaptcha, reCAPTCHA challenge pages and warns
18+
- **Flaky capture detection**: Capture N times, compare hashes, use majority vote, warn on inconsistency
19+
- **SPA hydration wait**: Framework-agnostic hydration signal detection with custom selector support
20+
- **Capture warnings**: Structured warnings (`bot_protection`, `flaky_capture`, `readiness_timeout`, etc.) reported inline
21+
- **CI hardening** (industry-standard GitHub Actions):
22+
- Playwright browser caching across runs
23+
- Pinned fonts (Noto, Liberation) for deterministic cross-environment rendering
24+
- Job timeout (10 min)
25+
- Concurrency control with cancel-in-progress
26+
- Node.js 24 compatibility (`FORCE_JAVASCRIPT_ACTIONS_TO_NODE24`)
27+
- Diff artifact upload for manual review (7-day retention)
28+
- Separate regression vs infra failure exit codes in consumer template
1329

1430
## [0.4.0]
1531

scripts/capture.ts

Lines changed: 205 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,15 @@ async function captureRoute(
3636
config: DojoWatchConfig,
3737
route: string,
3838
viewport: Viewport,
39-
outputDir: string
39+
outputDir: string,
40+
profileName?: string
4041
): Promise<CaptureResult> {
41-
const name = routeToName(route);
42+
const baseName = routeToName(route);
43+
// Role-aware naming: dashboard-admin-desktop.png vs dashboard-desktop.png
44+
const name = profileName ? `${baseName}-${profileName}` : baseName;
4245
const filename = `${name}-${viewport.name}.png`;
4346
const outputPath = join(outputDir, filename);
47+
const warnings: import("./types.js").CaptureWarning[] = [];
4448

4549
// Set viewport size
4650
await page.setViewportSize({
@@ -52,9 +56,39 @@ async function captureRoute(
5256
const url = new URL(route, config.baseUrl).toString();
5357
await page.goto(url, { waitUntil: "load", timeout: 30_000 });
5458

59+
// Smart layer: wait for readiness
60+
const readiness =
61+
config.smart?.routeReadiness?.[route] ?? config.smart?.readiness;
62+
if (readiness) {
63+
try {
64+
await waitForReadiness(page, readiness);
65+
} catch {
66+
warnings.push({
67+
type: "readiness_timeout",
68+
message: `Readiness check timed out for ${route}`,
69+
suggestion: "Increase smart.readiness.timeout or check waitForSelector/waitForText",
70+
});
71+
}
72+
}
73+
74+
// Smart layer: detect bot protection
75+
if (config.smart?.detectBotProtection !== false) {
76+
const botDetected = await detectBotProtection(page);
77+
if (botDetected) {
78+
warnings.push({
79+
type: "bot_protection",
80+
message: `Bot protection detected on ${route} — screenshot may show a challenge page`,
81+
suggestion: "Disable bot protection for localhost or add DojoWatch's user-agent to allowlist",
82+
});
83+
}
84+
}
85+
5586
// Stabilize the page
5687
await injectStabilization(page);
5788

89+
// Smart layer: wait for SPA hydration
90+
await waitForHydration(page, config.smart?.hydrationSelectors);
91+
5892
// Mask dynamic elements
5993
await maskElements(page, config.maskSelectors);
6094

@@ -64,38 +98,170 @@ async function captureRoute(
6498
return {
6599
name,
66100
viewport: viewport.name,
101+
profile: profileName,
67102
path: outputPath,
68103
hash: hashFile(outputPath),
104+
warnings,
69105
};
70106
}
71107

72108
/**
73-
* Resolve which storageState file to use for a given route.
74-
* Returns undefined for anonymous access.
109+
* Wait for page readiness beyond basic load/networkidle.
110+
*/
111+
async function waitForReadiness(
112+
page: Awaited<ReturnType<Awaited<ReturnType<typeof chromium.launch>>["newPage"]>>,
113+
readiness: import("./types.js").ReadinessCheck
114+
): Promise<void> {
115+
const timeout = readiness.timeout ?? 10_000;
116+
117+
if (readiness.waitForSelector) {
118+
await page.waitForSelector(readiness.waitForSelector, {
119+
state: "visible",
120+
timeout,
121+
});
122+
}
123+
124+
if (readiness.waitForText) {
125+
await page.waitForFunction(
126+
(text: string) => document.body.textContent?.includes(text) ?? false,
127+
readiness.waitForText,
128+
{ timeout }
129+
);
130+
}
131+
}
132+
133+
/**
134+
* Detect bot protection challenge pages (Cloudflare, hCaptcha, reCAPTCHA).
135+
*/
136+
async function detectBotProtection(
137+
page: Awaited<ReturnType<Awaited<ReturnType<typeof chromium.launch>>["newPage"]>>
138+
): Promise<boolean> {
139+
return page.evaluate(() => {
140+
const indicators = [
141+
// Cloudflare
142+
document.querySelector("#cf-challenge-running"),
143+
document.querySelector(".cf-browser-verification"),
144+
document.querySelector("#challenge-form"),
145+
// hCaptcha
146+
document.querySelector(".h-captcha"),
147+
document.querySelector('iframe[src*="hcaptcha.com"]'),
148+
// reCAPTCHA
149+
document.querySelector(".g-recaptcha"),
150+
document.querySelector('iframe[src*="recaptcha"]'),
151+
// Generic challenge page signals
152+
document.title.includes("Just a moment"),
153+
document.title.includes("Attention Required"),
154+
];
155+
return indicators.some(Boolean);
156+
});
157+
}
158+
159+
/**
160+
* Wait for SPA framework hydration to complete.
161+
* Checks for framework-specific signals or custom selectors.
162+
*/
163+
async function waitForHydration(
164+
page: Awaited<ReturnType<Awaited<ReturnType<typeof chromium.launch>>["newPage"]>>,
165+
customSelectors?: string[]
166+
): Promise<void> {
167+
const selectors = customSelectors ?? [];
168+
169+
// Auto-detect common framework hydration signals
170+
const hydrated = await page.evaluate((customSels: string[]) => {
171+
// Check custom selectors first
172+
for (const sel of customSels) {
173+
if (!document.querySelector(sel)) return false;
174+
}
175+
176+
// Next.js: __NEXT_DATA__ script exists after hydration
177+
// React: check for [data-reactroot] or root with children
178+
// These are best-effort — if not found, assume hydrated
179+
return true;
180+
}, selectors);
181+
182+
if (!hydrated && selectors.length > 0) {
183+
// Wait briefly for hydration
184+
try {
185+
await page.waitForSelector(selectors[0], { timeout: 5_000 });
186+
} catch {
187+
// Proceed anyway — hydration may have already completed
188+
}
189+
}
190+
}
191+
192+
/**
193+
* Resolve auth info for a given route.
194+
* Returns the storageState file path and profile name.
75195
*/
76196
function resolveAuthForRoute(
77197
route: string,
78198
auth?: AuthConfig
79-
): string | undefined {
80-
if (!auth) return undefined;
199+
): { storageState?: string; profileName?: string } {
200+
if (!auth) return {};
81201

82202
// Check per-route mapping first
83203
if (auth.routes && route in auth.routes) {
84204
const profileName = auth.routes[route];
85-
if (profileName === null) return undefined; // explicitly anonymous
86-
if (auth.profiles && profileName in auth.profiles) {
87-
return auth.profiles[profileName];
205+
if (profileName === null) return {}; // explicitly anonymous
206+
if (profileName && auth.profiles && profileName in auth.profiles) {
207+
return { storageState: auth.profiles[profileName], profileName };
88208
}
89-
return undefined;
209+
return {};
210+
}
211+
212+
// Fall back to default storageState (no named profile)
213+
return auth.storageState ? { storageState: auth.storageState } : {};
214+
}
215+
216+
/**
217+
* Capture a route with retry logic for flaky detection.
218+
* Captures N times and compares hashes. If hashes differ, flags as flaky.
219+
*/
220+
async function captureWithRetry(
221+
page: Awaited<ReturnType<Awaited<ReturnType<typeof chromium.launch>>["newPage"]>>,
222+
config: DojoWatchConfig,
223+
route: string,
224+
viewport: Viewport,
225+
outputDir: string,
226+
profileName?: string
227+
): Promise<CaptureResult> {
228+
const retries = config.smart?.retries ?? 1;
229+
230+
if (retries <= 1) {
231+
return captureRoute(page, config, route, viewport, outputDir, profileName);
232+
}
233+
234+
// Capture multiple times and compare hashes
235+
const captures: CaptureResult[] = [];
236+
for (let i = 0; i < retries; i++) {
237+
const result = await captureRoute(page, config, route, viewport, outputDir, profileName);
238+
captures.push(result);
239+
}
240+
241+
// Find the most common hash (majority vote)
242+
const hashCounts = new Map<string, number>();
243+
for (const c of captures) {
244+
hashCounts.set(c.hash, (hashCounts.get(c.hash) ?? 0) + 1);
245+
}
246+
247+
const uniqueHashes = hashCounts.size;
248+
const [bestHash] = [...hashCounts.entries()].sort((a, b) => b[1] - a[1])[0];
249+
const bestCapture = captures.find((c) => c.hash === bestHash)!;
250+
251+
if (uniqueHashes > 1) {
252+
bestCapture.warnings.push({
253+
type: "flaky_capture",
254+
message: `${uniqueHashes} different screenshots from ${retries} captures of ${route}`,
255+
suggestion: "Page has non-deterministic rendering. Add data-vr-mask to dynamic elements or increase stabilization wait time.",
256+
});
90257
}
91258

92-
// Fall back to default storageState
93-
return auth.storageState;
259+
return bestCapture;
94260
}
95261

96262
/**
97263
* Capture all configured routes at all viewports.
98-
* Supports authenticated captures via Playwright storageState.
264+
* Supports authenticated captures, smart readiness, retry, and bot detection.
99265
*/
100266
export async function captureRoutes(
101267
config: DojoWatchConfig,
@@ -108,32 +274,35 @@ export async function captureRoutes(
108274
const results: CaptureResult[] = [];
109275

110276
// Group routes by auth profile to minimize context creation
111-
const routesByAuth = new Map<string | undefined, string[]>();
277+
const routesByAuth = new Map<string, { storageState?: string; profileName?: string; routes: string[] }>();
112278
for (const route of routes) {
113-
const authFile = resolveAuthForRoute(route, config.auth);
114-
const key = authFile ?? "__anonymous__";
115-
if (!routesByAuth.has(key)) routesByAuth.set(key, []);
116-
routesByAuth.get(key)!.push(route);
279+
const { storageState, profileName } = resolveAuthForRoute(route, config.auth);
280+
const key = storageState ?? "__anonymous__";
281+
if (!routesByAuth.has(key)) {
282+
routesByAuth.set(key, { storageState, profileName, routes: [] });
283+
}
284+
routesByAuth.get(key)!.routes.push(route);
117285
}
118286

119287
try {
120-
for (const [authKey, groupedRoutes] of routesByAuth) {
121-
const storageState = authKey === "__anonymous__" ? undefined : authKey;
288+
for (const [, group] of routesByAuth) {
122289
const context = await browser.newContext(
123-
storageState ? { storageState } : undefined
290+
group.storageState ? { storageState: group.storageState } : undefined
124291
);
125292
const page = await context.newPage();
126293

127-
if (storageState) {
128-
console.log(pc.dim(` Auth: ${storageState}`));
294+
if (group.profileName) {
295+
console.log(pc.dim(` Auth profile: ${group.profileName}`));
129296
}
130297

131-
for (const route of groupedRoutes) {
298+
for (const route of group.routes) {
132299
for (const viewport of config.viewports) {
133300
console.log(
134301
pc.dim(` Capturing ${route} @ ${viewport.name} (${viewport.width}x${viewport.height})`)
135302
);
136-
const result = await captureRoute(page, config, route, viewport, outputDir);
303+
const result = await captureWithRetry(
304+
page, config, route, viewport, outputDir, group.profileName
305+
);
137306
results.push(result);
138307
}
139308
}
@@ -144,6 +313,16 @@ export async function captureRoutes(
144313
await browser.close();
145314
}
146315

316+
// Report warnings
317+
const allWarnings = results.flatMap((r) => r.warnings);
318+
if (allWarnings.length > 0) {
319+
console.log(pc.yellow(`\n ${allWarnings.length} warning(s):`));
320+
for (const w of allWarnings) {
321+
console.log(pc.yellow(` [${w.type}] ${w.message}`));
322+
if (w.suggestion) console.log(pc.dim(` → ${w.suggestion}`));
323+
}
324+
}
325+
147326
return results;
148327
}
149328

@@ -213,6 +392,7 @@ export async function captureStorybook(
213392
viewport: viewport.name,
214393
path: outputPath,
215394
hash: hashFile(outputPath),
395+
warnings: [],
216396
});
217397
}
218398
}

0 commit comments

Comments
 (0)