Skip to content

Commit 8867cd2

Browse files
committed
✨ Add tab pool architecture to storybook SDK
Port the robust browser management from static-site SDK: - Add pool.js with tab reuse, recycling after 10 uses, state reset - Add tasks.js with retry logic on timeout/crash, progress tracking - Simplify browser.js to match static-site patterns - Update index.js to use pool-based architecture Fixes timeout issues in CI by: - Automatically retrying failed screenshots with fresh tabs - Recycling tabs to prevent memory leaks - Using CI-optimized browser flags
1 parent 4f2fa1b commit 8867cd2

12 files changed

Lines changed: 700 additions & 257 deletions

File tree

clients/storybook/src/browser.js

Lines changed: 12 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,19 @@
11
/**
22
* Browser management with Puppeteer
3-
* Functions for launching, managing, and closing browsers
3+
* Core functions for launching and managing browsers
44
*/
55

66
import puppeteer from 'puppeteer';
7-
import { setViewport } from './utils/viewport.js';
87

98
/**
10-
* Check if running in a CI environment
11-
* @returns {boolean}
12-
*/
13-
function isCI() {
14-
return !!(
15-
process.env.CI ||
16-
process.env.GITHUB_ACTIONS ||
17-
process.env.JENKINS_HOME ||
18-
process.env.CIRCLECI ||
19-
process.env.GITLAB_CI ||
20-
process.env.BUILDKITE
21-
);
22-
}
23-
24-
/**
25-
* Base browser args required for headless operation
26-
*/
27-
let BASE_ARGS = ['--no-sandbox', '--disable-setuid-sandbox'];
28-
29-
/**
30-
* Additional browser args optimized for CI environments
9+
* Default browser args optimized for CI environments
3110
* These reduce memory usage and improve stability in resource-constrained environments
3211
*/
3312
let CI_OPTIMIZED_ARGS = [
13+
// Required for running in containers/CI
14+
'--no-sandbox',
15+
'--disable-setuid-sandbox',
16+
3417
// Reduce memory usage
3518
'--disable-dev-shm-usage', // Use /tmp instead of /dev/shm (often too small in Docker)
3619
'--disable-gpu', // No GPU in CI
@@ -57,8 +40,8 @@ let CI_OPTIMIZED_ARGS = [
5740
'--no-first-run',
5841
'--safebrowsing-disable-auto-update',
5942

60-
// Memory optimizations
61-
'--js-flags=--max-old-space-size=1024', // Limit V8 heap (1GB for larger Storybooks)
43+
// Memory optimizations (1GB for larger Storybooks)
44+
'--js-flags=--max-old-space-size=1024',
6245
];
6346

6447
/**
@@ -71,13 +54,9 @@ let CI_OPTIMIZED_ARGS = [
7154
export async function launchBrowser(options = {}) {
7255
let { headless = true, args = [] } = options;
7356

74-
let browserArgs = isCI()
75-
? [...BASE_ARGS, ...CI_OPTIMIZED_ARGS, ...args]
76-
: [...BASE_ARGS, ...args];
77-
7857
let browser = await puppeteer.launch({
7958
headless,
80-
args: browserArgs,
59+
args: [...CI_OPTIMIZED_ARGS, ...args],
8160
// Reduce protocol timeout for faster failure detection
8261
protocolTimeout: 60_000, // 60s instead of default 180s
8362
});
@@ -96,15 +75,6 @@ export async function closeBrowser(browser) {
9675
}
9776
}
9877

99-
/**
100-
* Create a new page in the browser
101-
* @param {Object} browser - Browser instance
102-
* @returns {Promise<Object>} Page instance
103-
*/
104-
export async function createPage(browser) {
105-
return await browser.newPage();
106-
}
107-
10878
/**
10979
* Navigate to a URL and wait for the page to load
11080
* @param {Object} page - Puppeteer page instance
@@ -121,15 +91,10 @@ export async function navigateToUrl(page, url, options = {}) {
12191
});
12292
} catch (error) {
12393
// Fallback to domcontentloaded if networkidle2 times out
124-
let isTimeout =
125-
error.name === 'TimeoutError' ||
94+
if (
12695
error.message.includes('timeout') ||
127-
error.message.includes('Navigation timeout');
128-
129-
if (isTimeout) {
130-
console.warn(
131-
`Navigation timeout for ${url}, falling back to domcontentloaded`
132-
);
96+
error.message.includes('Navigation timeout')
97+
) {
13398
await page.goto(url, {
13499
waitUntil: 'domcontentloaded',
135100
timeout: 30000,
@@ -140,44 +105,3 @@ export async function navigateToUrl(page, url, options = {}) {
140105
}
141106
}
142107
}
143-
144-
/**
145-
* Process a single story - navigate, wait, and prepare for screenshot
146-
* @param {Object} browser - Browser instance
147-
* @param {string} url - Story URL
148-
* @param {Object} viewport - Viewport configuration
149-
* @param {Function|null} beforeScreenshot - Optional hook to run before screenshot
150-
* @returns {Promise<Object>} Page instance ready for screenshot
151-
*/
152-
export async function prepareStoryPage(
153-
browser,
154-
url,
155-
viewport,
156-
beforeScreenshot = null
157-
) {
158-
let page = await createPage(browser);
159-
160-
// Set viewport
161-
await setViewport(page, viewport);
162-
163-
// Navigate to story (waits for networkidle2)
164-
await navigateToUrl(page, url);
165-
166-
// Run custom interaction hook if provided
167-
if (beforeScreenshot && typeof beforeScreenshot === 'function') {
168-
await beforeScreenshot(page);
169-
}
170-
171-
return page;
172-
}
173-
174-
/**
175-
* Close a page
176-
* @param {Object} page - Page instance to close
177-
* @returns {Promise<void>}
178-
*/
179-
export async function closePage(page) {
180-
if (page) {
181-
await page.close();
182-
}
183-
}

clients/storybook/src/index.js

Lines changed: 22 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -1,124 +1,15 @@
11
/**
22
* Main entry point for @vizzly-testing/storybook
33
* Functional orchestration of story discovery and screenshot capture
4+
* Uses a tab pool for efficient browser tab management
45
*/
56

6-
import {
7-
closeBrowser,
8-
closePage,
9-
launchBrowser,
10-
prepareStoryPage,
11-
} from './browser.js';
7+
import { closeBrowser, launchBrowser } from './browser.js';
128
import { loadConfig } from './config.js';
13-
import { discoverStories, generateStoryUrl } from './crawler.js';
14-
import { getBeforeScreenshotHook, getStoryConfig } from './hooks.js';
15-
import { captureAndSendScreenshot } from './screenshot.js';
9+
import { discoverStories } from './crawler.js';
10+
import { createTabPool } from './pool.js';
1611
import { startStaticServer, stopStaticServer } from './server.js';
17-
18-
/**
19-
* Process a single story across all configured viewports
20-
* @param {Object} story - Story object
21-
* @param {Object} browser - Browser instance
22-
* @param {string} baseUrl - Base URL for Storybook (HTTP server)
23-
* @param {Object} config - Configuration
24-
* @param {Object} context - Plugin context
25-
* @returns {Promise<Object>} Result object with success count and errors
26-
*/
27-
async function processStory(story, browser, baseUrl, config, context) {
28-
let { logger } = context;
29-
let storyConfig = getStoryConfig(story, config);
30-
let storyUrl = generateStoryUrl(baseUrl, story.id);
31-
let hook = getBeforeScreenshotHook(story, config);
32-
let errors = [];
33-
34-
// Process each viewport for this story
35-
for (let viewport of storyConfig.viewports) {
36-
let page = null;
37-
38-
try {
39-
page = await prepareStoryPage(browser, storyUrl, viewport, hook);
40-
await captureAndSendScreenshot(
41-
page,
42-
story,
43-
viewport,
44-
storyConfig.screenshot
45-
);
46-
47-
logger.info(` ✓ ${story.title}/${story.name}@${viewport.name}`);
48-
} catch (error) {
49-
logger.error(
50-
` ✗ ${story.title}/${story.name}@${viewport.name}: ${error.message}`
51-
);
52-
errors.push({
53-
story: `${story.title}/${story.name}`,
54-
viewport: viewport.name,
55-
error: error.message,
56-
});
57-
} finally {
58-
await closePage(page);
59-
}
60-
}
61-
62-
return { errors };
63-
}
64-
65-
/**
66-
* Simple concurrency control - process items with limited parallelism
67-
* @param {Array} items - Items to process
68-
* @param {Function} fn - Async function to process each item
69-
* @param {number} concurrency - Max parallel operations
70-
* @returns {Promise<void>}
71-
*/
72-
async function mapWithConcurrency(items, fn, concurrency) {
73-
let results = [];
74-
let executing = [];
75-
76-
for (let item of items) {
77-
let promise = fn(item).then(result => {
78-
executing.splice(executing.indexOf(promise), 1);
79-
return result;
80-
});
81-
82-
results.push(promise);
83-
executing.push(promise);
84-
85-
if (executing.length >= concurrency) {
86-
await Promise.race(executing);
87-
}
88-
}
89-
90-
await Promise.all(results);
91-
}
92-
93-
/**
94-
* Process all stories with concurrency control
95-
* @param {Array<Object>} stories - Array of story objects
96-
* @param {Object} browser - Browser instance
97-
* @param {string} baseUrl - Base URL for Storybook (HTTP server)
98-
* @param {Object} config - Configuration
99-
* @param {Object} context - Plugin context
100-
* @returns {Promise<Array>} Array of all errors encountered
101-
*/
102-
async function processStories(stories, browser, baseUrl, config, context) {
103-
let allErrors = [];
104-
105-
await mapWithConcurrency(
106-
stories,
107-
async story => {
108-
let { errors } = await processStory(
109-
story,
110-
browser,
111-
baseUrl,
112-
config,
113-
context
114-
);
115-
allErrors.push(...errors);
116-
},
117-
config.concurrency
118-
);
119-
120-
return allErrors;
121-
}
12+
import { generateTasks, processAllTasks } from './tasks.js';
12213

12314
/**
12415
* Check if TDD mode is available
@@ -170,6 +61,7 @@ function hasApiToken(config) {
17061

17162
/**
17263
* Main run function - orchestrates the entire screenshot capture process
64+
* Uses a tab pool for efficient parallel screenshot capture
17365
* @param {string} storybookPath - Path to static Storybook build
17466
* @param {Object} options - CLI options
17567
* @param {Object} context - Plugin context (logger, config, services)
@@ -178,6 +70,7 @@ function hasApiToken(config) {
17870
export async function run(storybookPath, options = {}, context = {}) {
17971
let { logger, config: vizzlyConfig, services } = context;
18072
let browser = null;
73+
let pool = null;
18174
let serverInfo = null;
18275
let testRunner = null;
18376
let serverManager = null;
@@ -287,6 +180,8 @@ export async function run(storybookPath, options = {}, context = {}) {
287180
} catch (error) {
288181
// Log the error and continue without cloud mode
289182
logger.error(`Failed to initialize cloud mode: ${error.message}`);
183+
logger.warn('⚠️ Falling back to local-only mode');
184+
logger.info(' Screenshots will not be uploaded to cloud');
290185
testRunner = null;
291186
}
292187
}
@@ -310,26 +205,25 @@ export async function run(storybookPath, options = {}, context = {}) {
310205
return;
311206
}
312207

313-
// Launch browser
208+
// Launch browser and create tab pool
314209
browser = await launchBrowser(config.browser);
210+
pool = createTabPool(browser, config.concurrency);
315211

316-
// Process all stories
317-
let errors = await processStories(
318-
stories,
319-
browser,
320-
serverInfo.url,
321-
config,
322-
context
212+
// Generate all tasks upfront (stories × viewports)
213+
let tasks = generateTasks(stories, serverInfo.url, config);
214+
logger.info(
215+
`📸 Processing ${tasks.length} screenshots (${config.concurrency} concurrent tabs)`
323216
);
324217

218+
// Process all tasks through the tab pool
219+
let errors = await processAllTasks(tasks, pool, config, logger);
220+
325221
// Report summary
326222
if (errors.length > 0) {
327223
logger.warn(`\n⚠️ ${errors.length} screenshot(s) failed:`);
328224
errors.forEach(({ story, viewport, error }) => {
329225
logger.error(` ${story}@${viewport}: ${error}`);
330226
});
331-
} else {
332-
logger.info(`\n✅ Captured ${stories.length} screenshots successfully`);
333227
}
334228

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

357251
throw error;
358252
} finally {
359-
// Cleanup
253+
// Cleanup: drain pool first, then close browser
254+
if (pool) {
255+
await pool.drain();
256+
}
360257
if (browser) {
361258
await closeBrowser(browser);
362259
}

0 commit comments

Comments
 (0)