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
1 change: 1 addition & 0 deletions clients/static-site/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ Configuration is merged in this order (later overrides earlier):
- `--browser-args <args>` - Additional Puppeteer browser arguments
- `--headless` - Run browser in headless mode (default: true)
- `--full-page` - Capture full page screenshots (default: false)
- `--timeout <ms>` - Screenshot timeout in milliseconds (default: 45000)
- `--dry-run` - Print discovered pages and task count without capturing screenshots
- `--use-sitemap` - Use sitemap.xml for page discovery (default: true)
- `--sitemap-path <path>` - Path to sitemap.xml relative to build directory
Expand Down
43 changes: 42 additions & 1 deletion clients/static-site/src/browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,45 @@

import puppeteer from 'puppeteer';

/**
* Default browser args optimized for CI environments
* These reduce memory usage and improve stability in resource-constrained environments
*/
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
'--disable-software-rasterizer',

// Disable unnecessary features
'--disable-extensions',
'--disable-background-networking',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-breakpad', // Crash reporting
'--disable-component-update',
'--disable-default-apps',
'--disable-hang-monitor',
'--disable-ipc-flooding-protection',
'--disable-popup-blocking',
'--disable-prompt-on-repost',
'--disable-renderer-backgrounding',
'--disable-sync',
'--disable-translate',

// Reduce resource usage
'--metrics-recording-only',
'--no-first-run',
'--safebrowsing-disable-auto-update',

// Memory optimizations
'--js-flags=--max-old-space-size=512', // Limit V8 heap
];

/**
* Launch a Puppeteer browser instance
* @param {Object} options - Browser launch options
Expand All @@ -17,7 +56,9 @@ export async function launchBrowser(options = {}) {

let browser = await puppeteer.launch({
headless,
args: ['--no-sandbox', '--disable-setuid-sandbox', ...args],
args: [...CI_OPTIMIZED_ARGS, ...args],
// Reduce protocol timeout for faster failure detection
protocolTimeout: 60_000, // 60s instead of default 180s
});

return browser;
Expand Down
4 changes: 3 additions & 1 deletion clients/static-site/src/config-schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ let browserSchema = z.object({
let screenshotSchema = z.object({
fullPage: z.boolean().default(false),
omitBackground: z.boolean().default(false),
timeout: z.number().int().positive().default(45_000), // 45 seconds
});

/**
Expand Down Expand Up @@ -95,6 +96,7 @@ export let staticSiteConfigSchema = z
screenshot: screenshotSchema.default({
fullPage: false,
omitBackground: false,
timeout: 45_000,
}),
concurrency: z.number().int().positive().default(getDefaultConcurrency()),
include: z.string().nullable().optional(),
Expand All @@ -110,7 +112,7 @@ export let staticSiteConfigSchema = z
.default({
viewports: [{ name: 'default', width: 1920, height: 1080 }],
browser: { headless: true, args: [] },
screenshot: { fullPage: false, omitBackground: false },
screenshot: { fullPage: false, omitBackground: false, timeout: 45_000 },
concurrency: getDefaultConcurrency(),
pageDiscovery: {
useSitemap: true,
Expand Down
4 changes: 4 additions & 0 deletions clients/static-site/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ export function parseCliOptions(options) {
config.screenshot = { ...config.screenshot, fullPage: options.fullPage };
}

if (options.timeout !== undefined) {
config.screenshot = { ...config.screenshot, timeout: options.timeout };
}

if (options.useSitemap !== undefined) {
config.pageDiscovery = {
...config.pageDiscovery,
Expand Down
33 changes: 20 additions & 13 deletions clients/static-site/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -241,19 +241,26 @@ export async function run(buildPath, options = {}, context = {}) {
}

if (!isTdd && !hasToken) {
logger.error('❌ No TDD server or API token found');
logger.info('');
logger.info(' To capture screenshots, you need either:');
logger.info('');
logger.info(' 1. Start TDD server first (recommended for local dev):');
logger.info(' vizzly tdd start');
logger.info(' npx vizzly static-site ./dist');
logger.info('');
logger.info(' 2. Or set VIZZLY_TOKEN for cloud uploads:');
logger.info(
' VIZZLY_TOKEN=your-token npx vizzly static-site ./dist'
);
logger.info('');
// Use output module methods for clean formatting
let out = logger.print ? logger : null;
if (out) {
out.blank();
out.warn('No TDD server or API token found');
out.blank();
out.print(' To capture screenshots, you need either:');
out.blank();
out.print(' 1. Start TDD server first (recommended for local dev):');
out.hint(' vizzly tdd start');
out.hint(' npx vizzly static-site ./dist');
out.blank();
out.print(' 2. Or set VIZZLY_TOKEN for cloud uploads:');
out.hint(' VIZZLY_TOKEN=your-token npx vizzly static-site ./dist');
out.blank();
} else {
// Fallback for testing or when output module not available
logger.warn('No TDD server or API token found');
logger.info('Run "vizzly tdd start" first, or set VIZZLY_TOKEN');
}
return;
}

Expand Down
5 changes: 5 additions & 0 deletions clients/static-site/src/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export default {
.option('--browser-args <args>', 'Additional Puppeteer browser arguments')
.option('--headless', 'Run browser in headless mode')
.option('--full-page', 'Capture full page screenshots')
.option(
'--timeout <ms>',
'Screenshot timeout in milliseconds (default: 45000)',
parseInt
)
.option(
'--dry-run',
'Print discovered pages without capturing screenshots'
Expand Down
82 changes: 74 additions & 8 deletions clients/static-site/src/pool.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,37 @@
*/

/**
* Create a tab pool that manages browser tabs with reuse
* Default number of uses before recycling a tab
* After this many uses, the tab is closed and a fresh one created
* This prevents memory leaks from accumulating
*/
let DEFAULT_RECYCLE_AFTER = 10;

/**
* Create a tab pool that manages browser tabs with reuse and recycling
* @param {Object} browser - Puppeteer browser instance
* @param {number} size - Maximum number of concurrent tabs
* @param {Object} [options] - Pool options
* @param {number} [options.recycleAfter=10] - Recycle tab after N uses
* @returns {Object} Pool operations: { acquire, release, drain, stats }
*/
export function createTabPool(browser, size) {
export function createTabPool(browser, size, options = {}) {
let { recycleAfter = DEFAULT_RECYCLE_AFTER } = options;

// Track tabs with their use counts: { tab, useCount }
let available = [];
let waiting = [];
let totalTabs = 0;
let recycledCount = 0;

/**
* Create a fresh tab entry
* @returns {Promise<Object>} Tab entry { tab, useCount }
*/
let createTabEntry = async () => {
let tab = await browser.newPage();
return { tab, useCount: 0 };
};

/**
* Acquire a tab from the pool
Expand All @@ -27,13 +49,19 @@ export function createTabPool(browser, size) {
let acquire = async () => {
// Reuse existing tab if available
if (available.length > 0) {
return available.pop();
let entry = available.pop();
entry.useCount++;
return entry.tab;
}

// Create new tab if under limit
if (totalTabs < size) {
totalTabs++;
return await browser.newPage();
let entry = await createTabEntry();
entry.useCount = 1;
// Store entry reference on tab for release lookup
entry.tab._poolEntry = entry;
return entry.tab;
}

// Wait for a tab to become available
Expand Down Expand Up @@ -65,21 +93,58 @@ export function createTabPool(browser, size) {
/**
* Release a tab back to the pool
* Resets tab state before reuse to prevent cross-contamination.
* Recycles (closes and replaces) tabs that have been used too many times.
* If workers are waiting, hand off directly; otherwise add to available.
* @param {Object} tab - Puppeteer page instance to release
*/
let release = async tab => {
if (!tab) return;

let entry = tab._poolEntry;

// Check if tab needs recycling
if (entry && entry.useCount >= recycleAfter) {
recycledCount++;

// Close the old tab
try {
await tab.close();
} catch {
// Ignore close errors
}

// Create a fresh replacement
try {
let newEntry = await createTabEntry();
newEntry.tab._poolEntry = newEntry;

// Hand off to waiting worker or add to available
if (waiting.length > 0) {
newEntry.useCount = 1;
let next = waiting.shift();
next(newEntry.tab);
} else {
available.push(newEntry);
}
} catch {
// Failed to create new tab - reduce total count
totalTabs--;
}
return;
}

// Reset tab state before reuse
await resetTab(tab);

// If someone is waiting, give them the tab directly
if (waiting.length > 0) {
if (entry) entry.useCount++;
let next = waiting.shift();
next(tab);
} else if (entry) {
available.push(entry);
} else {
available.push(tab);
available.push({ tab, useCount: 0 });
}
};

Expand All @@ -95,8 +160,8 @@ export function createTabPool(browser, size) {
let drain = async () => {
// Close all available tabs
await Promise.all(
available.map(tab =>
tab.close().catch(() => {
available.map(entry =>
entry.tab.close().catch(() => {
// Ignore close errors (tab may already be closed)
})
)
Expand All @@ -114,13 +179,14 @@ export function createTabPool(browser, size) {

/**
* Get current pool statistics
* @returns {Object} { available, waiting, total, size }
* @returns {Object} { available, waiting, total, size, recycled }
*/
let stats = () => ({
available: available.length,
waiting: waiting.length,
total: totalTabs,
size,
recycled: recycledCount,
});

return { acquire, release, drain, stats };
Expand Down
14 changes: 13 additions & 1 deletion clients/static-site/src/screenshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,20 +55,32 @@ export function generateScreenshotProperties(viewport) {
};
}

/**
* Default screenshot timeout in milliseconds (45 seconds)
* If a page can't render within this time, something is likely wrong
*/
let DEFAULT_SCREENSHOT_TIMEOUT = 45_000;

/**
* Capture a screenshot from a page
* @param {Object} page - Puppeteer page instance
* @param {Object} options - Screenshot options
* @param {boolean} [options.fullPage=false] - Capture full page
* @param {boolean} [options.omitBackground=false] - Omit background
* @param {number} [options.timeout=45000] - Screenshot timeout in ms
* @returns {Promise<Buffer>} Screenshot buffer
*/
export async function captureScreenshot(page, options = {}) {
let { fullPage = false, omitBackground = false } = options;
let {
fullPage = false,
omitBackground = false,
timeout = DEFAULT_SCREENSHOT_TIMEOUT,
} = options;

let screenshot = await page.screenshot({
fullPage,
omitBackground,
timeout,
});

return screenshot;
Expand Down
Loading
Loading