Initial commit
This commit is contained in:
363
skills/visual-regression/functions/chromatic_config_generator.py
Normal file
363
skills/visual-regression/functions/chromatic_config_generator.py
Normal 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()
|
||||
490
skills/visual-regression/functions/ci_workflow_generator.py
Normal file
490
skills/visual-regression/functions/ci_workflow_generator.py
Normal 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()
|
||||
476
skills/visual-regression/functions/story_generator.py
Normal file
476
skills/visual-regression/functions/story_generator.py
Normal 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()
|
||||
409
skills/visual-regression/functions/vr_setup_validator.py
Normal file
409
skills/visual-regression/functions/vr_setup_validator.py
Normal 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()
|
||||
Reference in New Issue
Block a user