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,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()