cloudflare/kumo
Publicmirrored fromhttps://github.com/cloudflare/kumoAvailable
ci/visual-regression/run-visual-regression.ts
492lines · modecode
| 1 | #!/usr/bin/env tsx |
| 2 | import { execSync } from "node:child_process"; |
| 3 | import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; |
| 4 | import { join } from "node:path"; |
| 5 | import { |
| 6 | COMPONENT_ACTIONS, |
| 7 | discoverComponents, |
| 8 | getAffectedComponents, |
| 9 | type DiscoveredComponent, |
| 10 | } from "./page-config"; |
| 11 | |
| 12 | const WORKER_URL = |
| 13 | process.env.SCREENSHOT_WORKER_URL ?? |
| 14 | "https://kumo-screenshot-worker.design-engineering.workers.dev"; |
| 15 | const SCREENSHOTS_DIR = "ci/visual-regression/screenshots"; |
| 16 | const API_KEY = process.env.SCREENSHOT_API_KEY ?? ""; |
| 17 | |
| 18 | interface ScreenshotResult { |
| 19 | url: string; |
| 20 | image: string; |
| 21 | error?: string; |
| 22 | sectionId?: string; |
| 23 | sectionTitle?: string; |
| 24 | } |
| 25 | |
| 26 | interface WorkerResponse { |
| 27 | results: ScreenshotResult[]; |
| 28 | } |
| 29 | |
| 30 | interface CapturedScreenshot { |
| 31 | id: string; |
| 32 | name: string; |
| 33 | path: string; |
| 34 | url: string | null; |
| 35 | } |
| 36 | |
| 37 | interface ComparisonResult { |
| 38 | id: string; |
| 39 | name: string; |
| 40 | beforeUrl: string; |
| 41 | afterUrl: string; |
| 42 | changed: boolean; |
| 43 | } |
| 44 | |
| 45 | function getChangedFiles(): string[] { |
| 46 | try { |
| 47 | const base = process.env.GITHUB_BASE_REF ?? "main"; |
| 48 | const output = execSync(`git diff --name-only origin/${base}...HEAD`, { |
| 49 | encoding: "utf-8", |
| 50 | }); |
| 51 | return output.trim().split("\n").filter(Boolean); |
| 52 | } catch { |
| 53 | return []; |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | function ensureDir(dir: string): void { |
| 58 | if (!existsSync(dir)) { |
| 59 | mkdirSync(dir, { recursive: true }); |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | async function uploadImageToGitHub( |
| 64 | imageBuffer: Buffer, |
| 65 | filename: string, |
| 66 | ): Promise<string> { |
| 67 | const token = process.env.GITHUB_TOKEN; |
| 68 | const repo = process.env.GITHUB_REPOSITORY ?? "cloudflare/kumo"; |
| 69 | const prNumber = process.env.GITHUB_PR_NUMBER ?? process.env.PR_NUMBER; |
| 70 | const runId = process.env.GITHUB_RUN_ID ?? Date.now().toString(); |
| 71 | |
| 72 | if (!token) { |
| 73 | throw new Error("GITHUB_TOKEN required for image upload"); |
| 74 | } |
| 75 | |
| 76 | const [owner, repoName] = repo.split("/"); |
| 77 | const branch = `vr-screenshots-${prNumber}-${runId}`; |
| 78 | const path = `screenshots/${filename}`; |
| 79 | |
| 80 | const mainRef = await fetch( |
| 81 | `https://api.github.com/repos/${owner}/${repoName}/git/ref/heads/main`, |
| 82 | { |
| 83 | headers: { |
| 84 | Authorization: `Bearer ${token}`, |
| 85 | Accept: "application/vnd.github.v3+json", |
| 86 | }, |
| 87 | }, |
| 88 | ); |
| 89 | const mainData = (await mainRef.json()) as { object: { sha: string } }; |
| 90 | const baseSha = mainData.object.sha; |
| 91 | |
| 92 | const refCheck = await fetch( |
| 93 | `https://api.github.com/repos/${owner}/${repoName}/git/ref/heads/${branch}`, |
| 94 | { |
| 95 | headers: { |
| 96 | Authorization: `Bearer ${token}`, |
| 97 | Accept: "application/vnd.github.v3+json", |
| 98 | }, |
| 99 | }, |
| 100 | ); |
| 101 | |
| 102 | if (refCheck.status === 404) { |
| 103 | await fetch(`https://api.github.com/repos/${owner}/${repoName}/git/refs`, { |
| 104 | method: "POST", |
| 105 | headers: { |
| 106 | Authorization: `Bearer ${token}`, |
| 107 | Accept: "application/vnd.github.v3+json", |
| 108 | "Content-Type": "application/json", |
| 109 | }, |
| 110 | body: JSON.stringify({ |
| 111 | ref: `refs/heads/${branch}`, |
| 112 | sha: baseSha, |
| 113 | }), |
| 114 | }); |
| 115 | } |
| 116 | |
| 117 | const content = imageBuffer.toString("base64"); |
| 118 | |
| 119 | const existingFile = await fetch( |
| 120 | `https://api.github.com/repos/${owner}/${repoName}/contents/${path}?ref=${branch}`, |
| 121 | { |
| 122 | headers: { |
| 123 | Authorization: `Bearer ${token}`, |
| 124 | Accept: "application/vnd.github.v3+json", |
| 125 | }, |
| 126 | }, |
| 127 | ); |
| 128 | |
| 129 | const existingData = existingFile.ok |
| 130 | ? ((await existingFile.json()) as { sha?: string }) |
| 131 | : null; |
| 132 | |
| 133 | await fetch( |
| 134 | `https://api.github.com/repos/${owner}/${repoName}/contents/${path}`, |
| 135 | { |
| 136 | method: "PUT", |
| 137 | headers: { |
| 138 | Authorization: `Bearer ${token}`, |
| 139 | Accept: "application/vnd.github.v3+json", |
| 140 | "Content-Type": "application/json", |
| 141 | }, |
| 142 | body: JSON.stringify({ |
| 143 | message: `Visual regression: ${filename}`, |
| 144 | content, |
| 145 | branch, |
| 146 | ...(existingData?.sha ? { sha: existingData.sha } : {}), |
| 147 | }), |
| 148 | }, |
| 149 | ); |
| 150 | |
| 151 | return `https://raw.githubusercontent.com/${owner}/${repoName}/${branch}/${path}`; |
| 152 | } |
| 153 | |
| 154 | interface PageRequest { |
| 155 | url: string; |
| 156 | captureSections: boolean; |
| 157 | hideSidebar: boolean; |
| 158 | actions?: Array<{ type: string; selector: string; waitAfter?: number }>; |
| 159 | } |
| 160 | |
| 161 | async function captureScreenshots( |
| 162 | baseUrl: string, |
| 163 | components: DiscoveredComponent[], |
| 164 | outputDir: string, |
| 165 | prefix: string, |
| 166 | ): Promise<CapturedScreenshot[]> { |
| 167 | ensureDir(outputDir); |
| 168 | const screenshots: CapturedScreenshot[] = []; |
| 169 | |
| 170 | const requests: PageRequest[] = []; |
| 171 | |
| 172 | for (const component of components) { |
| 173 | requests.push({ |
| 174 | url: component.url, |
| 175 | captureSections: true, |
| 176 | hideSidebar: true, |
| 177 | }); |
| 178 | |
| 179 | const action = COMPONENT_ACTIONS[component.id]; |
| 180 | if (action) { |
| 181 | requests.push({ |
| 182 | url: component.url, |
| 183 | captureSections: false, |
| 184 | hideSidebar: true, |
| 185 | actions: [action], |
| 186 | }); |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | console.log(`Capturing screenshots from ${baseUrl}...`); |
| 191 | console.log(` ${components.length} components, ${requests.length} requests`); |
| 192 | |
| 193 | const headers: Record<string, string> = { |
| 194 | "Content-Type": "application/json", |
| 195 | }; |
| 196 | if (API_KEY) { |
| 197 | headers["X-API-Key"] = API_KEY; |
| 198 | } |
| 199 | |
| 200 | const response = await fetch(`${WORKER_URL}/batch`, { |
| 201 | method: "POST", |
| 202 | headers, |
| 203 | body: JSON.stringify({ |
| 204 | baseUrl, |
| 205 | pages: requests, |
| 206 | viewport: { width: 1440, height: 900 }, |
| 207 | hideSidebar: true, |
| 208 | }), |
| 209 | }); |
| 210 | |
| 211 | if (!response.ok) { |
| 212 | const text = await response.text(); |
| 213 | throw new Error(`Worker request failed: ${response.status} - ${text}`); |
| 214 | } |
| 215 | |
| 216 | const data = (await response.json()) as WorkerResponse; |
| 217 | |
| 218 | for (const result of data.results) { |
| 219 | if (result.error) { |
| 220 | console.warn(` Error: ${result.url}: ${result.error}`); |
| 221 | continue; |
| 222 | } |
| 223 | |
| 224 | if (!result.image) { |
| 225 | console.warn(` Empty: ${result.url}`); |
| 226 | continue; |
| 227 | } |
| 228 | |
| 229 | const urlPath = new URL(result.url).pathname.replace(/\/$/, ""); |
| 230 | const componentSlug = urlPath.split("/").pop() || "unknown"; |
| 231 | |
| 232 | const isOpenState = requests.some( |
| 233 | (r) => |
| 234 | r.url === urlPath.replace(/\/$/, "") && |
| 235 | r.actions && |
| 236 | r.actions.length > 0, |
| 237 | ); |
| 238 | |
| 239 | let screenshotId: string; |
| 240 | let screenshotName: string; |
| 241 | |
| 242 | if (result.sectionId) { |
| 243 | screenshotId = `${componentSlug}-${result.sectionId}`; |
| 244 | screenshotName = `${formatName(componentSlug)} / ${result.sectionTitle || result.sectionId}`; |
| 245 | } else if (isOpenState) { |
| 246 | screenshotId = `${componentSlug}-open`; |
| 247 | screenshotName = `${formatName(componentSlug)} (Open)`; |
| 248 | } else { |
| 249 | screenshotId = componentSlug; |
| 250 | screenshotName = formatName(componentSlug); |
| 251 | } |
| 252 | |
| 253 | const filename = `${prefix}-${screenshotId}.png`; |
| 254 | const filepath = join(outputDir, filename); |
| 255 | |
| 256 | const imageBuffer = Buffer.from(result.image, "base64"); |
| 257 | writeFileSync(filepath, imageBuffer); |
| 258 | |
| 259 | let imageUrl: string | null = null; |
| 260 | try { |
| 261 | imageUrl = await uploadImageToGitHub(imageBuffer, filename); |
| 262 | console.log(` OK: ${screenshotName} -> ${imageUrl}`); |
| 263 | } catch (err) { |
| 264 | const msg = err instanceof Error ? err.message : String(err); |
| 265 | if (msg.includes("GITHUB_TOKEN required")) { |
| 266 | console.log(` OK: ${screenshotName} (local only, no GITHUB_TOKEN)`); |
| 267 | } else { |
| 268 | console.error(` Upload failed for ${screenshotName}: ${msg}`); |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | screenshots.push({ |
| 273 | id: screenshotId, |
| 274 | name: screenshotName, |
| 275 | path: filepath, |
| 276 | url: imageUrl, |
| 277 | }); |
| 278 | } |
| 279 | |
| 280 | return screenshots; |
| 281 | } |
| 282 | |
| 283 | function formatName(slug: string): string { |
| 284 | return slug |
| 285 | .split("-") |
| 286 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) |
| 287 | .join(" "); |
| 288 | } |
| 289 | |
| 290 | function compareImages(beforePath: string, afterPath: string): boolean { |
| 291 | if (!existsSync(beforePath) || !existsSync(afterPath)) { |
| 292 | return true; |
| 293 | } |
| 294 | |
| 295 | const before = readFileSync(beforePath); |
| 296 | const after = readFileSync(afterPath); |
| 297 | |
| 298 | return !before.equals(after); |
| 299 | } |
| 300 | |
| 301 | function generateMarkdownReport(comparisons: ComparisonResult[]): string { |
| 302 | const changed = comparisons.filter((c) => c.changed); |
| 303 | const unchanged = comparisons.filter((c) => !c.changed); |
| 304 | |
| 305 | const lines: string[] = [ |
| 306 | "<!-- kumo-visual-regression -->", |
| 307 | "## Visual Regression Report", |
| 308 | "", |
| 309 | ]; |
| 310 | |
| 311 | if (changed.length === 0) { |
| 312 | lines.push("No visual changes detected."); |
| 313 | return lines.join("\n"); |
| 314 | } |
| 315 | |
| 316 | lines.push(`**${changed.length} screenshot(s) with visual changes:**`); |
| 317 | lines.push(""); |
| 318 | |
| 319 | for (const comp of changed) { |
| 320 | lines.push(`### ${comp.name}`); |
| 321 | lines.push(""); |
| 322 | lines.push("| Before | After |"); |
| 323 | lines.push("|--------|-------|"); |
| 324 | lines.push(`|  |  |`); |
| 325 | lines.push(""); |
| 326 | } |
| 327 | |
| 328 | if (unchanged.length > 0) { |
| 329 | lines.push("<details>"); |
| 330 | lines.push( |
| 331 | `<summary>${unchanged.length} screenshot(s) unchanged</summary>`, |
| 332 | ); |
| 333 | lines.push(""); |
| 334 | unchanged.forEach((c) => lines.push(`- ${c.name}`)); |
| 335 | lines.push("</details>"); |
| 336 | } |
| 337 | |
| 338 | lines.push(""); |
| 339 | lines.push("---"); |
| 340 | lines.push("*Generated by Kumo Visual Regression*"); |
| 341 | |
| 342 | return lines.join("\n"); |
| 343 | } |
| 344 | |
| 345 | async function postPRComment(body: string): Promise<void> { |
| 346 | const token = process.env.GITHUB_TOKEN; |
| 347 | const prNumber = process.env.GITHUB_PR_NUMBER ?? process.env.PR_NUMBER; |
| 348 | const repo = process.env.GITHUB_REPOSITORY ?? "cloudflare/kumo"; |
| 349 | |
| 350 | if (!token || !prNumber) { |
| 351 | console.log("Missing GITHUB_TOKEN or PR_NUMBER, skipping PR comment"); |
| 352 | console.log("\n--- Report ---\n"); |
| 353 | console.log(body); |
| 354 | return; |
| 355 | } |
| 356 | |
| 357 | const [owner, repoName] = repo.split("/"); |
| 358 | const marker = "<!-- kumo-visual-regression -->"; |
| 359 | |
| 360 | const commentsResponse = await fetch( |
| 361 | `https://api.github.com/repos/${owner}/${repoName}/issues/${prNumber}/comments`, |
| 362 | { |
| 363 | headers: { |
| 364 | Authorization: `Bearer ${token}`, |
| 365 | Accept: "application/vnd.github.v3+json", |
| 366 | }, |
| 367 | }, |
| 368 | ); |
| 369 | |
| 370 | const comments = (await commentsResponse.json()) as Array<{ |
| 371 | id: number; |
| 372 | body?: string; |
| 373 | }>; |
| 374 | const existingComment = comments.find((c) => c.body?.startsWith(marker)); |
| 375 | |
| 376 | const url = existingComment |
| 377 | ? `https://api.github.com/repos/${owner}/${repoName}/issues/comments/${existingComment.id}` |
| 378 | : `https://api.github.com/repos/${owner}/${repoName}/issues/${prNumber}/comments`; |
| 379 | |
| 380 | const method = existingComment ? "PATCH" : "POST"; |
| 381 | |
| 382 | await fetch(url, { |
| 383 | method, |
| 384 | headers: { |
| 385 | Authorization: `Bearer ${token}`, |
| 386 | Accept: "application/vnd.github.v3+json", |
| 387 | "Content-Type": "application/json", |
| 388 | }, |
| 389 | body: JSON.stringify({ body }), |
| 390 | }); |
| 391 | |
| 392 | console.log(`PR comment ${existingComment ? "updated" : "created"}`); |
| 393 | } |
| 394 | |
| 395 | async function main(): Promise<void> { |
| 396 | const args = process.argv.slice(2); |
| 397 | const fullRegression = args.includes("--full"); |
| 398 | |
| 399 | const beforeUrl = process.env.BEFORE_URL ?? "https://kumo-ui.com"; |
| 400 | const afterUrl = |
| 401 | process.env.AFTER_URL ?? process.env.PREVIEW_URL ?? beforeUrl; |
| 402 | |
| 403 | console.log("Discovering components from docs site..."); |
| 404 | const allComponents = await discoverComponents(beforeUrl); |
| 405 | console.log(`Found ${allComponents.length} components\n`); |
| 406 | |
| 407 | let components: DiscoveredComponent[]; |
| 408 | |
| 409 | if (fullRegression) { |
| 410 | components = allComponents; |
| 411 | console.log( |
| 412 | `Running full visual regression (${components.length} components)...\n`, |
| 413 | ); |
| 414 | } else { |
| 415 | const changedFiles = getChangedFiles(); |
| 416 | components = getAffectedComponents(changedFiles, allComponents); |
| 417 | |
| 418 | if (components.length === 0) { |
| 419 | console.log( |
| 420 | "No relevant file changes detected. Skipping visual regression.", |
| 421 | ); |
| 422 | return; |
| 423 | } |
| 424 | |
| 425 | console.log(`Found ${components.length} affected component(s):`); |
| 426 | components.forEach((c) => console.log(` - ${c.name} (${c.url})`)); |
| 427 | console.log(""); |
| 428 | } |
| 429 | |
| 430 | const beforeDir = join(SCREENSHOTS_DIR, "before"); |
| 431 | const afterDir = join(SCREENSHOTS_DIR, "after"); |
| 432 | |
| 433 | console.log("=== Capturing BEFORE screenshots ==="); |
| 434 | const beforeScreenshots = await captureScreenshots( |
| 435 | beforeUrl, |
| 436 | components, |
| 437 | beforeDir, |
| 438 | "before", |
| 439 | ); |
| 440 | |
| 441 | console.log("\n=== Capturing AFTER screenshots ==="); |
| 442 | const afterScreenshots = await captureScreenshots( |
| 443 | afterUrl, |
| 444 | components, |
| 445 | afterDir, |
| 446 | "after", |
| 447 | ); |
| 448 | |
| 449 | console.log("\n=== Comparing screenshots ==="); |
| 450 | const comparisons: ComparisonResult[] = []; |
| 451 | |
| 452 | const beforeMap = new Map(beforeScreenshots.map((s) => [s.id, s])); |
| 453 | const afterMap = new Map(afterScreenshots.map((s) => [s.id, s])); |
| 454 | |
| 455 | const allIds = Array.from( |
| 456 | new Set([...Array.from(beforeMap.keys()), ...Array.from(afterMap.keys())]), |
| 457 | ); |
| 458 | |
| 459 | for (const id of allIds) { |
| 460 | const before = beforeMap.get(id); |
| 461 | const after = afterMap.get(id); |
| 462 | |
| 463 | if (!before || !after) continue; |
| 464 | if (!before.url || !after.url) { |
| 465 | console.log( |
| 466 | ` ${before?.name || after?.name || id}: skipped (upload failed)`, |
| 467 | ); |
| 468 | continue; |
| 469 | } |
| 470 | |
| 471 | const changed = compareImages(before.path, after.path); |
| 472 | |
| 473 | comparisons.push({ |
| 474 | id, |
| 475 | name: before.name, |
| 476 | beforeUrl: before.url, |
| 477 | afterUrl: after.url, |
| 478 | changed, |
| 479 | }); |
| 480 | |
| 481 | console.log(` ${before.name}: ${changed ? "CHANGED" : "unchanged"}`); |
| 482 | } |
| 483 | |
| 484 | console.log("\n=== Generating report ==="); |
| 485 | const report = generateMarkdownReport(comparisons); |
| 486 | await postPRComment(report); |
| 487 | } |
| 488 | |
| 489 | main().catch((error) => { |
| 490 | console.error("Visual regression failed:", error); |
| 491 | process.exit(1); |
| 492 | }); |
| 493 | |