CI/CD Integration & Visual Regression Testing
Overview
Phase 3.2 provides production-ready CI/CD pipelines, visual regression testing, and PR preview generation for design token projects. This guide covers:
- Storybook integration with live token documentation
- GitHub Actions workflows (validation, testing, coverage, publishing)
- Playwright-based visual regression testing
- Token change visualization for PRs
- PR preview deployment strategies
- Best practices and troubleshooting
Storybook Integration
Installation
bash
npm install --save-dev @storybook/react @storybook/addon-docs @storybook/addon-toolbars
npm install --save-dev @tokiforge/react # If using React tokensConfiguration
.storybook/main.ts:
typescript
import type { StorybookConfig } from "@storybook/react-webpack5";
const config: StorybookConfig = {
stories: ["../src/**/*.stories.ts?(x)", "../src/**/*.mdx"],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-docs",
"@storybook/addon-toolbars",
],
framework: {
name: "@storybook/react-webpack5",
options: {},
},
docs: {
autodocs: true,
},
};
export default config;.storybook/preview.ts:
typescript
import type { Preview, StoryObj } from "@storybook/react";
import { useEffect } from "react";
import { TokiForge } from "@tokiforge/react";
import tokens from "../tokens.json";
const preview: Preview = {
parameters: {
layout: "fullscreen",
docs: {
theme: "light",
},
},
globalTypes: {
theme: {
name: "Theme",
description: "Select a design token theme",
defaultValue: "light",
toolbar: {
icon: "circlehollow",
items: [
{ value: "light", icon: "circlehollow", title: "Light" },
{ value: "dark", icon: "circle", title: "Dark" },
{ value: "high-contrast", icon: "bolt", title: "High Contrast" },
],
},
},
},
};
export const decorators = [
(Story, context) => {
const selectedTheme = context.globals.theme || "light";
return (
<TokiForge tokens={tokens} theme={selectedTheme}>
<Story />
</TokiForge>
);
},
];
export default preview;Create Token Documentation Stories
src/tokens.stories.mdx:
mdx
import { Canvas, Meta, ColorPalette, ColorItem } from "@storybook/addon-docs";
import tokens from "../tokens.json";
<Meta title="Design System/Tokens" />
# Design Tokens
Live documentation for all design tokens used in your application.
## Colors
<ColorPalette>
{Object.entries(tokens.color || {}).map(([name, token]) => (
<ColorItem
key={name}
title={name}
subtitle={token.value}
colors={[token.value]}
/>
))}
</ColorPalette>
## Typography
| Name | Font | Weight | Size | Line Height |
| --------------------------------- | ------- | ---------------------------- | ------------------- | ----------------- | ------------------- | --- |
| {Object.entries(tokens.typography | | {}).map(([name, token]) => ( |
| ` | ${name} | ${token.fontFamily} | ${token.fontWeight} | ${token.fontSize} | ${token.lineHeight} | ` |
))}
## Spacing
| Name | Value |
| ------------------------------ | ------- | ---------------------------- | --- |
| {Object.entries(tokens.spacing | | {}).map(([name, token]) => ( |
| ` | ${name} | ${token.value} | ` |
))}Theme Switching Story
src/components/ThemeSwitcher.stories.tsx:
typescript
import type { Meta, StoryObj } from "@storybook/react";
import { useTheme } from "@tokiforge/react";
const ThemeSwitcher = () => {
const { theme, setTheme } = useTheme();
return (
<div style={{ padding: "20px" }}>
<h2>Current Theme: {theme}</h2>
<div style={{ display: "flex", gap: "10px" }}>
<button onClick={() => setTheme("light")}>Light</button>
<button onClick={() => setTheme("dark")}>Dark</button>
<button onClick={() => setTheme("high-contrast")}>High Contrast</button>
</div>
</div>
);
};
const meta: Meta<typeof ThemeSwitcher> = {
title: "Foundation/Theme Switcher",
component: ThemeSwitcher,
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};Run Storybook
bash
npm run storybook
# Static build for CI
npm run build-storybookGitHub Actions Workflows
1. Token Validation Workflow
.github/workflows/validate-tokens.yml:
yaml
name: Validate Tokens
on:
pull_request:
paths:
- "tokens.json"
- "tokens/**"
- "packages/*/tokens/**"
push:
branches: [main]
paths:
- "tokens.json"
- "tokens/**"
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Validate token structure
run: npx tokiforge validate tokens.json
- name: Check token consistency
run: npx tokiforge validate --check-consistency
- name: Validate semantic tokens
run: npx tokiforge validate --semantic
- name: Check for unused tokens
run: npx tokiforge analytics --unused --format json > unused-tokens.json || true
- name: Comment PR with validation results
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const unusedFile = fs.existsSync('unused-tokens.json')
? JSON.parse(fs.readFileSync('unused-tokens.json', 'utf8'))
: null;
let comment = '✅ Tokens validation passed!\n\n';
if (unusedFile && unusedFile.unused.length > 0) {
comment += '⚠️ Potentially unused tokens:\n';
unusedFile.unused.forEach(token => {
comment += `- \`${token}\`\n`;
});
}
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment,
});2. Visual Regression Testing
.github/workflows/visual-regression.yml:
yaml
name: Visual Regression Tests
on:
pull_request:
paths:
- "tokens.json"
- "tokens/**"
- "src/**"
- ".github/workflows/visual-regression.yml"
push:
branches: [main]
paths:
- "tokens.json"
- "tokens/**"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build Storybook
run: npm run build-storybook
- name: Run Playwright visual tests
run: npm run test:visual
continue-on-error: true
- name: Upload visual regression report
if: always()
uses: actions/upload-artifact@v4
with:
name: visual-regression-report
path: test-results/visual-regression/
retention-days: 30
- name: Comment with visual diff
if: github.event_name == 'pull_request' && failure()
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '⚠️ Visual regression detected! Check the artifact for details.',
});3. Coverage and Metrics
.github/workflows/coverage.yml:
yaml
name: Coverage & Metrics
on:
pull_request:
push:
branches: [main]
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npm test -- --coverage
- name: Generate analytics
run: npx tokiforge analytics --format json --output metrics.json
- name: Comment PR with metrics
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const metrics = JSON.parse(fs.readFileSync('metrics.json', 'utf8'));
const comment = `
## Token Metrics 📊
- **Total Tokens**: ${metrics.totalTokens}
- **Bundle Impact**: ${metrics.estimatedBundleSize}
- **Coverage**: ${metrics.coverage}%
- **Types**: ${Object.keys(metrics.tokensByType).map(t => \`\${t} (\${metrics.tokensByType[t]})\`).join(', ')}
\`\`\`json
${JSON.stringify(metrics, null, 2)}
\`\`\`
`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment,
});4. Automated Publishing
.github/workflows/publish.yml:
yaml
name: Publish Tokens
on:
push:
branches: [main]
paths:
- "tokens.json"
- "CHANGELOG.md"
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
cache: "npm"
registry-url: "https://npm.pkg.github.com"
- name: Install dependencies
run: npm ci
- name: Run all validations
run: |
npm run validate
npm test
npm run test:visual
- name: Publish to npm
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create GitHub Release
uses: actions/github-script@v7
with:
script: |
const { version } = require('./package.json');
github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: \`v\${version}\`,
name: \`Release v\${version}\`,
draft: false,
prerelease: false,
});Visual Regression Testing Setup
Configuration
playwright.config.ts:
typescript
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e/visual",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
["html", { outputFolder: "test-results/visual-regression" }],
["json", { outputFile: "test-results/visual-regression.json" }],
],
use: {
baseURL: "http://localhost:6006", // Storybook port
screenshot: "only-on-failure",
video: "retain-on-failure",
},
webServer: {
command: "npm run storybook",
port: 6006,
reuseExistingServer: !process.env.CI,
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
],
});Test Suite
e2e/visual/tokens.spec.ts:
typescript
import { test, expect } from "@playwright/test";
test.describe("Token Visual Regression", () => {
test("should render color palette without changes", async ({ page }) => {
await page.goto("/?path=/docs/design-system-tokens--page");
await expect(page).toHaveScreenshot("color-palette.png", {
maxDiffPixels: 100,
threshold: 0.2,
});
});
test("should render typography specimens", async ({ page }) => {
await page.goto("/?path=/docs/design-system-tokens--page");
const typographySection = page.locator('[data-testid="typography"]');
await expect(typographySection).toHaveScreenshot("typography.png");
});
test("should render spacing scale", async ({ page }) => {
await page.goto("/?path=/docs/design-system-tokens--page");
const spacingSection = page.locator('[data-testid="spacing"]');
await expect(spacingSection).toHaveScreenshot("spacing.png");
});
test.describe("Theme switching", () => {
test("should render light theme", async ({ page }) => {
await page.goto("/?globals=theme:light");
await expect(page).toHaveScreenshot("theme-light.png");
});
test("should render dark theme", async ({ page }) => {
await page.goto("/?globals=theme:dark");
await expect(page).toHaveScreenshot("theme-dark.png");
});
test("should render high-contrast theme", async ({ page }) => {
await page.goto("/?globals=theme:high-contrast");
await expect(page).toHaveScreenshot("theme-high-contrast.png");
});
});
test.describe("Component rendering", () => {
test("should render buttons with token colors", async ({ page }) => {
await page.goto("/?path=/story/components-button--primary");
const button = page.locator("button").first();
await expect(button).toHaveScreenshot("button-primary.png");
});
test("should render cards with spacing", async ({ page }) => {
await page.goto("/?path=/story/components-card--default");
const card = page.locator('[data-testid="card"]').first();
await expect(card).toHaveScreenshot("card-default.png");
});
});
});Run Tests
bash
# Run visual regression tests
npm run test:visual
# Update baselines (use with caution)
npm run test:visual -- --update-snapshots
# Run specific test
npm run test:visual -- --grep "color-palette"Token Change Visualization
Utilities
scripts/visualize-token-changes.ts:
typescript
import * as fs from "fs";
import * as path from "path";
import type { DesignTokens, TokenValue } from "@tokiforge/core";
interface TokenChange {
path: string;
type: "added" | "removed" | "modified";
before?: TokenValue;
after?: TokenValue;
impact: "low" | "medium" | "high";
}
export function detectTokenChanges(
before: DesignTokens,
after: DesignTokens
): TokenChange[] {
const changes: TokenChange[] = [];
const beforePaths = new Set(getTokenPaths(before));
const afterPaths = new Set(getTokenPaths(after));
// Detect additions
for (const path of afterPaths) {
if (!beforePaths.has(path)) {
changes.push({
path,
type: "added",
after: getTokenByPath(after, path),
impact: "low",
});
}
}
// Detect removals
for (const path of beforePaths) {
if (!afterPaths.has(path)) {
changes.push({
path,
type: "removed",
before: getTokenByPath(before, path),
impact: "high",
});
}
}
// Detect modifications
for (const path of Array.from(beforePaths).filter((p) => afterPaths.has(p))) {
const beforeValue = getTokenByPath(before, path);
const afterValue = getTokenByPath(after, path);
if (JSON.stringify(beforeValue) !== JSON.stringify(afterValue)) {
const impact = calculateImpact(beforeValue, afterValue, path);
changes.push({
path,
type: "modified",
before: beforeValue,
after: afterValue,
impact,
});
}
}
return changes;
}
export function generateChangeReport(changes: TokenChange[]): string {
const byType = {
added: changes.filter((c) => c.type === "added"),
removed: changes.filter((c) => c.type === "removed"),
modified: changes.filter((c) => c.type === "modified"),
};
let report = `# Token Changes\n\n`;
report += `**Summary**: ${changes.length} total changes\n`;
report += `- ✅ Added: ${byType.added.length}\n`;
report += `- ❌ Removed: ${byType.removed.length}\n`;
report += `- 🔄 Modified: ${byType.modified.length}\n\n`;
if (byType.added.length > 0) {
report += `## Added Tokens\n\n`;
byType.added.forEach((change) => {
report += `- ✅ \`${change.path}\`: \`${(change.after as any).value}\`\n`;
});
report += "\n";
}
if (byType.removed.length > 0) {
report += `## Removed Tokens\n\n`;
byType.removed.forEach((change) => {
report += `- ❌ \`${change.path}\`: was \`${
(change.before as any).value
}\`\n`;
});
report += "\n";
}
if (byType.modified.length > 0) {
report += `## Modified Tokens\n\n`;
byType.modified.forEach((change) => {
const before = (change.before as any).value;
const after = (change.after as any).value;
const impact =
change.impact === "high"
? "🔴"
: change.impact === "medium"
? "🟡"
: "🟢";
report += `- 🔄 \`${change.path}\`: ${impact}\n`;
report += ` - Before: \`${before}\`\n`;
report += ` - After: \`${after}\`\n`;
});
report += "\n";
}
return report;
}
function getTokenPaths(tokens: DesignTokens, prefix = ""): string[] {
const paths: string[] = [];
const traverse = (obj: any, path: string) => {
for (const [key, value] of Object.entries(obj)) {
const currentPath = path ? `${path}.${key}` : key;
if (value && typeof value === "object" && !Array.isArray(value)) {
if ("value" in value) {
paths.push(currentPath);
} else {
traverse(value, currentPath);
}
}
}
};
traverse(tokens, prefix);
return paths;
}
function getTokenByPath(tokens: any, path: string): any {
const parts = path.split(".");
let current = tokens;
for (const part of parts) {
if (current && typeof current === "object" && part in current) {
current = current[part];
} else {
return undefined;
}
}
return current;
}
function calculateImpact(
before: TokenValue,
after: TokenValue,
path: string
): "low" | "medium" | "high" {
// Type changes are high impact
if ((before as any).type !== (after as any).type) {
return "high";
}
// Color/brand tokens are high impact
if (path.includes("color.primary") || path.includes("color.brand")) {
return "high";
}
// Typography changes are medium impact
if (path.includes("typography")) {
return "medium";
}
// Spacing changes are low impact
return "low";
}
// CLI usage
if (require.main === module) {
const beforePath = process.argv[2];
const afterPath = process.argv[3];
if (!beforePath || !afterPath) {
console.error(
"Usage: npx ts-node scripts/visualize-token-changes.ts <before.json> <after.json>"
);
process.exit(1);
}
const before = JSON.parse(fs.readFileSync(beforePath, "utf8"));
const after = JSON.parse(fs.readFileSync(afterPath, "utf8"));
const changes = detectTokenChanges(before, after);
const report = generateChangeReport(changes);
console.log(report);
fs.writeFileSync("token-changes.md", report);
}PR Preview Generation
Netlify Deployment
.github/workflows/preview.yml:
yaml
name: Preview Deployment
on:
pull_request:
paths:
- "tokens.json"
- "src/**"
- ".storybook/**"
env:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build Storybook
run: npm run build-storybook
- name: Generate token changes
run: |
git fetch origin main
npx ts-node scripts/visualize-token-changes.ts \
<(git show origin/main:tokens.json) \
tokens.json > token-changes.md || true
- name: Deploy preview
id: deploy
run: |
npm install -g netlify-cli
DEPLOY=$(netlify deploy \
--dir=storybook-static \
--message="PR #${{ github.event.pull_request.number }}" \
--alias="pr-${{ github.event.pull_request.number }}")
PREVIEW_URL=$(echo "$DEPLOY" | grep "Website URL" | awk '{print $NF}')
echo "preview_url=$PREVIEW_URL" >> $GITHUB_OUTPUT
- name: Comment with preview link
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const tokenChanges = fs.existsSync('token-changes.md')
? fs.readFileSync('token-changes.md', 'utf8')
: '';
let comment = '## 🎨 Preview Deployment\n\n';
comment += `[View Preview](${{ steps.deploy.outputs.preview_url }})\n\n`;
if (tokenChanges) {
comment += '### Token Changes\n\n';
comment += tokenChanges;
}
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment,
});CI/CD Best Practices
1. Error Handling
yaml
# Graceful failure handling
- name: Run optional validation
run: npx tokiforge validate --strict || true
continue-on-error: true
# Status checks
- name: Check critical validations
run: |
set -e
npx tokiforge validate tokens.json
npm test
npm run build2. Performance Optimization
yaml
# Cache dependencies
- uses: actions/setup-node@v4
with:
cache: 'npm'
# Parallel jobs
jobs:
validate:
runs-on: ubuntu-latest
test:
runs-on: ubuntu-latest
visual:
runs-on: ubuntu-latest3. Security
yaml
# Token security
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
pull-requests: write
# Dependency scanning
- name: Run Dependabot
uses: dependabot/fetch-metadata@v14. Notifications
yaml
# Slack notifications
- name: Notify Slack
if: failure()
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "CI/CD Pipeline Failed",
"blocks": [
{"type": "section", "text": {"type": "mrkdwn", "text": "Workflow: ${{ github.workflow }}\nBranch: ${{ github.ref }}"}}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}Troubleshooting
Visual Tests Failing Intermittently
Solution: Increase timeouts and add retries
yaml
test.setTimeout(60000); // Increase timeout
test.beforeEach(async ({ page }) => {
await page.goto(url);
await page.waitForLoadState('networkidle');
});Baseline Snapshot Mismatches
Solution: Update baselines carefully
bash
# Review changes before updating
git diff test-results/
npm run test:visual -- --update-snapshots
git add test-results/Workflow Timeouts
Solution: Optimize workflow steps
yaml
# Split long workflows
- name: Run quick validation
run: npx tokiforge validate tokens.json
- name: Run slow tests
timeout-minutes: 10
run: npm run test:visualIntegration Examples
Monorepo Setup
.github/workflows/
├── validate-tokens.yml
├── visual-regression.yml
├── coverage.yml
└── publish.yml
packages/
├── core/
├── react/
└── design-system/Single Package Setup
.github/workflows/
└── ci.yml (combined validation + testing)
src/
├── components/
└── tokens.jsonRelated Documentation
Last updated: January 2025Version: 3.2.0