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
13 changes: 11 additions & 2 deletions clients/static-site/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ export default {
omitBackground: false,
},

concurrency: 3,
// Concurrency auto-detected from CPU cores (min 2, max 8)
// concurrency: 4,

// Page filtering
include: 'blog/**',
Expand Down Expand Up @@ -134,12 +135,13 @@ Configuration is merged in this order (later overrides earlier):
## CLI Options

- `--viewports <list>` - Comma-separated viewport definitions (format: `name:WxH`)
- `--concurrency <n>` - Number of parallel pages to process (default: 3)
- `--concurrency <n>` - Number of parallel browser tabs (default: auto-detected based on CPU cores, min 2, max 8)
- `--include <pattern>` - Include page pattern (glob)
- `--exclude <pattern>` - Exclude page pattern (glob)
- `--browser-args <args>` - Additional Puppeteer browser arguments
- `--headless` - Run browser in headless mode (default: true)
- `--full-page` - Capture full page screenshots (default: false)
- `--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 Expand Up @@ -382,6 +384,13 @@ jobs:

### Pages not found

Use `--dry-run` to see which pages are discovered without capturing screenshots:
```bash
vizzly static-site ./dist --dry-run
```

This shows pages grouped by source (sitemap vs HTML scan), the total screenshot count, and your current configuration.

Ensure your build has completed and check for sitemap.xml or HTML files:
```bash
ls dist/sitemap.xml
Expand Down
4 changes: 2 additions & 2 deletions clients/static-site/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 18 additions & 2 deletions clients/static-site/src/config-schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,24 @@
* Uses Zod for runtime validation
*/

import { cpus } from 'node:os';
import { z } from 'zod';

/**
* Cache CPU count at module load time
* Avoids repeated system calls
*/
let cachedCpuCount = cpus().length;

/**
* Calculate smart default concurrency based on CPU cores
* Uses half the cores (min 2, max 8) to balance speed vs resource usage
* @returns {number} Default concurrency value
*/
export function getDefaultConcurrency() {
return Math.max(2, Math.min(8, Math.floor(cachedCpuCount / 2)));
}

/**
* Viewport schema
*/
Expand Down Expand Up @@ -80,7 +96,7 @@ export let staticSiteConfigSchema = z
fullPage: false,
omitBackground: false,
}),
concurrency: z.number().int().positive().default(3),
concurrency: z.number().int().positive().default(getDefaultConcurrency()),
include: z.string().nullable().optional(),
exclude: z.string().nullable().optional(),
pageDiscovery: pageDiscoverySchema.default({
Expand All @@ -95,7 +111,7 @@ export let staticSiteConfigSchema = z
viewports: [{ name: 'default', width: 1920, height: 1080 }],
browser: { headless: true, args: [] },
screenshot: { fullPage: false, omitBackground: false },
concurrency: 3,
concurrency: getDefaultConcurrency(),
pageDiscovery: {
useSitemap: true,
sitemapPath: 'sitemap.xml',
Expand Down
61 changes: 59 additions & 2 deletions clients/static-site/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,51 @@ export async function run(buildPath, options = {}, context = {}) {
// Load and merge configuration
let config = await loadConfig(buildPath, options, vizzlyConfig);

// Handle dry-run mode early - just discover and print pages
if (options.dryRun) {
let pages = await discoverPages(config.buildPath, config);
logger.info(
`🔍 Dry run: Found ${pages.length} pages in ${config.buildPath}\n`
);

if (pages.length === 0) {
logger.warn(' No pages found matching your configuration.');
return;
}

// Group by source for clarity
let sitemapPages = pages.filter(p => p.source === 'sitemap');
let htmlPages = pages.filter(p => p.source === 'html');

if (sitemapPages.length > 0) {
logger.info(` From sitemap (${sitemapPages.length}):`);
for (let page of sitemapPages) {
logger.info(` ${page.path}`);
}
}

if (htmlPages.length > 0) {
logger.info(` From HTML scan (${htmlPages.length}):`);
for (let page of htmlPages) {
logger.info(` ${page.path}`);
}
}

// Show task count that would be generated
let taskCount = pages.length * config.viewports.length;
logger.info('');
logger.info(`📸 Would capture ${taskCount} screenshots:`);
logger.info(
` ${pages.length} pages × ${config.viewports.length} viewports`
);
logger.info(
` Viewports: ${config.viewports.map(v => `${v.name} (${v.width}×${v.height})`).join(', ')}`
);
logger.info(` Concurrency: ${config.concurrency} tabs`);

return;
}

// Determine mode: TDD or Run
let debug = logger.debug?.bind(logger) || (() => {});
let isTdd = await isTddModeAvailable(debug);
Expand Down Expand Up @@ -196,8 +241,20 @@ export async function run(buildPath, options = {}, context = {}) {
}

if (!isTdd && !hasToken) {
logger.warn('⚠️ No TDD server or API token found');
logger.info(' Run `vizzly tdd start` or set VIZZLY_TOKEN');
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('');
return;
}

// Start HTTP server to serve static site files
Expand Down
8 changes: 7 additions & 1 deletion clients/static-site/src/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* Registers the `vizzly static-site` command
*/

import { getDefaultConcurrency } from './config-schema.js';

export default {
name: 'static-site',
version: '0.1.0',
Expand All @@ -25,7 +27,7 @@ export default {
fullPage: false,
omitBackground: false,
},
concurrency: 3,
concurrency: getDefaultConcurrency(),
include: null,
exclude: null,
pageDiscovery: {
Expand Down Expand Up @@ -64,6 +66,10 @@ 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(
'--dry-run',
'Print discovered pages without capturing screenshots'
)
.option('--use-sitemap', 'Use sitemap.xml for page discovery')
.option(
'--sitemap-path <path>',
Expand Down
147 changes: 142 additions & 5 deletions clients/static-site/src/tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,93 @@ export async function mapWithConcurrency(items, fn, concurrency) {
await Promise.all(results);
}

/**
* Format milliseconds as human-readable duration
* @param {number} ms - Milliseconds
* @returns {string} Formatted duration (e.g., "2m 30s", "45s")
*/
function formatDuration(ms) {
let seconds = Math.floor(ms / 1000);
let minutes = Math.floor(seconds / 60);
seconds = seconds % 60;

if (minutes > 0) {
return `${minutes}m ${seconds}s`;
}
return `${seconds}s`;
}

/**
* Check if stdout is an interactive TTY
* @returns {boolean}
*/
function isInteractiveTTY() {
return process.stdout.isTTY && !process.env.CI;
}

/**
* Create a simple output coordinator for TTY progress
* Prevents race conditions when multiple concurrent tasks update progress
* @returns {Object} Coordinator with writeProgress and logError methods
*/
function createOutputCoordinator() {
let pendingErrors = [];
let isWriting = false;

return {
/**
* Update progress line (only in TTY mode)
* @param {string} text - Progress text
*/
writeProgress(text) {
if (!isInteractiveTTY()) return;

// Flush any pending errors first
if (pendingErrors.length > 0 && !isWriting) {
isWriting = true;
process.stdout.clearLine(0);
process.stdout.cursorTo(0);
for (let err of pendingErrors) {
process.stdout.write(`${err}\n`);
}
pendingErrors = [];
isWriting = false;
}

process.stdout.clearLine(0);
process.stdout.cursorTo(0);
process.stdout.write(text);
},

/**
* Queue an error message to be printed
* @param {string} message - Error message
* @param {Object} logger - Logger instance
*/
logError(message, logger) {
if (isInteractiveTTY()) {
// Queue error to be printed before next progress update
pendingErrors.push(message);
}
logger.error(message);
},

/**
* Clear progress and flush any remaining errors
*/
flush() {
if (!isInteractiveTTY()) return;

process.stdout.clearLine(0);
process.stdout.cursorTo(0);
for (let err of pendingErrors) {
process.stdout.write(`${err}\n`);
}
pendingErrors = [];
},
};
}

/**
* Process all tasks through the tab pool
* @param {Array<Object>} tasks - Array of task objects
Expand All @@ -125,13 +212,21 @@ export async function processAllTasks(tasks, pool, config, logger, deps = {}) {
let errors = [];
let completed = 0;
let total = tasks.length;
let startTime = Date.now();
let taskTimes = [];
let interactive = isInteractiveTTY();
let output = createOutputCoordinator();

// Minimum samples before showing ETA (avoids wild estimates from cold start)
let minSamplesForEta = Math.min(5, Math.ceil(total * 0.1));

// Merge deps for processTask
let taskDeps = { ...defaultDeps, ...deps };

await mapWithConcurrency(
tasks,
async task => {
let taskStart = Date.now();
let tab = await pool.acquire();

// Handle case where pool was drained while waiting
Expand All @@ -147,18 +242,47 @@ export async function processAllTasks(tasks, pool, config, logger, deps = {}) {
try {
await processTask(tab, task, taskDeps);
completed++;
logger.info(
` ✓ [${completed}/${total}] ${task.page.path}@${task.viewport.name}`
);

// Track task duration for ETA calculation
let taskDuration = Date.now() - taskStart;
taskTimes.push(taskDuration);

// Calculate ETA - only show after enough samples for accuracy
let eta = '';
if (taskTimes.length >= minSamplesForEta) {
// Use recent samples for better accuracy (exponential-ish weighting)
let recentTimes = taskTimes.slice(-20);
let avgTime =
recentTimes.reduce((a, b) => a + b, 0) / recentTimes.length;
let remaining = total - completed;
// Divide by concurrency since tasks run in parallel
let etaMs = (remaining * avgTime) / config.concurrency;
eta = remaining > 0 ? `~${formatDuration(etaMs)} remaining` : '';
}
let percent = Math.round((completed / total) * 100);

if (interactive) {
// Update single progress line
output.writeProgress(
` 📸 [${completed}/${total}] ${percent}% ${eta} - ${task.page.path}@${task.viewport.name}`
);
} else {
// Non-interactive: log each completion
logger.info(
` ✓ [${completed}/${total}] ${task.page.path}@${task.viewport.name} ${eta}`
);
}
} catch (error) {
completed++;
errors.push({
page: task.page.path,
viewport: task.viewport.name,
error: error.message,
});
logger.error(
` ✗ [${completed}/${total}] ${task.page.path}@${task.viewport.name}: ${error.message}`

output.logError(
` ✗ ${task.page.path}@${task.viewport.name}: ${error.message}`,
logger
);
} finally {
pool.release(tab);
Expand All @@ -167,5 +291,18 @@ export async function processAllTasks(tasks, pool, config, logger, deps = {}) {
config.concurrency
);

// Flush any remaining output
output.flush();

// Log total time
let totalTime = Date.now() - startTime;
logger.info(
` ✅ Completed ${total} screenshots in ${formatDuration(totalTime)}`
);

if (errors.length > 0) {
logger.warn(` ⚠️ ${errors.length} failed`);
}

return errors;
}
Loading