Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 17:51:59 +08:00
commit 38e80921c8
89 changed files with 20480 additions and 0 deletions

View File

@@ -0,0 +1,545 @@
# Visual Regression Testing Setup Skill
**Auto-invokes when user says**:
- "Set up visual regression"
- "Add Chromatic tests"
- "Create visual tests for [component]"
- "Configure visual regression testing"
- "Add screenshot testing"
- "Set up Percy"
- "Add BackstopJS"
---
## Skill Purpose
Generate complete visual regression testing setup with Storybook stories, configuration files, and CI/CD workflows.
**Supports**: Chromatic, Percy, BackstopJS
**Frameworks**: React, Vue, Svelte (TypeScript/JavaScript)
**CI/CD**: GitHub Actions, GitLab CI, CircleCI
---
## What This Skill Does
1. **Detects existing setup**: Storybook version, VR tool, CI platform
2. **Validates component**: Extract props, variants, states
3. **Generates stories**: Complete `.stories.tsx` with all variants
4. **Creates config files**: Chromatic, Percy, or BackstopJS configuration
5. **Sets up CI/CD**: Auto-generate workflow files
6. **Provides instructions**: Next steps for API tokens, first baseline
---
## Workflow
### Step 1: Validate Project Setup
**Execute**: `vr_setup_validator.py`
**Check**:
- Framework (React/Vue/Svelte) from package.json
- Existing Storybook config (.storybook/ directory)
- Existing VR tool (chromatic, percy, backstopjs in dependencies)
- CI platform (.github/, .gitlab-ci.yml, .circleci/)
- Component file exists and is valid
**Output**:
```json
{
"framework": "react",
"storybook_version": "7.6.0",
"vr_tool": "chromatic",
"ci_platform": "github",
"component": {
"path": "src/components/ProfileCard.tsx",
"name": "ProfileCard",
"props": [...],
"valid": true
},
"dependencies": {
"installed": ["@storybook/react", "@storybook/addon-essentials"],
"missing": ["chromatic", "@chromatic-com/storybook"]
}
}
```
**If Storybook not found**: Ask user if they want to install Storybook first, provide setup instructions.
**If multiple VR tools found**: Ask user which to use (Chromatic recommended).
---
### Step 2: Generate Storybook Stories
**Execute**: `story_generator.py`
**Process**:
1. Parse component file (TypeScript/JSX/Vue SFC)
2. Extract props, prop types, default values
3. Identify variants (size, variant, disabled, etc.)
4. Generate story file from template
5. Add accessibility tests (@storybook/addon-a11y)
6. Add interaction tests (if @storybook/test available)
**Template**: `templates/story-template.tsx.j2`
**Example output** (`ProfileCard.stories.tsx`):
```typescript
import type { Meta, StoryObj } from '@storybook/react';
import { ProfileCard } from './ProfileCard';
const meta = {
title: 'Components/ProfileCard',
component: ProfileCard,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
size: { control: 'select', options: ['sm', 'md', 'lg'] },
variant: { control: 'select', options: ['default', 'compact'] },
},
} satisfies Meta<typeof ProfileCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
name: 'John Doe',
avatar: 'https://example.com/avatar.jpg',
bio: 'Software Engineer',
size: 'md',
variant: 'default',
},
};
export const Small: Story = {
args: {
...Default.args,
size: 'sm',
},
};
export const Large: Story = {
args: {
...Default.args,
size: 'lg',
},
};
export const Compact: Story = {
args: {
...Default.args,
variant: 'compact',
},
};
// Accessibility test
Default.parameters = {
a11y: {
config: {
rules: [
{ id: 'color-contrast', enabled: true },
{ id: 'label', enabled: true },
],
},
},
};
```
**Write to**: `{component_directory}/{ComponentName}.stories.tsx`
---
### Step 3: Generate Configuration Files
**Execute**: `chromatic_config_generator.py` (or percy/backstop equivalent)
#### For Chromatic:
**Generate 3 files**:
1. **chromatic.config.json**:
```json
{
"projectId": "<PROJECT_ID_PLACEHOLDER>",
"buildScriptName": "build-storybook",
"exitZeroOnChanges": true,
"exitOnceUploaded": true,
"onlyChanged": true,
"externals": ["public/**"],
"skip": "dependabot/**",
"ignoreLastBuildOnBranch": "main"
}
```
2. **Update .storybook/main.js** (add addon):
```javascript
module.exports = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@chromatic-com/storybook', // ← Added
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
};
```
3. **Update package.json** (add scripts):
```json
{
"scripts": {
"chromatic": "npx chromatic",
"chromatic:ci": "npx chromatic --exit-zero-on-changes"
}
}
```
**For Percy**: Generate `.percy.yml` instead
**For BackstopJS**: Generate `backstop.config.js` instead
---
### Step 4: Generate CI/CD Workflow
**Execute**: `ci_workflow_generator.py`
**Detect CI platform** from existing files:
- `.github/workflows/` → GitHub Actions
- `.gitlab-ci.yml` → GitLab CI
- `.circleci/config.yml` → CircleCI
- None → Ask user, default to GitHub Actions
#### GitHub Actions Example:
**Generate**: `.github/workflows/chromatic.yml`
```yaml
name: Visual Regression Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
chromatic:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for Chromatic
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run Chromatic
uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
exitZeroOnChanges: true
onlyChanged: true
```
**For GitLab CI**: Add job to `.gitlab-ci.yml`
**For CircleCI**: Add job to `.circleci/config.yml`
---
### Step 5: Provide Setup Instructions
**Output to user**:
````markdown
✅ Visual regression testing setup complete!
## Files Created/Modified
✅ {ComponentName}.stories.tsx (Storybook story with variants)
✅ chromatic.config.json (Chromatic configuration)
✅ .storybook/main.js (Added @chromatic-com/storybook addon)
✅ package.json (Added chromatic scripts)
✅ .github/workflows/chromatic.yml (CI workflow)
## Next Steps
### 1. Install Dependencies
```bash
npm install --save-dev chromatic @chromatic-com/storybook
```
### 2. Create Chromatic Project
1. Go to https://www.chromatic.com/start
2. Sign in with GitHub
3. Create new project
4. Copy project token
### 3. Add Secret to GitHub
1. Go to repository Settings → Secrets and variables → Actions
2. Create secret: `CHROMATIC_PROJECT_TOKEN`
3. Paste your project token
### 4. Update chromatic.config.json
Replace `<PROJECT_ID_PLACEHOLDER>` with your actual project ID from Chromatic dashboard.
### 5. Create Baseline
```bash
npm run chromatic
```
This captures the initial screenshots as your baseline.
### 6. Test Visual Regression
1. Make a visual change to ProfileCard
2. Commit and push
3. CI will run Chromatic automatically
4. Review changes in Chromatic dashboard
## Documentation
See `.agent/sops/testing/visual-regression-setup.md` for detailed workflow.
## Troubleshooting
**Storybook build fails**: Ensure all component dependencies are installed
**Chromatic upload fails**: Check project token in secrets
**No changes detected**: Chromatic only runs on changed stories (use `--force-rebuild` to test)
````
---
## Predefined Functions Reference
### vr_setup_validator.py
```python
def detect_storybook_config(project_root: str) -> dict
def detect_vr_tool(project_root: str) -> str
def validate_component_path(component_path: str) -> dict
def check_dependencies(project_root: str) -> dict
```
**Returns**: Validation report with detected setup and missing dependencies
### story_generator.py
```python
def analyze_component(component_path: str, framework: str) -> dict
def generate_story(component_info: dict, template_path: str) -> str
def create_accessibility_tests(component_info: dict) -> str
def create_interaction_tests(component_info: dict) -> str
```
**Returns**: Generated story file content
### chromatic_config_generator.py
```python
def generate_chromatic_config(project_info: dict) -> str
def generate_storybook_config(existing_config: dict) -> str
def generate_package_scripts(existing_scripts: dict) -> dict
def generate_percy_config(project_info: dict) -> str # Percy alternative
def generate_backstop_config(project_info: dict) -> str # BackstopJS alternative
```
**Returns**: Configuration file contents as strings
### ci_workflow_generator.py
```python
def detect_ci_platform(project_root: str) -> str
def generate_github_workflow(project_info: dict) -> str
def generate_gitlab_ci(project_info: dict) -> str
def generate_circleci_config(project_info: dict) -> str
```
**Returns**: CI workflow file contents
---
## Templates Reference
- **story-template.tsx.j2**: React/TypeScript story template
- **story-template.vue.j2**: Vue SFC story template
- **chromatic-config.json.j2**: Chromatic configuration
- **percy-config.yml.j2**: Percy configuration
- **github-workflow.yml.j2**: GitHub Actions workflow
- **gitlab-ci.yml.j2**: GitLab CI job
- **storybook-main.js.j2**: Storybook addon configuration
---
## Examples
### Example 1: Simple Component
```
User: "Set up visual regression for ProfileCard component"
→ Detects: React, existing Storybook, no VR tool
→ Generates: ProfileCard.stories.tsx with 4 variants
→ Creates: Chromatic config, GitHub workflow
→ Outputs: Setup instructions
```
See: `examples/simple-component-vr.md`
### Example 2: Full Design System
```
User: "Set up visual regression for entire design system"
→ Detects: React, Storybook, components in src/components/
→ Generates: Stories for all components (Button, Input, Card, etc.)
→ Creates: Chromatic config with design token validation
→ Outputs: Bulk setup instructions
```
See: `examples/design-system-vr.md`
### Example 3: Existing Storybook
```
User: "Add Chromatic to existing Storybook"
→ Detects: Storybook v7, existing stories
→ Adds: @chromatic-com/storybook addon
→ Creates: Chromatic config, CI workflow
→ Preserves: Existing stories and configuration
```
See: `examples/existing-storybook-vr.md`
---
## Integration with product-design Skill
After `product-design` generates implementation plan, suggest visual regression:
```
"Implementation plan created! Consider setting up visual regression testing:
'Set up visual regression for {ComponentName}'
This ensures pixel-perfect implementation and prevents visual drift."
```
---
## Tool Comparison
### Chromatic (Recommended)
- ✅ Purpose-built for Storybook
- ✅ Component-focused testing
- ✅ UI review workflow
- ✅ Free tier: 5,000 snapshots/month
- ❌ Requires cloud service
### Percy
- ✅ Multi-framework support
- ✅ Responsive testing
- ✅ Visual reviews
- ❌ More expensive
- ❌ Less Storybook-specific
### BackstopJS
- ✅ Open source, self-hosted
- ✅ No cloud dependency
- ✅ Free
- ❌ More manual setup
- ❌ Less automation
**Default**: Chromatic (best Storybook integration)
---
## Error Handling
### Component Not Found
```
Error: Component file not found at {path}
Please provide correct path:
"Set up visual regression for src/components/ProfileCard.tsx"
```
### Storybook Not Installed
```
Storybook not detected. Install first:
npm install --save-dev @storybook/react @storybook/addon-essentials
npx storybook init
Then retry: "Set up visual regression for ProfileCard"
```
### Multiple VR Tools Detected
```
Multiple VR tools found: chromatic, percy
Which should I use?
- "Use Chromatic for visual regression"
- "Use Percy for visual regression"
```
---
## Best Practices
1. **Start with key components**: Don't test everything, focus on design system primitives
2. **Use interaction tests**: Combine visual + functional testing
3. **Baseline on main**: Always merge baselines to main branch
4. **Review changes**: Don't auto-accept visual changes
5. **Test states**: Capture hover, focus, error states
6. **Accessibility**: Include a11y tests in all stories
---
## Token Efficiency
**Traditional approach** (50k tokens):
1. Read Storybook docs (20k)
2. Read Chromatic docs (15k)
3. Write stories manually (10k)
4. Configure CI (5k)
**With visual-regression skill** (3k tokens):
1. Skill auto-invokes (0 tokens)
2. Instructions load (3k tokens)
3. Functions execute (0 tokens)
**Savings**: 94% (47k tokens)
---
## Version History
- **v3.3.0**: Initial release with Chromatic support
- **Future**: Percy, BackstopJS, Vue, Svelte support
---
**Last Updated**: 2025-10-21
**Skill Type**: Project-specific
**Generator**: nav-skill-creator (self-improving)

View File

@@ -0,0 +1,107 @@
# Example: Visual Regression for Full Design System
Setup visual regression for entire design system with token validation.
---
## Scenario
You have a design system with multiple components:
- Button, Input, Card, Avatar, Badge, Modal, etc.
- Design tokens extracted from Figma (via product-design skill)
- Want to ensure pixel-perfect implementation
---
## Usage
```
"Set up visual regression for entire design system in src/components"
```
---
## What Skill Does
1. **Discovers components**: Scans `src/components/` directory
2. **Generates stories**: Creates `.stories.tsx` for each component
3. **Token validation**: Compares CSS values to design tokens
4. **Bulk setup**: Single Chromatic config for all components
---
## Generated Files
```
src/components/
├── Button/
│ ├── Button.tsx
│ └── Button.stories.tsx # ← Generated
├── Input/
│ ├── Input.tsx
│ └── Input.stories.tsx # ← Generated
├── Card/
│ ├── Card.tsx
│ └── Card.stories.tsx # ← Generated
...
chromatic.config.json # ← Generated
.github/workflows/chromatic.yml # ← Generated
```
---
## Integration with product-design Skill
If you used `product-design` skill to extract Figma tokens:
```
1. "Review this design from Figma"
→ Extracts tokens to tokens.json
2. "Set up visual regression for design system"
→ Generates stories with token values
→ Validates implementation matches tokens
```
---
## Token Validation Example
**Design token** (from Figma):
```json
{
"color": {
"primary": {
"value": "#3B82F6"
}
}
}
```
**Story validation**:
```typescript
export const Primary: Story = {
args: { variant: 'primary' },
play: async ({ canvasElement }) => {
const button = within(canvasElement).getByRole('button');
const computedStyle = window.getComputedStyle(button);
expect(computedStyle.backgroundColor).toBe('rgb(59, 130, 246)'); // #3B82F6
},
};
```
---
## Benefits
- **Prevent drift**: Catch when code diverges from designs
- **Scale testing**: Test 50+ components in one workflow
- **Token enforcement**: Ensure design tokens are used correctly
- **Design review**: Designers see visual diffs in Chromatic
---
**Time saved**: 6-10 hours → 15 minutes (95% reduction)
**Components**: All in design system
**Tokens validated**: Automatically

View File

@@ -0,0 +1,109 @@
# Example: Add Chromatic to Existing Storybook
Add visual regression to project that already has Storybook configured.
---
## Scenario
- Storybook 7.x already installed and configured
- Existing `.stories.tsx` files for components
- Want to add Chromatic without breaking existing setup
---
## Usage
```
"Add Chromatic to existing Storybook"
```
---
## What Skill Does
1. **Detects existing setup**: Reads `.storybook/main.js`, existing stories
2. **Non-destructive update**: Only adds Chromatic addon
3. **Preserves config**: Keeps existing addons, framework, settings
4. **CI integration**: Generates workflow
---
## Changes Made
### Before
**.storybook/main.js**:
```javascript
module.exports = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
};
```
### After
```javascript
module.exports = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@chromatic-com/storybook', // ← Added
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
};
```
---
## Additional Files
- `chromatic.config.json` (new)
- `.github/workflows/chromatic.yml` (new)
- `package.json` scripts updated
---
## No Stories Generated
Skill detects existing stories and **skips generation**:
```
✅ Existing Storybook detected
✅ Found 23 existing story files
✅ Skipping story generation
✅ Adding Chromatic configuration only
```
---
## Next Steps
```bash
# Install Chromatic
npm install --save-dev chromatic @chromatic-com/storybook
# Run on existing stories
npm run chromatic
```
All existing stories are captured as baseline automatically.
---
**Time saved**: 1-2 hours → 3 minutes
**Stories affected**: 0 (uses existing)
**Breaking changes**: None

View File

@@ -0,0 +1,297 @@
# Example: Visual Regression for Simple Component
This example shows how to set up visual regression testing for a single React component.
---
## Scenario
You have a `ProfileCard` component that you want to test for visual regressions.
**Component**: `src/components/ProfileCard.tsx`
```typescript
interface ProfileCardProps {
name: string;
avatar: string;
bio: string;
size?: 'sm' | 'md' | 'lg';
variant?: 'default' | 'compact';
}
export function ProfileCard({ name, avatar, bio, size = 'md', variant = 'default' }: ProfileCardProps) {
// Component implementation
}
```
---
## Usage
### Step 1: Invoke Skill
```
"Set up visual regression for ProfileCard component"
```
### Step 2: Skill Auto-Invokes
Navigator detects the command and invokes `visual-regression` skill.
### Step 3: Validation
Skill executes `vr_setup_validator.py`:
```json
{
"framework": "react",
"storybook": {
"installed": true,
"version": "7.6.0"
},
"vr_tool": null,
"ci_platform": "github",
"component": {
"valid": true,
"path": "src/components/ProfileCard.tsx",
"name": "ProfileCard"
},
"dependencies": {
"installed": ["@storybook/react", "@storybook/addon-essentials"],
"missing": ["chromatic", "@chromatic-com/storybook"]
},
"ready": true
}
```
### Step 4: Generate Story
Skill executes `story_generator.py`:
**Created**: `src/components/ProfileCard.stories.tsx`
```typescript
import type { Meta, StoryObj } from '@storybook/react';
import { ProfileCard } from './ProfileCard';
const meta = {
title: 'Components/ProfileCard',
component: ProfileCard,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
size: { control: 'select', options: ['sm', 'md', 'lg'] },
variant: { control: 'select', options: ['default', 'compact'] },
},
} satisfies Meta<typeof ProfileCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
name: 'John Doe',
avatar: 'https://via.placeholder.com/150',
bio: 'Software Engineer',
size: 'md',
variant: 'default',
},
};
export const Small: Story = {
args: {
...Default.args,
size: 'sm',
},
};
export const Large: Story = {
args: {
...Default.args,
size: 'lg',
},
};
export const Compact: Story = {
args: {
...Default.args,
variant: 'compact',
},
};
// Accessibility tests
Default.parameters = {
a11y: {
config: {
rules: [
{ id: 'color-contrast', enabled: true },
{ id: 'label', enabled: true },
],
},
},
};
```
### Step 5: Generate Chromatic Config
Skill executes `chromatic_config_generator.py`:
**Created**: `chromatic.config.json`
```json
{
"projectId": "<PROJECT_ID_PLACEHOLDER>",
"buildScriptName": "build-storybook",
"exitZeroOnChanges": true,
"exitOnceUploaded": true,
"onlyChanged": true,
"externals": ["public/**"],
"skip": "dependabot/**",
"ignoreLastBuildOnBranch": "main"
}
```
**Updated**: `.storybook/main.js`
```diff
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
+ '@chromatic-com/storybook',
'@storybook/addon-interactions',
],
```
**Updated**: `package.json`
```diff
"scripts": {
+ "chromatic": "npx chromatic",
+ "chromatic:ci": "npx chromatic --exit-zero-on-changes",
"build-storybook": "storybook build"
}
```
### Step 6: Generate CI Workflow
Skill executes `ci_workflow_generator.py`:
**Created**: `.github/workflows/chromatic.yml`
```yaml
name: Visual Regression Tests
on:
push:
branches: ['main', 'develop']
pull_request:
branches: ['main']
jobs:
chromatic:
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: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run Chromatic
uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
exitZeroOnChanges: true
onlyChanged: true
autoAcceptChanges: 'main'
```
### Step 7: Setup Instructions
Skill outputs:
```markdown
✅ Visual regression testing setup complete!
## Files Created/Modified
✅ ProfileCard.stories.tsx (Storybook story with 4 variants)
✅ chromatic.config.json (Chromatic configuration)
✅ .storybook/main.js (Added @chromatic-com/storybook addon)
✅ package.json (Added chromatic scripts)
✅ .github/workflows/chromatic.yml (CI workflow)
## Next Steps
### 1. Install Dependencies
```bash
npm install --save-dev chromatic @chromatic-com/storybook
```
### 2. Create Chromatic Project
1. Go to https://www.chromatic.com/start
2. Sign in with GitHub
3. Create new project
4. Copy project token
### 3. Add Secret to GitHub
1. Go to repository Settings → Secrets and variables → Actions
2. Create secret: `CHROMATIC_PROJECT_TOKEN`
3. Paste your project token
### 4. Update chromatic.config.json
Replace `<PROJECT_ID_PLACEHOLDER>` with your actual project ID.
### 5. Create Baseline
```bash
npm run chromatic
```
### 6. Test Visual Regression
1. Make a visual change to ProfileCard
2. Commit and push
3. CI will run Chromatic automatically
4. Review changes in Chromatic dashboard
```
---
## Result
**Time saved**: 2-3 hours → 5 minutes (96% reduction)
**Files created**: 4
**Lines of code generated**: ~150
**Token usage**: 3k tokens (vs 50k manual)
---
## Follow-up Tasks
After setup, you can:
1. **Add more variants**: Edit `ProfileCard.stories.tsx` to add edge cases
2. **Customize Chromatic**: Adjust `chromatic.config.json` settings
3. **Add interaction tests**: Use `@storybook/test` for click/focus states
4. **Review visual diffs**: Monitor Chromatic dashboard for changes
---
**Skill used**: visual-regression
**Time to complete**: ~5 minutes
**Automated**: Story generation, config creation, CI setup

View File

@@ -0,0 +1,363 @@
#!/usr/bin/env python3
"""
Chromatic Configuration Generator
Generates Chromatic config files, updates Storybook configuration,
and adds package.json scripts for visual regression testing.
Usage:
python chromatic_config_generator.py <project_root> [vr_tool]
"""
import json
import os
import re
import sys
from pathlib import Path
from typing import Dict, Optional
def generate_chromatic_config(project_info: Dict) -> str:
"""
Generate chromatic.config.json content.
Args:
project_info: Project information (main_branch, etc.)
Returns:
JSON config as string
"""
main_branch = project_info.get('main_branch', 'main')
config = {
"projectId": "<PROJECT_ID_PLACEHOLDER>",
"buildScriptName": "build-storybook",
"exitZeroOnChanges": True,
"exitOnceUploaded": True,
"onlyChanged": True,
"externals": ["public/**"],
"skip": "dependabot/**",
"ignoreLastBuildOnBranch": main_branch
}
return json.dumps(config, indent=2)
def generate_percy_config(project_info: Dict) -> str:
"""
Generate .percy.yml content for Percy.
Args:
project_info: Project information
Returns:
YAML config as string
"""
config = """version: 2
static:
build-dir: storybook-static
clean-urls: false
snapshot:
widths:
- 375
- 768
- 1280
min-height: 1024
percy-css: ''
"""
return config
def generate_backstop_config(project_info: Dict) -> str:
"""
Generate backstop.config.js for BackstopJS.
Args:
project_info: Project information
Returns:
JS config as string
"""
config = """module.exports = {
id: 'backstop_default',
viewports: [
{
label: 'phone',
width: 375,
height: 667
},
{
label: 'tablet',
width: 768,
height: 1024
},
{
label: 'desktop',
width: 1280,
height: 1024
}
],
scenarios: [],
paths: {
bitmaps_reference: 'backstop_data/bitmaps_reference',
bitmaps_test: 'backstop_data/bitmaps_test',
engine_scripts: 'backstop_data/engine_scripts',
html_report: 'backstop_data/html_report',
ci_report: 'backstop_data/ci_report'
},
report: ['browser'],
engine: 'puppeteer',
engineOptions: {
args: ['--no-sandbox']
},
asyncCaptureLimit: 5,
asyncCompareLimit: 50,
debug: false,
debugWindow: false
};
"""
return config
def update_storybook_main_config(main_js_path: str, vr_tool: str = 'chromatic') -> str:
"""
Update .storybook/main.js to include VR tool addon.
Args:
main_js_path: Path to main.js file
vr_tool: VR tool name ('chromatic', 'percy', 'backstopjs')
Returns:
Updated main.js content
"""
# Read existing config
if not os.path.exists(main_js_path):
# Generate new config if doesn't exist
return generate_new_storybook_config(vr_tool)
with open(main_js_path, 'r') as f:
content = f.read()
# Determine addon to add
if vr_tool == 'chromatic':
addon = '@chromatic-com/storybook'
elif vr_tool == 'percy':
addon = '@percy/storybook'
else:
return content # BackstopJS doesn't need addon
# Check if addon already exists
if addon in content:
return content # Already configured
# Find addons array and insert
addons_pattern = r'addons:\s*\[(.*?)\]'
match = re.search(addons_pattern, content, re.DOTALL)
if match:
existing_addons = match.group(1).strip()
# Add new addon
updated_addons = f"{existing_addons},\n '{addon}'"
updated_content = content.replace(match.group(0), f"addons: [\n {updated_addons}\n ]")
return updated_content
else:
# No addons array found - append at end
return content + f"\n// Added by Navigator visual-regression skill\nmodule.exports.addons.push('{addon}');\n"
def generate_new_storybook_config(vr_tool: str = 'chromatic') -> str:
"""
Generate new .storybook/main.js from scratch.
Args:
vr_tool: VR tool name
Returns:
main.js content
"""
addon = '@chromatic-com/storybook' if vr_tool == 'chromatic' else '@percy/storybook'
config = f"""module.exports = {{
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'{addon}',
'@storybook/addon-interactions',
],
framework: {{
name: '@storybook/react-vite',
options: {{}},
}},
}};
"""
return config
def update_package_json_scripts(package_json_path: str, vr_tool: str = 'chromatic') -> Dict:
"""
Add VR tool scripts to package.json.
Args:
package_json_path: Path to package.json
vr_tool: VR tool name
Returns:
Updated package.json data
"""
with open(package_json_path, 'r') as f:
package_data = json.load(f)
scripts = package_data.get('scripts', {})
# Add VR tool scripts
if vr_tool == 'chromatic':
scripts['chromatic'] = 'npx chromatic'
scripts['chromatic:ci'] = 'npx chromatic --exit-zero-on-changes'
elif vr_tool == 'percy':
scripts['percy'] = 'percy storybook storybook-static'
scripts['percy:ci'] = 'percy storybook storybook-static --partial'
elif vr_tool == 'backstopjs':
scripts['backstop:reference'] = 'backstop reference'
scripts['backstop:test'] = 'backstop test'
scripts['backstop:approve'] = 'backstop approve'
# Ensure build-storybook script exists
if 'build-storybook' not in scripts:
scripts['build-storybook'] = 'storybook build'
package_data['scripts'] = scripts
return package_data
def detect_main_branch(project_root: str) -> str:
"""
Detect main branch name from git.
Args:
project_root: Project root directory
Returns:
Branch name ('main' or 'master')
"""
git_head = Path(project_root) / '.git' / 'HEAD'
if git_head.exists():
with open(git_head, 'r') as f:
content = f.read().strip()
if 'refs/heads/main' in content:
return 'main'
elif 'refs/heads/master' in content:
return 'master'
return 'main' # Default
def generate_configs(project_root: str, vr_tool: str = 'chromatic') -> Dict:
"""
Generate all configuration files for VR setup.
Args:
project_root: Project root directory
vr_tool: VR tool to configure
Returns:
Dict with file paths and contents
"""
project_info = {
'main_branch': detect_main_branch(project_root)
}
result = {
'configs_generated': [],
'configs_updated': [],
'errors': []
}
# Generate tool-specific config
if vr_tool == 'chromatic':
config_path = os.path.join(project_root, 'chromatic.config.json')
config_content = generate_chromatic_config(project_info)
result['configs_generated'].append({
'path': config_path,
'content': config_content
})
elif vr_tool == 'percy':
config_path = os.path.join(project_root, '.percy.yml')
config_content = generate_percy_config(project_info)
result['configs_generated'].append({
'path': config_path,
'content': config_content
})
elif vr_tool == 'backstopjs':
config_path = os.path.join(project_root, 'backstop.config.js')
config_content = generate_backstop_config(project_info)
result['configs_generated'].append({
'path': config_path,
'content': config_content
})
# Update Storybook main.js
storybook_dir = Path(project_root) / '.storybook'
main_js_candidates = [
storybook_dir / 'main.js',
storybook_dir / 'main.ts'
]
main_js_path = None
for candidate in main_js_candidates:
if candidate.exists():
main_js_path = str(candidate)
break
if main_js_path:
main_js_content = update_storybook_main_config(main_js_path, vr_tool)
result['configs_updated'].append({
'path': main_js_path,
'content': main_js_content
})
elif storybook_dir.exists():
# Create new main.js
main_js_path = str(storybook_dir / 'main.js')
main_js_content = generate_new_storybook_config(vr_tool)
result['configs_generated'].append({
'path': main_js_path,
'content': main_js_content
})
# Update package.json
package_json_path = os.path.join(project_root, 'package.json')
if os.path.exists(package_json_path):
updated_package = update_package_json_scripts(package_json_path, vr_tool)
result['configs_updated'].append({
'path': package_json_path,
'content': json.dumps(updated_package, indent=2)
})
return result
def main():
"""CLI entry point."""
if len(sys.argv) < 2:
print("Usage: python chromatic_config_generator.py <project_root> [vr_tool]", file=sys.stderr)
sys.exit(1)
project_root = sys.argv[1]
vr_tool = sys.argv[2] if len(sys.argv) > 2 else 'chromatic'
if vr_tool not in ['chromatic', 'percy', 'backstopjs']:
print(f"Unsupported VR tool: {vr_tool}. Use: chromatic, percy, or backstopjs", file=sys.stderr)
sys.exit(1)
result = generate_configs(project_root, vr_tool)
# Output as JSON
print(json.dumps(result, indent=2))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,490 @@
#!/usr/bin/env python3
"""
CI/CD Workflow Generator for Visual Regression
Generates GitHub Actions, GitLab CI, and CircleCI workflows for Chromatic/Percy/BackstopJS.
Usage:
python ci_workflow_generator.py <project_root> <ci_platform> <vr_tool>
"""
import json
import os
import sys
from pathlib import Path
from typing import Dict
def detect_node_version(project_root: str) -> str:
"""
Detect Node.js version from .nvmrc or package.json.
Args:
project_root: Project root directory
Returns:
Node version string (default: '20')
"""
# Check .nvmrc
nvmrc = Path(project_root) / '.nvmrc'
if nvmrc.exists():
with open(nvmrc, 'r') as f:
return f.read().strip()
# Check package.json engines.node
package_json = Path(project_root) / 'package.json'
if package_json.exists():
with open(package_json, 'r') as f:
try:
data = json.load(f)
node_version = data.get('engines', {}).get('node')
if node_version:
# Extract version number (handle ">=18.0.0" format)
import re
match = re.search(r'\d+', node_version)
if match:
return match.group(0)
except json.JSONDecodeError:
pass
return '20' # Default
def detect_package_manager(project_root: str) -> str:
"""
Detect package manager from lock files.
Args:
project_root: Project root directory
Returns:
Package manager name ('npm', 'yarn', 'pnpm')
"""
root = Path(project_root)
if (root / 'pnpm-lock.yaml').exists():
return 'pnpm'
elif (root / 'yarn.lock').exists():
return 'yarn'
else:
return 'npm'
def get_install_command(package_manager: str) -> str:
"""Get install command for package manager."""
commands = {
'npm': 'npm ci',
'yarn': 'yarn install --frozen-lockfile',
'pnpm': 'pnpm install --frozen-lockfile'
}
return commands.get(package_manager, 'npm ci')
def detect_branches(project_root: str) -> list:
"""
Detect main branches from git config.
Args:
project_root: Project root directory
Returns:
List of branch names
"""
git_head = Path(project_root) / '.git' / 'HEAD'
if git_head.exists():
with open(git_head, 'r') as f:
content = f.read().strip()
if 'refs/heads/main' in content:
return ['main', 'develop']
elif 'refs/heads/master' in content:
return ['master', 'develop']
return ['main', 'develop']
def generate_github_workflow_chromatic(project_info: Dict) -> str:
"""
Generate GitHub Actions workflow for Chromatic.
Args:
project_info: Project information
Returns:
YAML workflow content
"""
node_version = project_info.get('node_version', '20')
package_manager = project_info.get('package_manager', 'npm')
install_command = get_install_command(package_manager)
branches = project_info.get('branches', ['main', 'develop'])
workflow = f"""name: Visual Regression Tests
on:
push:
branches: {json.dumps(branches)}
pull_request:
branches: ['main']
jobs:
chromatic:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for Chromatic
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '{node_version}'
cache: '{package_manager}'
- name: Install dependencies
run: {install_command}
- name: Run Chromatic
uses: chromaui/action@latest
with:
projectToken: ${{{{ secrets.CHROMATIC_PROJECT_TOKEN }}}}
exitZeroOnChanges: true
onlyChanged: true
autoAcceptChanges: 'main' # Auto-accept on main branch
"""
return workflow
def generate_github_workflow_percy(project_info: Dict) -> str:
"""
Generate GitHub Actions workflow for Percy.
Args:
project_info: Project information
Returns:
YAML workflow content
"""
node_version = project_info.get('node_version', '20')
package_manager = project_info.get('package_manager', 'npm')
install_command = get_install_command(package_manager)
branches = project_info.get('branches', ['main', 'develop'])
workflow = f"""name: Visual Regression Tests
on:
push:
branches: {json.dumps(branches)}
pull_request:
branches: ['main']
jobs:
percy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '{node_version}'
cache: '{package_manager}'
- name: Install dependencies
run: {install_command}
- name: Build Storybook
run: npm run build-storybook
- name: Run Percy
run: npx percy storybook storybook-static
env:
PERCY_TOKEN: ${{{{ secrets.PERCY_TOKEN }}}}
"""
return workflow
def generate_github_workflow_backstop(project_info: Dict) -> str:
"""
Generate GitHub Actions workflow for BackstopJS.
Args:
project_info: Project information
Returns:
YAML workflow content
"""
node_version = project_info.get('node_version', '20')
package_manager = project_info.get('package_manager', 'npm')
install_command = get_install_command(package_manager)
branches = project_info.get('branches', ['main', 'develop'])
workflow = f"""name: Visual Regression Tests
on:
push:
branches: {json.dumps(branches)}
pull_request:
branches: ['main']
jobs:
backstop:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '{node_version}'
cache: '{package_manager}'
- name: Install dependencies
run: {install_command}
- name: Run BackstopJS
run: npm run backstop:test
- name: Upload test results
if: failure()
uses: actions/upload-artifact@v3
with:
name: backstop-results
path: backstop_data/
"""
return workflow
def generate_gitlab_ci_chromatic(project_info: Dict) -> str:
"""
Generate GitLab CI job for Chromatic.
Args:
project_info: Project information
Returns:
YAML job content
"""
node_version = project_info.get('node_version', '20')
install_command = get_install_command(project_info.get('package_manager', 'npm'))
job = f"""# Add to .gitlab-ci.yml
chromatic:
stage: test
image: node:{node_version}
cache:
paths:
- node_modules/
script:
- {install_command}
- npx chromatic --exit-zero-on-changes --only-changed
variables:
CHROMATIC_PROJECT_TOKEN: $CHROMATIC_PROJECT_TOKEN
only:
- main
- develop
- merge_requests
"""
return job
def generate_gitlab_ci_percy(project_info: Dict) -> str:
"""
Generate GitLab CI job for Percy.
Args:
project_info: Project information
Returns:
YAML job content
"""
node_version = project_info.get('node_version', '20')
install_command = get_install_command(project_info.get('package_manager', 'npm'))
job = f"""# Add to .gitlab-ci.yml
percy:
stage: test
image: node:{node_version}
cache:
paths:
- node_modules/
script:
- {install_command}
- npm run build-storybook
- npx percy storybook storybook-static
variables:
PERCY_TOKEN: $PERCY_TOKEN
only:
- main
- develop
- merge_requests
"""
return job
def generate_circleci_config_chromatic(project_info: Dict) -> str:
"""
Generate CircleCI job for Chromatic.
Args:
project_info: Project information
Returns:
YAML job content
"""
node_version = project_info.get('node_version', '20')
install_command = get_install_command(project_info.get('package_manager', 'npm'))
config = f"""# Add to .circleci/config.yml
version: 2.1
executors:
node:
docker:
- image: cimg/node:{node_version}
jobs:
chromatic:
executor: node
steps:
- checkout
- restore_cache:
keys:
- v1-dependencies-{{{{ checksum "package.json" }}}}
- run: {install_command}
- save_cache:
paths:
- node_modules
key: v1-dependencies-{{{{ checksum "package.json" }}}}
- run:
name: Run Chromatic
command: npx chromatic --exit-zero-on-changes --only-changed
environment:
CHROMATIC_PROJECT_TOKEN: $CHROMATIC_PROJECT_TOKEN
workflows:
version: 2
test:
jobs:
- chromatic
"""
return config
def generate_workflow(project_root: str, ci_platform: str, vr_tool: str) -> Dict:
"""
Generate CI/CD workflow for specified platform and VR tool.
Args:
project_root: Project root directory
ci_platform: CI platform ('github', 'gitlab', 'circleci')
vr_tool: VR tool ('chromatic', 'percy', 'backstopjs')
Returns:
Dict with workflow path and content
"""
# Gather project info
project_info = {
'node_version': detect_node_version(project_root),
'package_manager': detect_package_manager(project_root),
'branches': detect_branches(project_root)
}
result = {
'platform': ci_platform,
'vr_tool': vr_tool,
'workflow_path': None,
'workflow_content': None,
'instructions': None
}
# Generate workflow based on platform and tool
if ci_platform == 'github':
workflow_dir = Path(project_root) / '.github' / 'workflows'
workflow_file = 'chromatic.yml' if vr_tool == 'chromatic' else f'{vr_tool}.yml'
result['workflow_path'] = str(workflow_dir / workflow_file)
if vr_tool == 'chromatic':
result['workflow_content'] = generate_github_workflow_chromatic(project_info)
elif vr_tool == 'percy':
result['workflow_content'] = generate_github_workflow_percy(project_info)
elif vr_tool == 'backstopjs':
result['workflow_content'] = generate_github_workflow_backstop(project_info)
result['instructions'] = f"""
GitHub Actions workflow created: {result['workflow_path']}
Next steps:
1. Add secret: Repository Settings → Secrets → Actions
2. Create secret: CHROMATIC_PROJECT_TOKEN (or PERCY_TOKEN)
3. Commit and push this file
4. Workflow will run automatically on push/PR
"""
elif ci_platform == 'gitlab':
result['workflow_path'] = str(Path(project_root) / '.gitlab-ci.yml')
if vr_tool == 'chromatic':
result['workflow_content'] = generate_gitlab_ci_chromatic(project_info)
elif vr_tool == 'percy':
result['workflow_content'] = generate_gitlab_ci_percy(project_info)
result['instructions'] = """
GitLab CI job generated. Add to your .gitlab-ci.yml file.
Next steps:
1. Add variable: Project Settings → CI/CD → Variables
2. Create variable: CHROMATIC_PROJECT_TOKEN (or PERCY_TOKEN)
3. Commit and push .gitlab-ci.yml
4. Pipeline will run automatically
"""
elif ci_platform == 'circleci':
result['workflow_path'] = str(Path(project_root) / '.circleci' / 'config.yml')
if vr_tool == 'chromatic':
result['workflow_content'] = generate_circleci_config_chromatic(project_info)
result['instructions'] = """
CircleCI job generated. Add to your .circleci/config.yml file.
Next steps:
1. Add environment variable in CircleCI project settings
2. Variable name: CHROMATIC_PROJECT_TOKEN
3. Commit and push config.yml
4. Build will run automatically
"""
return result
def main():
"""CLI entry point."""
if len(sys.argv) < 4:
print("Usage: python ci_workflow_generator.py <project_root> <ci_platform> <vr_tool>", file=sys.stderr)
print(" ci_platform: github, gitlab, circleci", file=sys.stderr)
print(" vr_tool: chromatic, percy, backstopjs", file=sys.stderr)
sys.exit(1)
project_root = sys.argv[1]
ci_platform = sys.argv[2].lower()
vr_tool = sys.argv[3].lower()
if ci_platform not in ['github', 'gitlab', 'circleci']:
print(f"Unsupported CI platform: {ci_platform}", file=sys.stderr)
sys.exit(1)
if vr_tool not in ['chromatic', 'percy', 'backstopjs']:
print(f"Unsupported VR tool: {vr_tool}", file=sys.stderr)
sys.exit(1)
result = generate_workflow(project_root, ci_platform, vr_tool)
# Output as JSON
print(json.dumps(result, indent=2))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,476 @@
#!/usr/bin/env python3
"""
Storybook Story Generator
Analyzes React/Vue/Svelte components and generates comprehensive Storybook stories
with variants, accessibility tests, and interaction tests.
Usage:
python story_generator.py <component_path> <framework> [template_path]
"""
import json
import os
import re
import sys
from pathlib import Path
from typing import Dict, List, Optional
def extract_component_name(file_path: str) -> str:
"""Extract component name from file path."""
return Path(file_path).stem
def analyze_react_component(component_path: str) -> Dict:
"""
Analyze React/TypeScript component to extract props and metadata.
Args:
component_path: Path to component file
Returns:
Dict with component info: name, props, prop_types, exports
"""
with open(component_path, 'r') as f:
content = f.read()
component_name = extract_component_name(component_path)
result = {
'name': component_name,
'path': component_path,
'props': [],
'has_typescript': component_path.endswith(('.tsx', '.ts')),
'is_default_export': False,
'story_title': f'Components/{component_name}'
}
# Check for default export
if re.search(r'export\s+default\s+' + component_name, content):
result['is_default_export'] = True
# Extract TypeScript interface/type props
if result['has_typescript']:
# Match interface or type definition
interface_pattern = r'(?:interface|type)\s+' + component_name + r'Props\s*{([^}]+)}'
match = re.search(interface_pattern, content, re.DOTALL)
if match:
props_block = match.group(1)
# Parse each prop
prop_pattern = r'(\w+)(\?)?:\s*([^;]+);?'
for prop_match in re.finditer(prop_pattern, props_block):
prop_name = prop_match.group(1)
is_optional = prop_match.group(2) == '?'
prop_type = prop_match.group(3).strip()
# Determine control type based on prop type
control = infer_control_type(prop_type)
# Extract possible values for enums
values = extract_enum_values(prop_type)
result['props'].append({
'name': prop_name,
'type': prop_type,
'optional': is_optional,
'control': control,
'values': values,
'default': infer_default_value(prop_type, prop_name)
})
# Fallback: extract props from function signature
if not result['props']:
func_pattern = r'(?:function|const)\s+' + component_name + r'\s*(?:<[^>]+>)?\s*\(\s*{\s*([^}]+)\s*}'
match = re.search(func_pattern, content)
if match:
props_str = match.group(1)
# Simple extraction of prop names
prop_names = [p.strip().split(':')[0].strip() for p in props_str.split(',')]
for prop_name in prop_names:
result['props'].append({
'name': prop_name,
'type': 'any',
'optional': False,
'control': 'text',
'values': None,
'default': None
})
return result
def infer_control_type(prop_type: str) -> str:
"""
Infer Storybook control type from TypeScript type.
Args:
prop_type: TypeScript type string
Returns:
Storybook control type
"""
prop_type_lower = prop_type.lower()
# Boolean
if 'boolean' in prop_type_lower:
return 'boolean'
# Number
if 'number' in prop_type_lower:
return 'number'
# Union types (enums)
if '|' in prop_type:
return 'select'
# Objects
if prop_type_lower in ['object', 'record']:
return 'object'
# Arrays
if '[]' in prop_type or prop_type.startswith('array'):
return 'object'
# Functions
if '=>' in prop_type or prop_type.startswith('('):
return 'function'
# Default to text
return 'text'
def extract_enum_values(prop_type: str) -> Optional[List[str]]:
"""
Extract possible values from union type.
Args:
prop_type: TypeScript type string (e.g., "'sm' | 'md' | 'lg'")
Returns:
List of possible values or None
"""
if '|' not in prop_type:
return None
# Extract string literals
values = re.findall(r"['\"]([^'\"]+)['\"]", prop_type)
return values if values else None
def infer_default_value(prop_type: str, prop_name: str) -> any:
"""
Infer reasonable default value for prop.
Args:
prop_type: TypeScript type string
prop_name: Prop name
Returns:
Default value
"""
prop_type_lower = prop_type.lower()
prop_name_lower = prop_name.lower()
# Boolean
if 'boolean' in prop_type_lower:
return False
# Number
if 'number' in prop_type_lower:
if 'count' in prop_name_lower:
return 0
return 1
# Union types - return first value
values = extract_enum_values(prop_type)
if values:
return values[0]
# Strings - context-aware defaults
if 'name' in prop_name_lower:
return 'John Doe'
if 'title' in prop_name_lower:
return 'Example Title'
if 'description' in prop_name_lower or 'bio' in prop_name_lower:
return 'This is an example description'
if 'email' in prop_name_lower:
return 'user@example.com'
if 'url' in prop_name_lower or 'href' in prop_name_lower:
return 'https://example.com'
if 'image' in prop_name_lower or 'avatar' in prop_name_lower:
return 'https://via.placeholder.com/150'
return 'Example text'
def generate_variants(component_info: Dict) -> List[Dict]:
"""
Generate story variants based on component props.
Args:
component_info: Component analysis result
Returns:
List of variant definitions
"""
variants = []
# Generate variants for enum props
for prop in component_info['props']:
if prop['values'] and len(prop['values']) > 1:
# Create variant for each enum value
for value in prop['values']:
if value != prop['default']: # Skip default (already in Default story)
variant_name = value.capitalize()
variants.append({
'name': variant_name,
'prop_name': prop['name'],
'value': value
})
# Generate boolean state variants
for prop in component_info['props']:
if prop['type'].lower() == 'boolean' and not prop['default']:
variant_name = prop['name'].capitalize()
variants.append({
'name': variant_name,
'prop_name': prop['name'],
'value': True
})
return variants
def generate_story_content(component_info: Dict, framework: str = 'react') -> str:
"""
Generate complete Storybook story file content.
Args:
component_info: Component analysis result
framework: Framework name ('react', 'vue', 'svelte')
Returns:
Story file content as string
"""
if framework == 'react':
return generate_react_story(component_info)
elif framework == 'vue':
return generate_vue_story(component_info)
elif framework == 'svelte':
return generate_svelte_story(component_info)
else:
raise ValueError(f"Unsupported framework: {framework}")
def generate_react_story(component_info: Dict) -> str:
"""Generate React/TypeScript story."""
name = component_info['name']
props = component_info['props']
variants = generate_variants(component_info)
# Build imports
imports = f"""import type {{ Meta, StoryObj }} from '@storybook/react';
import {{ {name} }} from './{name}';
"""
# Build argTypes
arg_types = []
for prop in props:
if prop['values']:
arg_types.append(f" {prop['name']}: {{ control: '{prop['control']}', options: {json.dumps(prop['values'])} }}")
else:
arg_types.append(f" {prop['name']}: {{ control: '{prop['control']}' }}")
arg_types_str = ',\n'.join(arg_types) if arg_types else ''
# Build default args
default_args = []
for prop in props:
if prop['default'] is not None:
if isinstance(prop['default'], str):
default_args.append(f" {prop['name']}: '{prop['default']}'")
else:
default_args.append(f" {prop['name']}: {json.dumps(prop['default'])}")
default_args_str = ',\n'.join(default_args) if default_args else ''
# Build meta
meta = f"""
const meta = {{
title: '{component_info['story_title']}',
component: {name},
parameters: {{
layout: 'centered',
}},
tags: ['autodocs'],
argTypes: {{
{arg_types_str}
}},
}} satisfies Meta<typeof {name}>;
export default meta;
type Story = StoryObj<typeof meta>;
"""
# Default story
default_story = f"""
export const Default: Story = {{
args: {{
{default_args_str}
}},
}};
"""
# Variant stories
variant_stories = []
for variant in variants:
if isinstance(variant['value'], str):
value_str = f"'{variant['value']}'"
else:
value_str = json.dumps(variant['value'])
variant_stories.append(f"""
export const {variant['name']}: Story = {{
args: {{
...Default.args,
{variant['prop_name']}: {value_str},
}},
}};
""")
variant_stories_str = ''.join(variant_stories)
# Accessibility tests
a11y = f"""
// Accessibility tests
Default.parameters = {{
a11y: {{
config: {{
rules: [
{{ id: 'color-contrast', enabled: true }},
{{ id: 'label', enabled: true }},
],
}},
}},
}};
"""
return imports + meta + default_story + variant_stories_str + a11y
def generate_vue_story(component_info: Dict) -> str:
"""Generate Vue story (simplified)."""
name = component_info['name']
return f"""import type {{ Meta, StoryObj }} from '@storybook/vue3';
import {name} from './{name}.vue';
const meta = {{
title: 'Components/{name}',
component: {name},
tags: ['autodocs'],
}} satisfies Meta<typeof {name}>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {{
args: {{}},
}};
"""
def generate_svelte_story(component_info: Dict) -> str:
"""Generate Svelte story (simplified)."""
name = component_info['name']
return f"""import type {{ Meta, StoryObj }} from '@storybook/svelte';
import {name} from './{name}.svelte';
const meta = {{
title: 'Components/{name}',
component: {name},
tags: ['autodocs'],
}} satisfies Meta<typeof {name}>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {{
args: {{}},
}};
"""
def write_story_file(component_path: str, story_content: str) -> str:
"""
Write story file next to component file.
Args:
component_path: Path to component file
story_content: Generated story content
Returns:
Path to created story file
"""
component_file = Path(component_path)
story_file = component_file.parent / f"{component_file.stem}.stories{component_file.suffix}"
with open(story_file, 'w') as f:
f.write(story_content)
return str(story_file)
def main():
"""CLI entry point."""
if len(sys.argv) < 3:
print("Usage: python story_generator.py <component_path> <framework>", file=sys.stderr)
sys.exit(1)
component_path = sys.argv[1]
framework = sys.argv[2].lower()
if framework not in ['react', 'vue', 'svelte']:
print(f"Unsupported framework: {framework}. Use: react, vue, or svelte", file=sys.stderr)
sys.exit(1)
if not os.path.exists(component_path):
print(f"Component file not found: {component_path}", file=sys.stderr)
sys.exit(1)
# Analyze component
if framework == 'react':
component_info = analyze_react_component(component_path)
else:
# Simplified for Vue/Svelte
component_info = {
'name': extract_component_name(component_path),
'path': component_path,
'props': [],
'story_title': f'Components/{extract_component_name(component_path)}'
}
# Generate story
story_content = generate_story_content(component_info, framework)
# Write story file
story_file_path = write_story_file(component_path, story_content)
# Output result
result = {
'component': component_info,
'story_file': story_file_path,
'success': True
}
print(json.dumps(result, indent=2))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,409 @@
#!/usr/bin/env python3
"""
Visual Regression Setup Validator
Detects existing Storybook setup, VR tools, CI platform, and validates component paths.
Returns comprehensive validation report to guide skill execution.
Usage:
python vr_setup_validator.py <project_root> [component_path]
"""
import json
import os
import sys
from pathlib import Path
from typing import Dict, List, Optional
def detect_framework(project_root: str) -> Optional[str]:
"""
Detect frontend framework from package.json dependencies.
Args:
project_root: Path to project root directory
Returns:
Framework name ('react', 'vue', 'svelte') or None
"""
package_json_path = Path(project_root) / 'package.json'
if not package_json_path.exists():
return None
try:
with open(package_json_path, 'r') as f:
package_data = json.load(f)
dependencies = {
**package_data.get('dependencies', {}),
**package_data.get('devDependencies', {})
}
if 'react' in dependencies:
return 'react'
elif 'vue' in dependencies:
return 'vue'
elif 'svelte' in dependencies:
return 'svelte'
return None
except (json.JSONDecodeError, FileNotFoundError):
return None
def detect_storybook_config(project_root: str) -> Dict:
"""
Detect Storybook version and configuration.
Args:
project_root: Path to project root directory
Returns:
Dict with version, addons, framework, and config path
"""
storybook_dir = Path(project_root) / '.storybook'
package_json_path = Path(project_root) / 'package.json'
result = {
'installed': False,
'version': None,
'config_path': None,
'main_js_path': None,
'addons': [],
'framework': None
}
# Check if .storybook directory exists
if not storybook_dir.exists():
return result
result['installed'] = True
result['config_path'] = str(storybook_dir)
# Check for main.js or main.ts
main_js = storybook_dir / 'main.js'
main_ts = storybook_dir / 'main.ts'
if main_js.exists():
result['main_js_path'] = str(main_js)
elif main_ts.exists():
result['main_js_path'] = str(main_ts)
# Extract version from package.json
if package_json_path.exists():
try:
with open(package_json_path, 'r') as f:
package_data = json.load(f)
dependencies = {
**package_data.get('dependencies', {}),
**package_data.get('devDependencies', {})
}
# Find Storybook version
for dep in dependencies:
if dep.startswith('@storybook/'):
result['version'] = dependencies[dep].replace('^', '').replace('~', '')
break
# Extract addons from dependencies
result['addons'] = [
dep for dep in dependencies.keys()
if dep.startswith('@storybook/addon-') or dep == '@chromatic-com/storybook'
]
except (json.JSONDecodeError, FileNotFoundError):
pass
# Try to parse main.js for framework
if result['main_js_path']:
try:
with open(result['main_js_path'], 'r') as f:
content = f.read()
if '@storybook/react' in content:
result['framework'] = 'react'
elif '@storybook/vue' in content:
result['framework'] = 'vue'
elif '@storybook/svelte' in content:
result['framework'] = 'svelte'
except FileNotFoundError:
pass
return result
def detect_vr_tool(project_root: str) -> Optional[str]:
"""
Detect existing visual regression tool from package.json.
Args:
project_root: Path to project root directory
Returns:
Tool name ('chromatic', 'percy', 'backstopjs') or None
"""
package_json_path = Path(project_root) / 'package.json'
if not package_json_path.exists():
return None
try:
with open(package_json_path, 'r') as f:
package_data = json.load(f)
dependencies = {
**package_data.get('dependencies', {}),
**package_data.get('devDependencies', {})
}
if 'chromatic' in dependencies or '@chromatic-com/storybook' in dependencies:
return 'chromatic'
elif '@percy/cli' in dependencies or '@percy/storybook' in dependencies:
return 'percy'
elif 'backstopjs' in dependencies:
return 'backstopjs'
return None
except (json.JSONDecodeError, FileNotFoundError):
return None
def detect_ci_platform(project_root: str) -> Optional[str]:
"""
Detect CI/CD platform from existing configuration files.
Args:
project_root: Path to project root directory
Returns:
Platform name ('github', 'gitlab', 'circleci', 'bitbucket') or None
"""
root = Path(project_root)
# GitHub Actions
if (root / '.github' / 'workflows').exists():
return 'github'
# GitLab CI
if (root / '.gitlab-ci.yml').exists():
return 'gitlab'
# CircleCI
if (root / '.circleci' / 'config.yml').exists():
return 'circleci'
# Bitbucket Pipelines
if (root / 'bitbucket-pipelines.yml').exists():
return 'bitbucket'
return None
def validate_component_path(component_path: str, project_root: str = '.') -> Dict:
"""
Validate component file exists and extract basic information.
Args:
component_path: Path to component file (relative or absolute)
project_root: Project root directory
Returns:
Dict with validation status and component info
"""
# Handle relative paths
if not os.path.isabs(component_path):
component_path = os.path.join(project_root, component_path)
component_file = Path(component_path)
result = {
'valid': False,
'path': component_path,
'name': None,
'extension': None,
'directory': None,
'error': None
}
# Check if file exists
if not component_file.exists():
result['error'] = f"Component file not found: {component_path}"
return result
# Check if it's a file (not directory)
if not component_file.is_file():
result['error'] = f"Path is not a file: {component_path}"
return result
# Validate extension
valid_extensions = ['.tsx', '.ts', '.jsx', '.js', '.vue', '.svelte']
if component_file.suffix not in valid_extensions:
result['error'] = f"Invalid file extension. Expected one of: {', '.join(valid_extensions)}"
return result
# Extract component name (filename without extension)
result['name'] = component_file.stem
result['extension'] = component_file.suffix
result['directory'] = str(component_file.parent)
result['valid'] = True
return result
def check_dependencies(project_root: str, vr_tool: Optional[str] = 'chromatic') -> Dict:
"""
Check which required dependencies are installed.
Args:
project_root: Path to project root directory
vr_tool: VR tool to check for ('chromatic', 'percy', 'backstopjs')
Returns:
Dict with installed and missing dependencies
"""
package_json_path = Path(project_root) / 'package.json'
result = {
'installed': [],
'missing': []
}
if not package_json_path.exists():
result['missing'] = ['package.json not found']
return result
try:
with open(package_json_path, 'r') as f:
package_data = json.load(f)
dependencies = {
**package_data.get('dependencies', {}),
**package_data.get('devDependencies', {})
}
# Core Storybook dependencies
required_deps = [
'@storybook/addon-essentials',
'@storybook/addon-interactions',
]
# Add VR tool specific dependencies
if vr_tool == 'chromatic':
required_deps.extend(['chromatic', '@chromatic-com/storybook'])
elif vr_tool == 'percy':
required_deps.extend(['@percy/cli', '@percy/storybook'])
elif vr_tool == 'backstopjs':
required_deps.append('backstopjs')
# Check each dependency
for dep in required_deps:
if dep in dependencies:
result['installed'].append(dep)
else:
result['missing'].append(dep)
except (json.JSONDecodeError, FileNotFoundError):
result['missing'] = ['Error reading package.json']
return result
def get_package_manager(project_root: str) -> str:
"""
Detect package manager from lock files.
Args:
project_root: Path to project root directory
Returns:
Package manager name ('npm', 'yarn', 'pnpm')
"""
root = Path(project_root)
if (root / 'pnpm-lock.yaml').exists():
return 'pnpm'
elif (root / 'yarn.lock').exists():
return 'yarn'
else:
return 'npm' # Default to npm
def validate_setup(project_root: str, component_path: Optional[str] = None) -> Dict:
"""
Comprehensive validation of VR setup requirements.
Args:
project_root: Path to project root directory
component_path: Optional path to component file
Returns:
Complete validation report
"""
report = {
'project_root': project_root,
'framework': detect_framework(project_root),
'storybook': detect_storybook_config(project_root),
'vr_tool': detect_vr_tool(project_root),
'ci_platform': detect_ci_platform(project_root),
'package_manager': get_package_manager(project_root),
'component': None,
'dependencies': None,
'ready': False,
'warnings': [],
'errors': []
}
# Validate component if path provided
if component_path:
report['component'] = validate_component_path(component_path, project_root)
if not report['component']['valid']:
report['errors'].append(report['component']['error'])
# Check framework
if not report['framework']:
report['errors'].append('Framework not detected. Ensure React, Vue, or Svelte is installed.')
# Check Storybook
if not report['storybook']['installed']:
report['errors'].append('Storybook not installed. Run: npx storybook init')
# Determine VR tool (use detected or default to Chromatic)
vr_tool = report['vr_tool'] or 'chromatic'
report['dependencies'] = check_dependencies(project_root, vr_tool)
# Add warnings for missing dependencies
if report['dependencies']['missing']:
report['warnings'].append(
f"Missing dependencies: {', '.join(report['dependencies']['missing'])}"
)
# Determine if ready to proceed
report['ready'] = (
report['framework'] is not None and
report['storybook']['installed'] and
len(report['errors']) == 0
)
return report
def main():
"""CLI entry point."""
if len(sys.argv) < 2:
print("Usage: python vr_setup_validator.py <project_root> [component_path]", file=sys.stderr)
sys.exit(1)
project_root = sys.argv[1]
component_path = sys.argv[2] if len(sys.argv) > 2 else None
report = validate_setup(project_root, component_path)
# Output as JSON
print(json.dumps(report, indent=2))
# Exit with error code if not ready
sys.exit(0 if report['ready'] else 1)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,10 @@
{
"projectId": "{{ project_id | default('<PROJECT_ID_PLACEHOLDER>') }}",
"buildScriptName": "build-storybook",
"exitZeroOnChanges": true,
"exitOnceUploaded": true,
"onlyChanged": true,
"externals": ["public/**"],
"skip": "{{ skip_pattern | default('dependabot/**') }}",
"ignoreLastBuildOnBranch": "{{ main_branch | default('main') }}"
}

View File

@@ -0,0 +1,80 @@
name: Visual Regression Tests
on:
push:
branches: {{ branches | default(['main', 'develop']) | tojson }}
pull_request:
branches: ['main']
jobs:
{% if vr_tool == 'chromatic' -%}
chromatic:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for Chromatic
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '{{ node_version | default('20') }}'
cache: '{{ package_manager | default('npm') }}'
- name: Install dependencies
run: {{ install_command | default('npm ci') }}
- name: Run Chromatic
uses: chromaui/action@latest
with:
projectToken: {% raw %}${{ secrets.CHROMATIC_PROJECT_TOKEN }}{% endraw %}
exitZeroOnChanges: true
onlyChanged: true
autoAcceptChanges: 'main'
{% elif vr_tool == 'percy' -%}
percy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '{{ node_version | default('20') }}'
cache: '{{ package_manager | default('npm') }}'
- name: Install dependencies
run: {{ install_command | default('npm ci') }}
- name: Build Storybook
run: npm run build-storybook
- name: Run Percy
run: npx percy storybook storybook-static
env:
PERCY_TOKEN: {% raw %}${{ secrets.PERCY_TOKEN }}{% endraw %}
{% elif vr_tool == 'backstopjs' -%}
backstop:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '{{ node_version | default('20') }}'
cache: '{{ package_manager | default('npm') }}'
- name: Install dependencies
run: {{ install_command | default('npm ci') }}
- name: Run BackstopJS
run: npm run backstop:test
- name: Upload test results
if: failure()
uses: actions/upload-artifact@v3
with:
name: backstop-results
path: backstop_data/
{% endif -%}

View File

@@ -0,0 +1,54 @@
# Add to .gitlab-ci.yml
{% if vr_tool == 'chromatic' -%}
chromatic:
stage: test
image: node:{{ node_version | default('20') }}
cache:
paths:
- node_modules/
script:
- {{ install_command | default('npm ci') }}
- npx chromatic --exit-zero-on-changes --only-changed
variables:
CHROMATIC_PROJECT_TOKEN: $CHROMATIC_PROJECT_TOKEN
only:
- main
- develop
- merge_requests
{% elif vr_tool == 'percy' -%}
percy:
stage: test
image: node:{{ node_version | default('20') }}
cache:
paths:
- node_modules/
script:
- {{ install_command | default('npm ci') }}
- npm run build-storybook
- npx percy storybook storybook-static
variables:
PERCY_TOKEN: $PERCY_TOKEN
only:
- main
- develop
- merge_requests
{% elif vr_tool == 'backstopjs' -%}
backstop:
stage: test
image: node:{{ node_version | default('20') }}
cache:
paths:
- node_modules/
script:
- {{ install_command | default('npm ci') }}
- npm run backstop:test
artifacts:
when: on_failure
paths:
- backstop_data/
only:
- main
- develop
- merge_requests
{% endif -%}

View File

@@ -0,0 +1,60 @@
import type { Meta, StoryObj } from '@storybook/react';
import { {{ component_name }} } from './{{ component_name }}';
const meta = {
title: '{{ story_title }}',
component: {{ component_name }},
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
{% for prop in props -%}
{% if prop.values -%}
{{ prop.name }}: { control: '{{ prop.control }}', options: {{ prop.values | tojson }} },
{% else -%}
{{ prop.name }}: { control: '{{ prop.control }}' },
{% endif -%}
{% endfor %}
},
} satisfies Meta<typeof {{ component_name }}>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
{% for prop in props -%}
{% if prop.default is string -%}
{{ prop.name }}: '{{ prop.default }}',
{% elif prop.default is not none -%}
{{ prop.name }}: {{ prop.default | tojson }},
{% endif -%}
{% endfor %}
},
};
{% for variant in variants %}
export const {{ variant.name }}: Story = {
args: {
...Default.args,
{% if variant.value is string -%}
{{ variant.prop_name }}: '{{ variant.value }}',
{% else -%}
{{ variant.prop_name }}: {{ variant.value | tojson }},
{% endif -%}
},
};
{% endfor %}
// Accessibility tests
Default.parameters = {
a11y: {
config: {
rules: [
{ id: 'color-contrast', enabled: true },
{ id: 'label', enabled: true },
],
},
},
};

View File

@@ -0,0 +1,17 @@
module.exports = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
{% if vr_tool == 'chromatic' -%}
'@chromatic-com/storybook',
{% elif vr_tool == 'percy' -%}
'@percy/storybook',
{% endif -%}
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/{{ framework | default('react') }}-vite',
options: {},
},
};