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
6 changes: 5 additions & 1 deletion clients/static-site/src/pool.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,12 @@ export function createTabPool(browser, size, options = {}) {
available.push(newEntry);
}
} catch {
// Failed to create new tab - reduce total count
// Failed to create new tab - reduce total count and notify waiting worker
totalTabs--;
if (waiting.length > 0) {
let next = waiting.shift();
next(null); // Signal failure so task can handle it
}
}
return;
}
Expand Down
103 changes: 14 additions & 89 deletions clients/storybook/src/browser.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,20 @@
/**
* Browser management with Puppeteer
* Functions for launching, managing, and closing browsers
* Core functions for launching and managing browsers
*/

import puppeteer from 'puppeteer';
import { setViewport } from './utils/viewport.js';

/**
* Check if running in a CI environment
* @returns {boolean}
*/
function isCI() {
return !!(
process.env.CI ||
process.env.GITHUB_ACTIONS ||
process.env.JENKINS_HOME ||
process.env.CIRCLECI ||
process.env.GITLAB_CI ||
process.env.BUILDKITE
);
}

/**
* Base browser args required for headless operation
*/
let BASE_ARGS = ['--no-sandbox', '--disable-setuid-sandbox'];

/**
* Additional browser args optimized for CI environments
* These reduce memory usage and improve stability in resource-constrained environments
* Browser args optimized for stability and consistency
* These are used in both local dev and CI to ensure identical behavior.
* Disabling GPU, extensions, etc. reduces flakiness and memory usage.
*/
let CI_OPTIMIZED_ARGS = [
// Required for running in containers/CI
'--no-sandbox',
'--disable-setuid-sandbox',

// Reduce memory usage
'--disable-dev-shm-usage', // Use /tmp instead of /dev/shm (often too small in Docker)
'--disable-gpu', // No GPU in CI
Expand All @@ -57,8 +41,8 @@ let CI_OPTIMIZED_ARGS = [
'--no-first-run',
'--safebrowsing-disable-auto-update',

// Memory optimizations
'--js-flags=--max-old-space-size=1024', // Limit V8 heap (1GB for larger Storybooks)
// Memory optimizations (1GB for larger Storybooks)
'--js-flags=--max-old-space-size=1024',
];

/**
Expand All @@ -71,13 +55,9 @@ let CI_OPTIMIZED_ARGS = [
export async function launchBrowser(options = {}) {
let { headless = true, args = [] } = options;

let browserArgs = isCI()
? [...BASE_ARGS, ...CI_OPTIMIZED_ARGS, ...args]
: [...BASE_ARGS, ...args];

let browser = await puppeteer.launch({
headless,
args: browserArgs,
args: [...CI_OPTIMIZED_ARGS, ...args],
// Reduce protocol timeout for faster failure detection
protocolTimeout: 60_000, // 60s instead of default 180s
});
Expand All @@ -96,15 +76,6 @@ export async function closeBrowser(browser) {
}
}

/**
* Create a new page in the browser
* @param {Object} browser - Browser instance
* @returns {Promise<Object>} Page instance
*/
export async function createPage(browser) {
return await browser.newPage();
}

/**
* Navigate to a URL and wait for the page to load
* @param {Object} page - Puppeteer page instance
Expand All @@ -121,15 +92,10 @@ export async function navigateToUrl(page, url, options = {}) {
});
} catch (error) {
// Fallback to domcontentloaded if networkidle2 times out
let isTimeout =
error.name === 'TimeoutError' ||
if (
error.message.includes('timeout') ||
error.message.includes('Navigation timeout');

if (isTimeout) {
console.warn(
`Navigation timeout for ${url}, falling back to domcontentloaded`
);
error.message.includes('Navigation timeout')
) {
await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 30000,
Expand All @@ -140,44 +106,3 @@ export async function navigateToUrl(page, url, options = {}) {
}
}
}

/**
* Process a single story - navigate, wait, and prepare for screenshot
* @param {Object} browser - Browser instance
* @param {string} url - Story URL
* @param {Object} viewport - Viewport configuration
* @param {Function|null} beforeScreenshot - Optional hook to run before screenshot
* @returns {Promise<Object>} Page instance ready for screenshot
*/
export async function prepareStoryPage(
browser,
url,
viewport,
beforeScreenshot = null
) {
let page = await createPage(browser);

// Set viewport
await setViewport(page, viewport);

// Navigate to story (waits for networkidle2)
await navigateToUrl(page, url);

// Run custom interaction hook if provided
if (beforeScreenshot && typeof beforeScreenshot === 'function') {
await beforeScreenshot(page);
}

return page;
}

/**
* Close a page
* @param {Object} page - Page instance to close
* @returns {Promise<void>}
*/
export async function closePage(page) {
if (page) {
await page.close();
}
}
147 changes: 22 additions & 125 deletions clients/storybook/src/index.js
Original file line number Diff line number Diff line change
@@ -1,124 +1,15 @@
/**
* Main entry point for @vizzly-testing/storybook
* Functional orchestration of story discovery and screenshot capture
* Uses a tab pool for efficient browser tab management
*/

import {
closeBrowser,
closePage,
launchBrowser,
prepareStoryPage,
} from './browser.js';
import { closeBrowser, launchBrowser } from './browser.js';
import { loadConfig } from './config.js';
import { discoverStories, generateStoryUrl } from './crawler.js';
import { getBeforeScreenshotHook, getStoryConfig } from './hooks.js';
import { captureAndSendScreenshot } from './screenshot.js';
import { discoverStories } from './crawler.js';
import { createTabPool } from './pool.js';
import { startStaticServer, stopStaticServer } from './server.js';

/**
* Process a single story across all configured viewports
* @param {Object} story - Story object
* @param {Object} browser - Browser instance
* @param {string} baseUrl - Base URL for Storybook (HTTP server)
* @param {Object} config - Configuration
* @param {Object} context - Plugin context
* @returns {Promise<Object>} Result object with success count and errors
*/
async function processStory(story, browser, baseUrl, config, context) {
let { logger } = context;
let storyConfig = getStoryConfig(story, config);
let storyUrl = generateStoryUrl(baseUrl, story.id);
let hook = getBeforeScreenshotHook(story, config);
let errors = [];

// Process each viewport for this story
for (let viewport of storyConfig.viewports) {
let page = null;

try {
page = await prepareStoryPage(browser, storyUrl, viewport, hook);
await captureAndSendScreenshot(
page,
story,
viewport,
storyConfig.screenshot
);

logger.info(` ✓ ${story.title}/${story.name}@${viewport.name}`);
} catch (error) {
logger.error(
` ✗ ${story.title}/${story.name}@${viewport.name}: ${error.message}`
);
errors.push({
story: `${story.title}/${story.name}`,
viewport: viewport.name,
error: error.message,
});
} finally {
await closePage(page);
}
}

return { errors };
}

/**
* Simple concurrency control - process items with limited parallelism
* @param {Array} items - Items to process
* @param {Function} fn - Async function to process each item
* @param {number} concurrency - Max parallel operations
* @returns {Promise<void>}
*/
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);
}

/**
* Process all stories with concurrency control
* @param {Array<Object>} stories - Array of story objects
* @param {Object} browser - Browser instance
* @param {string} baseUrl - Base URL for Storybook (HTTP server)
* @param {Object} config - Configuration
* @param {Object} context - Plugin context
* @returns {Promise<Array>} Array of all errors encountered
*/
async function processStories(stories, browser, baseUrl, config, context) {
let allErrors = [];

await mapWithConcurrency(
stories,
async story => {
let { errors } = await processStory(
story,
browser,
baseUrl,
config,
context
);
allErrors.push(...errors);
},
config.concurrency
);

return allErrors;
}
import { generateTasks, processAllTasks } from './tasks.js';

/**
* Check if TDD mode is available
Expand Down Expand Up @@ -170,6 +61,7 @@ function hasApiToken(config) {

/**
* Main run function - orchestrates the entire screenshot capture process
* Uses a tab pool for efficient parallel screenshot capture
* @param {string} storybookPath - Path to static Storybook build
* @param {Object} options - CLI options
* @param {Object} context - Plugin context (logger, config, services)
Expand All @@ -178,6 +70,7 @@ function hasApiToken(config) {
export async function run(storybookPath, options = {}, context = {}) {
let { logger, config: vizzlyConfig, services } = context;
let browser = null;
let pool = null;
let serverInfo = null;
let testRunner = null;
let serverManager = null;
Expand Down Expand Up @@ -287,6 +180,8 @@ export async function run(storybookPath, options = {}, context = {}) {
} catch (error) {
// Log the error and continue without cloud mode
logger.error(`Failed to initialize cloud mode: ${error.message}`);
logger.warn('⚠️ Falling back to local-only mode');
logger.info(' Screenshots will not be uploaded to cloud');
testRunner = null;
}
}
Expand All @@ -310,26 +205,25 @@ export async function run(storybookPath, options = {}, context = {}) {
return;
}

// Launch browser
// Launch browser and create tab pool
browser = await launchBrowser(config.browser);
pool = createTabPool(browser, config.concurrency);

// Process all stories
let errors = await processStories(
stories,
browser,
serverInfo.url,
config,
context
// Generate all tasks upfront (stories × viewports)
let tasks = generateTasks(stories, serverInfo.url, config);
logger.info(
`📸 Processing ${tasks.length} screenshots (${config.concurrency} concurrent tabs)`
);

// Process all tasks through the tab pool
let errors = await processAllTasks(tasks, pool, config, logger);

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

// Finalize build in run mode
Expand All @@ -356,7 +250,10 @@ export async function run(storybookPath, options = {}, context = {}) {

throw error;
} finally {
// Cleanup
// Cleanup: drain pool first, then close browser
if (pool) {
await pool.drain();
}
if (browser) {
await closeBrowser(browser);
}
Expand Down
Loading