cloudflare/kumo

Public

mirrored fromhttps://github.com/cloudflare/kumoAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
815628fe0b8553d34283f984a0afb85378c27f03

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

ci/visual-regression/page-config.ts

231lines · modecode

1/**
2 * Visual Regression Configuration
3 *
4 * Components are auto-discovered from the docs site sidebar.
5 * Only special actions (click/hover to open overlays) need explicit config.
6 */
7
8export interface ComponentAction {
9 type: "click" | "hover";
10 selector: string;
11 waitAfter?: number;
12}
13
14export interface DiscoveredComponent {
15 id: string;
16 name: string;
17 url: string;
18}
19
20/**
21 * Special actions for components that need interactions to show all states.
22 * Key is the component slug (e.g., "dialog", "dropdown").
23 * These components get TWO screenshots: default state + opened state.
24 */
25export const COMPONENT_ACTIONS: Record<string, ComponentAction> = {
26 dialog: { type: "click", selector: "main button", waitAfter: 400 },
27 dropdown: { type: "click", selector: "main button", waitAfter: 300 },
28 popover: { type: "click", selector: "main button", waitAfter: 300 },
29 tooltip: { type: "hover", selector: "main button", waitAfter: 500 },
30 select: { type: "click", selector: "main button", waitAfter: 300 },
31 combobox: { type: "click", selector: "main input", waitAfter: 300 },
32 toast: { type: "click", selector: "main button", waitAfter: 500 },
33 collapsible: { type: "click", selector: "main button", waitAfter: 300 },
34 "command-palette": { type: "click", selector: "main button", waitAfter: 300 },
35 "date-range-picker": {
36 type: "click",
37 selector: "main button",
38 waitAfter: 300,
39 },
40};
41
42/**
43 * Discover all component pages from the docs site sidebar.
44 */
45export async function discoverComponents(
46 baseUrl: string,
47): Promise<DiscoveredComponent[]> {
48 const response = await fetch(baseUrl);
49 const html = await response.text();
50
51 const componentLinks: DiscoveredComponent[] = [];
52 const seen = new Set<string>();
53
54 const linkRegex = /href="(\/components\/([^"]+))"/g;
55 let match;
56
57 while ((match = linkRegex.exec(html)) !== null) {
58 const url = match[1];
59 const slug = match[2];
60
61 if (seen.has(slug)) continue;
62 seen.add(slug);
63
64 const name = slug
65 .split("-")
66 .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
67 .join(" ");
68
69 componentLinks.push({ id: slug, name, url });
70 }
71
72 return componentLinks;
73}
74
75/**
76 * Representative components used as canaries for broad-impact changes.
77 * Covers a simple input, a complex overlay, and a layout-heavy component
78 * to catch regressions without screenshotting every page.
79 */
80export const CANARY_COMPONENTS = ["button", "dialog", "select"];
81
82/**
83 * Patterns that indicate broad visual impact across all components.
84 * Changes to these files trigger a canary regression instead of granular checks.
85 */
86const FULL_REGRESSION_PATTERNS: RegExp[] = [
87 // Shared styles and theming
88 /packages\/kumo\/src\/styles\//,
89 /packages\/kumo\/src\/utils\//,
90 /packages\/kumo\/src\/hooks\//,
91 /packages\/kumo\/src\/primitives\//,
92 /theme-kumo\.css/,
93 /tailwind\.config/,
94
95 // Docs site infrastructure that wraps all component demos
96 /packages\/kumo-docs-astro\/src\/components\/docs\//,
97 /packages\/kumo-docs-astro\/src\/layouts\//,
98 /packages\/kumo-docs-astro\/src\/styles\//,
99];
100
101/**
102 * Patterns for files that have no visual impact and are safe to skip.
103 */
104const SKIP_PATTERNS: RegExp[] = [
105 /\.md$/,
106 /\.changeset\//,
107 /^ci\/(?!visual-regression)/,
108 /\.github\//,
109 /lint\//,
110 /\.test\.(ts|tsx)$/,
111 /\.spec\.(ts|tsx)$/,
112 /packages\/kumo\/ai\//,
113 /packages\/kumo\/scripts\//,
114 /packages\/kumo-figma\//,
115 /packages\/kumo\/src\/command-line\//,
116 /packages\/kumo\/src\/catalog\//,
117 /packages\/kumo\/src\/blocks\//,
118 /lefthook/,
119 /tsconfig/,
120 /\.env/,
121];
122
123export interface ChangeClassification {
124 /** Component slugs that were directly changed */
125 affectedComponents: Set<string>;
126 /** Whether any broad-impact files were changed, requiring full regression */
127 requiresFullRegression: boolean;
128 /** Whether all changed files are safe to skip */
129 allSkippable: boolean;
130}
131
132/**
133 * Extract component slug from a changed file path.
134 * Returns null if the file doesn't map to a specific component.
135 *
136 * Examples:
137 * packages/kumo/src/components/button/button.tsx -> "button"
138 * packages/kumo/src/components/button/use-button.ts -> "button"
139 * packages/kumo/src/components/button/index.ts -> "button"
140 * packages/kumo-docs-astro/.../ButtonDemo.tsx -> "button"
141 */
142export function getComponentFromFile(filePath: string): string | null {
143 // Match any file under a component directory: packages/kumo/src/components/{name}/
144 const componentMatch = filePath.match(
145 /packages\/kumo\/src\/components\/([^/]+)\//,
146 );
147 if (componentMatch) {
148 return componentMatch[1];
149 }
150
151 // Match demo files: *Demo.tsx -> extract component name
152 const demoMatch = filePath.match(/([A-Z][a-zA-Z]+)Demo\.tsx$/);
153 if (demoMatch) {
154 // Convert PascalCase to kebab-case: DateRangePicker -> date-range-picker
155 const pascalName = demoMatch[1];
156 return pascalName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
157 }
158
159 return null;
160}
161
162/**
163 * Classify changed files into three tiers:
164 * 1. Component-specific: test only those components (granular)
165 * 2. Broad-impact: triggers full regression (shared styles, docs infra, etc.)
166 * 3. Skippable: no visual impact (CI scripts, markdown, tests, etc.)
167 *
168 * Files that don't match any pattern are treated as broad-impact (safe default).
169 */
170export function classifyChangedFiles(
171 changedFiles: string[],
172): ChangeClassification {
173 const affectedComponents = new Set<string>();
174 let requiresFullRegression = false;
175 let hasUnclassifiedFiles = false;
176
177 for (const file of changedFiles) {
178 // Check if it maps to a specific component
179 const slug = getComponentFromFile(file);
180 if (slug) {
181 affectedComponents.add(slug);
182 continue;
183 }
184
185 // Check if it's a known broad-impact file
186 if (FULL_REGRESSION_PATTERNS.some((pattern) => pattern.test(file))) {
187 requiresFullRegression = true;
188 continue;
189 }
190
191 // Check if it's safe to skip
192 if (SKIP_PATTERNS.some((pattern) => pattern.test(file))) {
193 continue;
194 }
195
196 // Unknown file — treat as broad impact to be safe
197 hasUnclassifiedFiles = true;
198 }
199
200 if (hasUnclassifiedFiles) {
201 requiresFullRegression = true;
202 }
203
204 const allSkippable = !requiresFullRegression && affectedComponents.size === 0;
205
206 return { affectedComponents, requiresFullRegression, allSkippable };
207}
208
209/** Strip hyphens for fuzzy slug comparison (e.g. "menubar" matches "menu-bar") */
210function normalizeSlug(slug: string): string {
211 return slug.replace(/-/g, "");
212}
213
214/**
215 * Get the components that should be screenshotted based on changed files.
216 */
217export function getAffectedComponents(
218 changedFiles: string[],
219 allComponents: DiscoveredComponent[],
220): DiscoveredComponent[] {
221 const affectedSlugs = new Set<string>();
222
223 for (const file of changedFiles) {
224 const slug = getComponentFromFile(file);
225 if (slug) {
226 affectedSlugs.add(normalizeSlug(slug));
227 }
228 }
229
230 return allComponents.filter((c) => affectedSlugs.has(normalizeSlug(c.id)));
231}
232