Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 38 additions & 6 deletions clients/storybook/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,27 +205,59 @@ Examples:
## Visual Development Workflow

This plugin integrates Storybook into Vizzly's visual development workflow, enabling both local TDD
iteration and seamless team collaboration:
iteration and seamless team collaboration. The plugin **automatically detects** which mode to use:

### TDD Mode (Local Development)

When a TDD server is running, screenshots are compared locally for fast iteration:

```bash
# Start TDD server
vizzly tdd start

# Capture Storybook screenshots
# Capture Storybook screenshots (automatically uses TDD mode)
vizzly storybook ./storybook-static

# View results at http://localhost:47392
# View live results at http://localhost:47392
```

**Output:**
```
ℹ 📍 TDD mode: Using local server
ℹ 📚 Found 5 stories in ./storybook-static
ℹ ✓ Components/Button/Primary@default
ℹ ✅ Captured 5 screenshots successfully
```

### Run Mode (CI/CD & Cloud)

### Run Mode (CI/CD)
When a `VIZZLY_TOKEN` is set, screenshots are uploaded to the cloud for team review:

```bash
# Capture and upload to Vizzly cloud
VIZZLY_TOKEN=your-token vizzly run "vizzly storybook ./storybook-static"
# Capture and upload to Vizzly cloud (automatically uses Run mode)
VIZZLY_TOKEN=your-token vizzly storybook ./storybook-static
```

**Output:**
```
ℹ ☁️ Run mode: Uploading to cloud
ℹ 🔗 https://app.vizzly.dev/your-org/project/builds/...
ℹ 📚 Found 5 stories in ./storybook-static
ℹ ✓ Components/Button/Primary@default
ℹ ✅ Captured 5 screenshots successfully
ℹ 🔗 View results: https://app.vizzly.dev/your-org/project/builds/...
```

### Mode Detection

The plugin automatically chooses the mode:

1. **TDD mode** - If a TDD server is running (`.vizzly/server.json` found)
2. **Run mode** - If `VIZZLY_TOKEN` environment variable is set
3. **Warning** - If neither is available, warns and skips screenshots

No need to wrap with `vizzly run` - the plugin handles everything!

## Supported Storybook Versions

- Storybook v6.x
Expand Down
223 changes: 199 additions & 24 deletions clients/storybook/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,6 @@ async function processStory(story, browser, baseUrl, config, context) {
let hook = getBeforeScreenshotHook(story, config);
let errors = [];

logger?.info?.(`Processing story: ${story.title}/${story.name}`);
logger?.info?.(` Story URL: ${storyUrl}`);

// Process each viewport for this story
for (let viewport of storyConfig.viewports) {
let page = null;
Expand All @@ -47,12 +44,10 @@ async function processStory(story, browser, baseUrl, config, context) {
storyConfig.screenshot
);

logger?.info?.(
` ✓ Captured ${story.title}/${story.name}@${viewport.name}`
);
logger.info(` ✓ ${story.title}/${story.name}@${viewport.name}`);
} catch (error) {
logger?.error?.(
` ✗ Failed to capture ${story.title}/${story.name}@${viewport.name}: ${error.message}`
logger.error(
` ${story.title}/${story.name}@${viewport.name}: ${error.message}`
);
errors.push({
story: `${story.title}/${story.name}`,
Expand Down Expand Up @@ -125,6 +120,54 @@ async function processStories(stories, browser, baseUrl, config, context) {
return allErrors;
}

/**
* Check if TDD mode is available
* @returns {Promise<boolean>} True if TDD server is running
*/
async function isTddModeAvailable() {
let { existsSync, readFileSync } = await import('fs');
let { join, parse, dirname } = await import('path');

try {
// Look for .vizzly/server.json
let currentDir = process.cwd();
let root = parse(currentDir).root;

while (currentDir !== root) {
let serverJsonPath = join(currentDir, '.vizzly', 'server.json');

if (existsSync(serverJsonPath)) {
try {
let serverInfo = JSON.parse(readFileSync(serverJsonPath, 'utf8'));
if (serverInfo.port) {
// Try to ping the server
let response = await fetch(
`http://localhost:${serverInfo.port}/health`
);
return response.ok;
}
} catch {
// Invalid JSON or server not responding
}
}
currentDir = dirname(currentDir);
}
} catch {
// Error checking for TDD mode
}

return false;
}

/**
* Check if API token is available for run mode
* @param {Object} config - Vizzly configuration
* @returns {boolean} True if API token exists
*/
function hasApiToken(config) {
return !!(config?.apiKey || process.env.VIZZLY_TOKEN);
}

/**
* Main run function - orchestrates the entire screenshot capture process
* @param {string} storybookPath - Path to static Storybook build
Expand All @@ -133,38 +176,144 @@ async function processStories(stories, browser, baseUrl, config, context) {
* @returns {Promise<void>}
*/
export async function run(storybookPath, options = {}, context = {}) {
let { logger } = context;
let { logger, config: vizzlyConfig, services } = context;
let browser = null;
let serverInfo = null;
let testRunner = null;
let serverManager = null;
let buildId = null;
let startTime = null;

if (!logger) {
throw new Error('Logger is required but was not provided in context');
}

try {
// Load and merge configuration
let config = await loadConfig(storybookPath, options);

logger?.info?.('Starting Storybook screenshot capture...');
logger?.info?.(`Storybook path: ${config.storybookPath}`);
// Determine mode: TDD or Run
let isTdd = await isTddModeAvailable();
let hasToken = hasApiToken(vizzlyConfig);

if (isTdd) {
logger.info('📍 TDD mode: Using local server');
} else if (hasToken) {
logger.info('☁️ Run mode: Uploading to cloud');
}

let buildUrl = null;

if (!isTdd && hasToken && services) {
// Run mode: Initialize test runner for build management
try {
testRunner = await services.get('testRunner');
serverManager = await services.get('serverManager');
startTime = Date.now();

// Listen for build-created event to get the URL
testRunner.once('build-created', buildInfo => {
if (buildInfo.url) {
buildUrl = buildInfo.url;
logger.info(`🔗 ${buildInfo.url}`);
}
});

// Detect git info - use dynamic import to access internal utils
let gitUtils;
try {
// Try to import from the installed CLI package
let cliPath = await import.meta.resolve?.('@vizzly-testing/cli');
if (cliPath) {
gitUtils = await import(
'@vizzly-testing/cli/dist/utils/git.js'
).catch(() => null);
}
} catch {
// Fallback: try relative path if in monorepo
try {
gitUtils = await import('../../../src/utils/git.js').catch(
() => null
);
} catch {
gitUtils = null;
}
}

let branch = gitUtils
? await gitUtils.detectBranch()
: process.env.VIZZLY_BRANCH || 'main';
let commit = gitUtils
? await gitUtils.detectCommit()
: process.env.VIZZLY_COMMIT_SHA || undefined;
let message = gitUtils
? await gitUtils.detectCommitMessage()
: process.env.VIZZLY_COMMIT_MESSAGE || undefined;
let buildName = gitUtils
? await gitUtils.generateBuildNameWithGit('Storybook')
: `Storybook ${new Date().toISOString()}`;
let pullRequestNumber = gitUtils
? gitUtils.detectPullRequestNumber()
: process.env.VIZZLY_PR_NUMBER || undefined;

// Build options for API
let runOptions = {
port: vizzlyConfig?.server?.port || 47392,
timeout: vizzlyConfig?.server?.timeout || 30000,
buildName,
branch,
commit,
message,
environment: vizzlyConfig?.build?.environment,
threshold: vizzlyConfig?.comparison?.threshold || 0,
eager: vizzlyConfig?.eager || false,
allowNoToken: false,
wait: false,
uploadAll: false,
pullRequestNumber,
parallelId: vizzlyConfig?.parallelId,
};

// Create build via API
buildId = await testRunner.createBuild(runOptions, false);

// Start screenshot server
await serverManager.start(buildId, false, false);

// Set environment for client SDK to connect
process.env.VIZZLY_SERVER_URL = `http://localhost:${runOptions.port}`;
process.env.VIZZLY_BUILD_ID = buildId;
process.env.VIZZLY_ENABLED = 'true';
} catch (error) {
// Log the error and continue without cloud mode
logger.error(`Failed to initialize cloud mode: ${error.message}`);
testRunner = null;
}
}

if (!isTdd && !hasToken) {
logger.warn('⚠️ No TDD server or API token found');
logger.info(' Run `vizzly tdd start` or set VIZZLY_TOKEN');
}

// Start HTTP server to serve Storybook static files
logger?.info?.('Starting static file server...');
serverInfo = await startStaticServer(config.storybookPath);
logger?.info?.(`Server running at ${serverInfo.url}`);

// Discover stories
logger?.info?.('Discovering stories...');
let stories = await discoverStories(config.storybookPath, config);
logger?.info?.(`Found ${stories.length} stories`);
logger.info(
`📚 Found ${stories.length} stories in ${config.storybookPath}`
);

if (stories.length === 0) {
logger?.warn?.('No stories found. Exiting.');
logger.warn('⚠️ No stories found');
return;
}

// Launch browser
logger?.info?.('Launching browser...');
browser = await launchBrowser(config.browser);

// Process all stories
logger?.info?.('Processing stories...');
let errors = await processStories(
stories,
browser,
Expand All @@ -175,16 +324,36 @@ export async function run(storybookPath, options = {}, context = {}) {

// Report summary
if (errors.length > 0) {
logger?.warn?.(`\n⚠️ ${errors.length} screenshot(s) failed`);
logger?.error?.('Failed screenshots:');
logger.warn(`\n⚠️ ${errors.length} screenshot(s) failed:`);
errors.forEach(({ story, viewport, error }) => {
logger?.error?.(` - ${story}@${viewport}: ${error}`);
logger.error(` ${story}@${viewport}: ${error}`);
});
} else {
logger?.info?.('✓ All stories processed successfully');
logger.info(`\n✅ Captured ${stories.length} screenshots successfully`);
}

// Finalize build in run mode
if (testRunner && buildId) {
let executionTime = Date.now() - startTime;
await testRunner.finalizeBuild(buildId, false, true, executionTime);

if (buildUrl) {
logger.info(`🔗 View results: ${buildUrl}`);
}
}
} catch (error) {
logger?.error?.('Failed to process stories:', error.message);
logger.error('Failed to process stories:', error.message);

// Mark build as failed if in run mode
if (testRunner && buildId) {
try {
let executionTime = startTime ? Date.now() - startTime : 0;
await testRunner.finalizeBuild(buildId, false, false, executionTime);
} catch {
// Ignore finalization errors
}
}

throw error;
} finally {
// Cleanup
Expand All @@ -193,7 +362,13 @@ export async function run(storybookPath, options = {}, context = {}) {
}
if (serverInfo) {
await stopStaticServer(serverInfo);
logger?.info?.('Static file server stopped');
}
if (serverManager) {
try {
await serverManager.stop();
} catch {
// Ignore stop errors
}
}
}
}
Expand Down
11 changes: 9 additions & 2 deletions clients/storybook/src/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export default {
* @param {Object} context.services - Service container
*/
register(program, { config, logger, services }) {
// Override logger level to 'info' for storybook command
// The CLI logger defaults to 'warn' but storybook needs 'info' for progress
logger.level = 'info';

program
.command('storybook <path>')
.description('Capture screenshots from static Storybook build')
Expand Down Expand Up @@ -52,8 +56,11 @@ export default {
services,
});
} catch (error) {
logger?.error?.('Failed to run Storybook plugin:', error.message);
throw error;
console.error('Failed to run Storybook plugin:', error);
if (logger && logger.error) {
logger.error('Failed to run Storybook plugin:', error.message);
}
process.exit(1);
}
});
},
Expand Down
Loading