diff --git a/clients/storybook/README.md b/clients/storybook/README.md index ce56278c..b03b1257 100644 --- a/clients/storybook/README.md +++ b/clients/storybook/README.md @@ -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 diff --git a/clients/storybook/src/index.js b/clients/storybook/src/index.js index a4c73867..bb5698a6 100644 --- a/clients/storybook/src/index.js +++ b/clients/storybook/src/index.js @@ -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; @@ -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}`, @@ -125,6 +120,54 @@ async function processStories(stories, browser, baseUrl, config, context) { return allErrors; } +/** + * Check if TDD mode is available + * @returns {Promise} 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 @@ -133,38 +176,144 @@ async function processStories(stories, browser, baseUrl, config, context) { * @returns {Promise} */ 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, @@ -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 @@ -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 + } } } } diff --git a/clients/storybook/src/plugin.js b/clients/storybook/src/plugin.js index 7ce91186..fb29488e 100644 --- a/clients/storybook/src/plugin.js +++ b/clients/storybook/src/plugin.js @@ -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 ') .description('Capture screenshots from static Storybook build') @@ -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); } }); }, diff --git a/clients/storybook/tests/concurrency.spec.js b/clients/storybook/tests/concurrency.spec.js new file mode 100644 index 00000000..be2e4744 --- /dev/null +++ b/clients/storybook/tests/concurrency.spec.js @@ -0,0 +1,88 @@ +/** + * Tests for concurrency control + */ + +import { describe, it, expect } from 'vitest'; + +// Simple concurrency control - process items with limited parallelism +async function mapWithConcurrency(items, fn, concurrency) { + let results = []; + let executing = []; + + for (let item of items) { + let promise = fn(item).then(result => { + executing.splice(executing.indexOf(promise), 1); + return result; + }); + + results.push(promise); + executing.push(promise); + + if (executing.length >= concurrency) { + await Promise.race(executing); + } + } + + await Promise.all(results); +} + +describe('mapWithConcurrency', () => { + it('should process all items', async () => { + let items = [1, 2, 3, 4, 5]; + let processed = []; + + await mapWithConcurrency( + items, + async item => { + processed.push(item); + }, + 2 + ); + + expect(processed).toHaveLength(5); + expect(processed.sort()).toEqual([1, 2, 3, 4, 5]); + }); + + it('should respect concurrency limit', async () => { + let items = [1, 2, 3, 4, 5]; + let activeCount = 0; + let maxConcurrent = 0; + + await mapWithConcurrency( + items, + async _item => { + activeCount++; + maxConcurrent = Math.max(maxConcurrent, activeCount); + await new Promise(resolve => setTimeout(resolve, 10)); + activeCount--; + }, + 2 + ); + + expect(maxConcurrent).toBeLessThanOrEqual(2); + }); + + it('should handle async function results', async () => { + let items = [1, 2, 3]; + + await mapWithConcurrency(items, async item => item * 2, 2); + + // Should complete without error + expect(true).toBe(true); + }); + + it('should handle errors in processing', async () => { + let items = [1, 2, 3]; + + await expect( + mapWithConcurrency( + items, + async item => { + if (item === 2) throw new Error('Test error'); + return item; + }, + 2 + ) + ).rejects.toThrow('Test error'); + }); +}); diff --git a/clients/storybook/tests/fixtures/sample-storybook/index.json b/clients/storybook/tests/fixtures/sample-storybook/index.json index b0f20957..a0aeedc1 100644 --- a/clients/storybook/tests/fixtures/sample-storybook/index.json +++ b/clients/storybook/tests/fixtures/sample-storybook/index.json @@ -26,9 +26,7 @@ "importPath": "./src/Card.stories.js", "parameters": { "vizzly": { - "viewports": [ - { "name": "mobile", "width": 375, "height": 667 } - ] + "viewports": [{ "name": "mobile", "width": 375, "height": 667 }] } } }, diff --git a/clients/storybook/tests/hooks.spec.js b/clients/storybook/tests/hooks.spec.js new file mode 100644 index 00000000..12dde7bb --- /dev/null +++ b/clients/storybook/tests/hooks.spec.js @@ -0,0 +1,173 @@ +/** + * Tests for interaction hooks + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + getBeforeScreenshotHook, + applyHook, + getStoryConfig, +} from '../src/hooks.js'; + +describe('getBeforeScreenshotHook', () => { + it('should return story-level hook if present', () => { + let storyHook = vi.fn(); + let story = { + parameters: { + vizzly: { + beforeScreenshot: storyHook, + }, + }, + }; + let globalConfig = { + interactions: { + 'Button/*': vi.fn(), + }, + }; + + let hook = getBeforeScreenshotHook(story, globalConfig); + + expect(hook).toBe(storyHook); + }); + + it('should return global pattern hook if no story hook', () => { + let globalHook = vi.fn(); + let story = { + id: 'button--primary', + title: 'Button', + name: 'Primary', + }; + let globalConfig = { + interactions: { + 'button*': globalHook, + }, + }; + + let hook = getBeforeScreenshotHook(story, globalConfig); + + expect(hook).toBe(globalHook); + }); + + it('should return null if no hooks match', () => { + let story = { + id: 'button--primary', + title: 'Button', + name: 'Primary', + }; + let globalConfig = { + interactions: { + 'Card/*': vi.fn(), + }, + }; + + let hook = getBeforeScreenshotHook(story, globalConfig); + + expect(hook).toBeNull(); + }); + + it('should prioritize story hook over global hook', () => { + let storyHook = vi.fn(); + let globalHook = vi.fn(); + let story = { + id: 'button--primary', + title: 'Button', + name: 'Primary', + parameters: { + vizzly: { + beforeScreenshot: storyHook, + }, + }, + }; + let globalConfig = { + interactions: { + 'button*': globalHook, + }, + }; + + let hook = getBeforeScreenshotHook(story, globalConfig); + + expect(hook).toBe(storyHook); + expect(hook).not.toBe(globalHook); + }); +}); + +describe('applyHook', () => { + it('should execute hook with page', async () => { + let mockPage = { hover: vi.fn() }; + let hook = vi.fn(async page => { + await page.hover('.button'); + }); + + await applyHook(mockPage, hook); + + expect(hook).toHaveBeenCalledWith(mockPage, {}); + expect(mockPage.hover).toHaveBeenCalledWith('.button'); + }); + + it('should pass context to hook', async () => { + let mockPage = {}; + let hook = vi.fn(); + let context = { story: 'test' }; + + await applyHook(mockPage, hook, context); + + expect(hook).toHaveBeenCalledWith(mockPage, context); + }); + + it('should do nothing if hook is null', async () => { + let mockPage = {}; + + await expect(applyHook(mockPage, null)).resolves.toBeUndefined(); + }); + + it('should do nothing if hook is not a function', async () => { + let mockPage = {}; + + await expect( + applyHook(mockPage, 'not a function') + ).resolves.toBeUndefined(); + }); + + it('should throw error if hook fails', async () => { + let mockPage = {}; + let hook = vi.fn(async () => { + throw new Error('Hook failed'); + }); + + await expect(applyHook(mockPage, hook)).rejects.toThrow( + 'Hook execution failed: Hook failed' + ); + }); +}); + +describe('getStoryConfig', () => { + it('should merge global and story config', () => { + let story = { + parameters: { + vizzly: { + viewports: [{ name: 'mobile', width: 375, height: 667 }], + }, + }, + }; + let globalConfig = { + viewports: [{ name: 'desktop', width: 1920, height: 1080 }], + concurrency: 3, + }; + + let config = getStoryConfig(story, globalConfig); + + expect(config.viewports[0].name).toBe('mobile'); + expect(config.concurrency).toBe(3); + }); + + it('should return global config if no story config', () => { + let story = {}; + let globalConfig = { + viewports: [{ name: 'desktop', width: 1920, height: 1080 }], + }; + + let config = getStoryConfig(story, globalConfig); + + expect(config).toEqual(globalConfig); + }); +});