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
3,348 changes: 225 additions & 3,123 deletions clients/static-site/package-lock.json

Large diffs are not rendered by default.

9 changes: 4 additions & 5 deletions clients/static-site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@
"scripts": {
"build": "npm run clean && npm run compile",
"clean": "rimraf dist",
"compile": "babel src --out-dir dist --ignore '**/*.spec.js'",
"compile": "babel src --out-dir dist --ignore '**/*.test.js'",
"prepublishOnly": "npm run lint && npm run build",
"test": "vitest run",
"test:watch": "vitest",
"test": "node --test --test-reporter=spec $(find tests -name '*.test.js')",
"test:watch": "node --test --test-reporter=spec --watch $(find tests -name '*.test.js')",
"lint": "biome lint src",
"lint:fix": "biome lint --write src",
"format": "biome format --write src",
Expand Down Expand Up @@ -76,7 +76,6 @@
"@babel/core": "^7.23.6",
"@babel/preset-env": "^7.23.6",
"@biomejs/biome": "^2.3.8",
"rimraf": "^6.0.1",
"vitest": "^3.2.4"
"rimraf": "^6.0.1"
}
}
55 changes: 2 additions & 53 deletions clients/static-site/src/browser.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
/**
* 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';

/**
* Launch a Puppeteer browser instance
Expand Down Expand Up @@ -35,15 +34,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 @@ -55,7 +45,7 @@ export async function navigateToUrl(page, url, options = {}) {
try {
await page.goto(url, {
waitUntil: 'networkidle2',
timeout: 30000, // 30 second timeout
timeout: 30000,
...options,
});
} catch (error) {
Expand All @@ -74,44 +64,3 @@ export async function navigateToUrl(page, url, options = {}) {
}
}
}

/**
* Process a single page - navigate, wait, and prepare for screenshot
* @param {Object} browser - Browser instance
* @param {string} url - Page 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 preparePageForScreenshot(
browser,
url,
viewport,
beforeScreenshot = null
) {
let page = await createPage(browser);

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

// Navigate to page (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();
}
}
173 changes: 37 additions & 136 deletions clients/static-site/src/index.js
Original file line number Diff line number Diff line change
@@ -1,133 +1,22 @@
/**
* Main entry point for @vizzly-testing/static-site
* Functional orchestration of page discovery and screenshot capture
* Uses a tab pool for efficient browser tab management
*/

import {
closeBrowser,
closePage,
launchBrowser,
preparePageForScreenshot,
} from './browser.js';
import { getPageConfig, loadConfig } from './config.js';
import { discoverPages, generatePageUrl } from './crawler.js';
import { getBeforeScreenshotHook } from './hooks.js';
import { captureAndSendScreenshot } from './screenshot.js';
import { closeBrowser, launchBrowser } from './browser.js';
import { loadConfig } from './config.js';
import { discoverPages } from './crawler.js';
import { createTabPool } from './pool.js';
import { startStaticServer, stopStaticServer } from './server.js';

/**
* Process a single page across all configured viewports
* @param {Object} page - Page object
* @param {Object} browser - Browser instance
* @param {string} baseUrl - Base URL for static site (HTTP server)
* @param {Object} config - Configuration
* @param {Object} context - Plugin context
* @returns {Promise<Object>} Result object with success count and errors
*/
async function processPage(page, browser, baseUrl, config, context) {
let { logger } = context;
let pageConfig = getPageConfig(config, page);
let pageUrl = generatePageUrl(baseUrl, page);
let hook = getBeforeScreenshotHook(page, config);
let errors = [];

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

try {
puppeteerPage = await preparePageForScreenshot(
browser,
pageUrl,
viewport,
hook
);
await captureAndSendScreenshot(
puppeteerPage,
page,
viewport,
pageConfig.screenshot
);

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

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 = new Set();

for (let item of items) {
let promise = fn(item).then(result => {
executing.delete(promise);
return result;
});

results.push(promise);
executing.add(promise);

if (executing.size >= concurrency) {
await Promise.race(executing);
}
}

await Promise.all(results);
}

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

await mapWithConcurrency(
pages,
async page => {
let { errors } = await processPage(
page,
browser,
baseUrl,
config,
context
);
allErrors.push(...errors);
},
config.concurrency
);

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

/**
* Check if TDD mode is available
* @param {Function} [debug] - Optional debug logger
* @returns {Promise<boolean>} True if TDD server is running
*/
async function isTddModeAvailable() {
async function isTddModeAvailable(debug = () => {}) {
let { existsSync, readFileSync } = await import('node:fs');
let { join, parse, dirname } = await import('node:path');

Expand All @@ -136,27 +25,34 @@ async function isTddModeAvailable() {
let currentDir = process.cwd();
let root = parse(currentDir).root;

debug(`Searching for TDD server from ${currentDir}`);

while (currentDir !== root) {
let serverJsonPath = join(currentDir, '.vizzly', 'server.json');

if (existsSync(serverJsonPath)) {
debug(`Found server.json at ${serverJsonPath}`);
try {
let serverInfo = JSON.parse(readFileSync(serverJsonPath, 'utf8'));
if (serverInfo.port) {
debug(`Pinging TDD server at port ${serverInfo.port}`);
// Try to ping the server
let response = await fetch(
`http://localhost:${serverInfo.port}/health`
);
debug(`TDD server health check: ${response.ok ? 'OK' : 'FAILED'}`);
return response.ok;
}
} catch {
// Invalid JSON or server not responding
debug('server.json missing port field');
} catch (error) {
debug(`Failed to connect to TDD server: ${error.message}`);
}
}
currentDir = dirname(currentDir);
}
} catch {
// Error checking for TDD mode
debug('No .vizzly/server.json found in parent directories');
} catch (error) {
debug(`Error checking for TDD mode: ${error.message}`);
}

return false;
Expand All @@ -173,6 +69,7 @@ function hasApiToken(config) {

/**
* Main run function - orchestrates the entire screenshot capture process
* Uses a tab pool for efficient parallel screenshot capture
* @param {string} buildPath - Path to static site build
* @param {Object} options - CLI options
* @param {Object} context - Plugin context (logger, config, services)
Expand All @@ -181,6 +78,7 @@ function hasApiToken(config) {
export async function run(buildPath, options = {}, context = {}) {
let { logger, config: vizzlyConfig, services } = context;
let browser = null;
let pool = null;
let serverInfo = null;
let testRunner = null;
let serverManager = null;
Expand All @@ -196,7 +94,8 @@ export async function run(buildPath, options = {}, context = {}) {
let config = await loadConfig(buildPath, options, vizzlyConfig);

// Determine mode: TDD or Run
let isTdd = await isTddModeAvailable();
let debug = logger.debug?.bind(logger) || (() => {});
let isTdd = await isTddModeAvailable(debug);
let hasToken = hasApiToken(vizzlyConfig);

if (isTdd) {
Expand Down Expand Up @@ -313,28 +212,27 @@ export async function run(buildPath, options = {}, context = {}) {
return;
}

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

// Process all pages
let errors = await processPages(
pages,
browser,
serverInfo.url,
config,
context
// Generate all tasks upfront (pages × viewports)
let tasks = generateTasks(pages, 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(({ page, viewport, error }) => {
logger.error(` ${page}@${viewport}: ${error}`);
});
} else {
logger.info(
`\n✅ Captured ${pages.length * config.viewports.length} screenshots successfully`
);
logger.info(`\n✅ Captured ${tasks.length} screenshots successfully`);
}

// Finalize build in run mode
Expand All @@ -361,7 +259,10 @@ export async function run(buildPath, 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
Loading