diff --git a/bin/design-extract.js b/bin/design-extract.js index b29a995..845a6a3 100755 --- a/bin/design-extract.js +++ b/bin/design-extract.js @@ -274,8 +274,8 @@ program if (merged.full || merged.screenshots) { spinner.text = 'Extracting logo...'; try { - const { chromium } = await import('playwright'); - const browser = await chromium.launch({ headless: true, ...(merged.systemChrome && { channel: 'chrome' }) }); + const { launchChromium } = await import('../src/browser.js'); + const browser = await launchChromium({ headless: true, ...(merged.systemChrome && { channel: 'chrome' }) }); const ctx = await browser.newContext({ viewport: { width: merged.width, height: parseInt(merged.height) || 800 } }); const lp = await ctx.newPage(); await lp.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {}); @@ -724,9 +724,9 @@ program process.exit(1); } spinner.fail('Extraction failed'); - if (err.message.includes('playwright')) { - console.error(chalk.red('\n Playwright is not installed.')); - console.error(chalk.gray(' Run: npx playwright install chromium\n')); + if (err.code === 'BROWSER_UNAVAILABLE' || err.message.includes('playwright')) { + console.error(chalk.red('\n No browser available.')); + console.error(chalk.gray(' Run: npx designlang install-browser (or install Google Chrome)\n')); } else { console.error(chalk.red(`\n ${err.message}\n`)); if (opts.verbose) console.error(err.stack); @@ -2371,9 +2371,9 @@ program const { chromium } = await import('playwright'); const bin = chromium.executablePath(); if (existsSync(bin)) add('Chromium binary', bin, 'OK'); - else add('Chromium binary', 'not installed', 'FAIL', 'npx playwright install chromium'); + else add('Chromium binary', 'not installed', 'FAIL', 'npx designlang install-browser'); } catch { - add('Chromium binary', 'not resolvable', 'FAIL', 'npx playwright install chromium'); + add('Chromium binary', 'not resolvable', 'FAIL', 'npx designlang install-browser'); } const outDir = resolve('./design-extract-output'); @@ -2516,4 +2516,19 @@ program await run(opts); }); +// ── Browser install ──────────────────────────────────────── +program + .command('install-browser') + .description('Download the Chromium build designlang drives (no longer done at npm install)') + .option('--with-deps', 'also install the system libraries Chromium needs (Linux CI)') + .action(async (opts) => { + const { createRequire } = await import('module'); + const { spawnSync } = await import('child_process'); + // playwright's exports map hides cli.js, so locate it from package.json. + const pwDir = dirname(createRequire(import.meta.url).resolve('playwright/package.json')); + const args = [join(pwDir, 'cli.js'), 'install', ...(opts.withDeps ? ['--with-deps'] : []), 'chromium']; + const r = spawnSync(process.execPath, args, { stdio: 'inherit' }); + process.exit(r.status ?? 1); + }); + program.parse(); diff --git a/package.json b/package.json index f3dc7e0..ad5c47c 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "./package.json": "./package.json" }, "scripts": { - "postinstall": "npx playwright install chromium --with-deps 2>/dev/null || npx playwright install chromium", "start": "node bin/design-extract.js", "check-plugin": "node scripts/check-plugin-version.mjs", "test": "node --test tests/*.test.js" diff --git a/smithery.dockerfile b/smithery.dockerfile index d312e1d..e7780c0 100644 --- a/smithery.dockerfile +++ b/smithery.dockerfile @@ -10,7 +10,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libxss1 libxtst6 wget \ && rm -rf /var/lib/apt/lists/* -RUN npm install -g designlang && npx playwright install chromium +RUN npm install -g designlang && designlang install-browser WORKDIR /app ENTRYPOINT ["npx", "designlang", "mcp"] diff --git a/src/browser.js b/src/browser.js new file mode 100644 index 0000000..f68c553 --- /dev/null +++ b/src/browser.js @@ -0,0 +1,32 @@ +// One place that launches Chromium, so every command shares the same fallback: +// bundled Chromium → system Chrome → an error that names the fix. +// +// Chromium used to be downloaded in `postinstall`, which fails in CI, Docker and +// behind proxies. It is now installed on demand with `designlang install-browser`. + +import { chromium as playwrightChromium } from 'playwright'; + +export const INSTALL_HINT = 'Run `npx designlang install-browser`, or install Google Chrome.'; + +function isMissingBrowser(err) { + return /Executable doesn't exist|distribution '[^']*' is not found/i.test(err?.message || ''); +} + +export async function launchChromium(options = {}, { chromium = playwrightChromium } = {}) { + try { + return await chromium.launch(options); + } catch (err) { + if (!isMissingBrowser(err)) throw err; + // Only fall back when the caller didn't ask for a specific browser. + if (!options.channel && !options.executablePath) { + try { + return await chromium.launch({ ...options, channel: 'chrome' }); + } catch (fallbackErr) { + if (!isMissingBrowser(fallbackErr)) throw fallbackErr; + } + } + const e = new Error(`No browser available. ${INSTALL_HINT}`, { cause: err }); + e.code = 'BROWSER_UNAVAILABLE'; + throw e; + } +} diff --git a/src/crawler.js b/src/crawler.js index e47a21e..3de36b2 100644 --- a/src/crawler.js +++ b/src/crawler.js @@ -1,4 +1,5 @@ import { chromium } from 'playwright'; +import { launchChromium } from './browser.js'; import { mkdirSync } from 'fs'; import { join } from 'path'; import { extractMediaDarkColors } from './extractors/dark-mode-pair.js'; @@ -53,7 +54,7 @@ export async function crawlPage(url, options = {}) { // require Playwright's protocol on a path like /playwright/chromium. const browser = usingRemote ? await chromium.connectOverCDP(wsEndpoint, { timeout: 30000 }) - : await chromium.launch({ + : await launchChromium({ headless: true, ...(executablePath && { executablePath }), // channel: 'chrome' forces Playwright to use the system Chrome install diff --git a/src/extractors/component-screenshots.js b/src/extractors/component-screenshots.js index 3a663f8..7b46486 100644 --- a/src/extractors/component-screenshots.js +++ b/src/extractors/component-screenshots.js @@ -7,7 +7,7 @@ // subdirectory under the output root and returns an index the bin emits as // `*-screenshots.json`. -import { chromium } from 'playwright'; +import { launchChromium } from '../browser.js'; import { mkdirSync } from 'fs'; import { join } from 'path'; @@ -121,7 +121,7 @@ export async function captureComponentScreenshotsV10(url, outDir, { width = 1280 const screenshotDir = join(outDir, 'screenshots'); mkdirSync(screenshotDir, { recursive: true }); - const browser = await chromium.launch({ headless: true, ...(channel && { channel }) }); + const browser = await launchChromium({ headless: true, ...(channel && { channel }) }); try { const context = await browser.newContext({ viewport: { width, height }, diff --git a/src/extractors/interactions.js b/src/extractors/interactions.js index eabf815..23a6955 100644 --- a/src/extractors/interactions.js +++ b/src/extractors/interactions.js @@ -1,10 +1,10 @@ // Interaction state extraction — hover, focus, active styles -import { chromium } from 'playwright'; +import { launchChromium } from '../browser.js'; export async function captureInteractions(url, options = {}) { const { width = 1280, height = 800, wait = 0 } = options; - const browser = await chromium.launch({ headless: true }); + const browser = await launchChromium({ headless: true }); const context = await browser.newContext({ viewport: { width, height } }); const page = await context.newPage(); diff --git a/src/extractors/perf.js b/src/extractors/perf.js index afea2ca..dc5f7af 100644 --- a/src/extractors/perf.js +++ b/src/extractors/perf.js @@ -7,7 +7,7 @@ // No Lighthouse dependency — everything is pure `playwright` + the page's own // `PerformanceObserver` API, which keeps the package size flat. -import { chromium } from 'playwright'; +import { launchChromium } from '../browser.js'; const THIRD_PARTY_HOSTS = [ 'google-analytics', 'googletagmanager', 'analytics.google', 'segment.', 'mixpanel', @@ -44,7 +44,7 @@ function fontLoadingStrategy(stack) { } export async function captureCoreWebVitals(url, { width = 1280, height = 800, channel, timeout = 30000 } = {}) { - const browser = await chromium.launch({ headless: true, ...(channel && { channel }) }); + const browser = await launchChromium({ headless: true, ...(channel && { channel }) }); try { const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: 'light' }); const page = await ctx.newPage(); diff --git a/src/extractors/responsive-screenshots.js b/src/extractors/responsive-screenshots.js index 5c30604..248d0ca 100644 --- a/src/extractors/responsive-screenshots.js +++ b/src/extractors/responsive-screenshots.js @@ -4,7 +4,7 @@ // component screenshots dir so output stays organised. Writes to // `screenshots/responsive/-.png` and returns an index. -import { chromium } from 'playwright'; +import { launchChromium } from '../browser.js'; import { mkdirSync } from 'fs'; import { join } from 'path'; @@ -16,7 +16,7 @@ const BREAKPOINTS = [ ]; async function captureAt(url, dir, bp, scheme, channel) { - const browser = await chromium.launch({ headless: true, ...(channel && { channel }) }); + const browser = await launchChromium({ headless: true, ...(channel && { channel }) }); try { const ctx = await browser.newContext({ viewport: { width: bp.width, height: bp.height }, diff --git a/src/extractors/responsive.js b/src/extractors/responsive.js index 76c2add..e77e167 100644 --- a/src/extractors/responsive.js +++ b/src/extractors/responsive.js @@ -1,6 +1,6 @@ // Responsive multi-breakpoint capture — extract at multiple viewports and diff -import { chromium } from 'playwright'; +import { launchChromium } from '../browser.js'; const VIEWPORTS = [ { name: 'mobile', width: 375, height: 812 }, @@ -11,7 +11,7 @@ const VIEWPORTS = [ export async function captureResponsive(url, options = {}) { const { wait = 0 } = options; - const browser = await chromium.launch({ headless: true }); + const browser = await launchChromium({ headless: true }); const snapshots = []; diff --git a/src/fidelity/run.js b/src/fidelity/run.js index 46b5374..de8f745 100644 --- a/src/fidelity/run.js +++ b/src/fidelity/run.js @@ -5,7 +5,7 @@ // The two fold into one combined report + a loss heatmap. Live browser + URLs, // so this is the integration layer; the scoring it calls is unit-tested. -import { chromium } from 'playwright'; +import { launchChromium } from '../browser.js'; import { extractDesignLanguage } from '../index.js'; import { diffPngBuffers, ratioToFidelity } from '../verify/diff.js'; import { scoreMotionFidelity } from './motion-fidelity.js'; @@ -55,7 +55,7 @@ export async function measureCloneFidelity({ originalUrl, cloneUrl, opts = {} } // Visual: pixel-diff full-page screenshots. let visualFidelity = null; let heatmap = null; - const browser = await chromium.launch({ headless: true, ...browserOpts }); + const browser = await launchChromium({ headless: true, ...browserOpts }); try { const [origShot, cloneShot] = await Promise.all([ fullPageShot(browser, originalUrl, opts), diff --git a/src/multipage.js b/src/multipage.js index baa83da..f88c2c8 100644 --- a/src/multipage.js +++ b/src/multipage.js @@ -11,7 +11,7 @@ // not re-implement extraction. That keeps this thin and lets every extractor // improve automatically when v10+ gains more signals. -import { chromium } from 'playwright'; +import { launchChromium } from './browser.js'; import { extractPageIntent } from './extractors/page-intent.js'; const PAGE_TYPE_RANK = [ @@ -198,7 +198,7 @@ export function computeCrossPageConsistency(pages) { export async function crawlCanonicalPages({ homepageUrl, homepageRawData, maxPages = 5, extract, crawlerOptions = {} }) { // `extract` is injected so we can reuse the full single-page pipeline without // circular imports (index.js → multipage.js → index.js). - const browser = await chromium.launch({ headless: true }); + const browser = await launchChromium({ headless: true }); const context = await browser.newContext({ viewport: { width: crawlerOptions.width || 1280, height: crawlerOptions.height || 800 }, colorScheme: 'light', diff --git a/src/pdf.js b/src/pdf.js index 45c4171..88bc0cf 100644 --- a/src/pdf.js +++ b/src/pdf.js @@ -1,4 +1,4 @@ -import { chromium } from 'playwright'; +import { launchChromium } from './browser.js'; import { writeFileSync } from 'fs'; export async function htmlToPdf(html, opts = {}) { @@ -12,7 +12,7 @@ export async function htmlToPdf(html, opts = {}) { } = opts; const format = String(paper).toLowerCase(); - const browser = await chromium.launch(); + const browser = await launchChromium(); try { const page = await browser.newPage(); await page.setContent(html, { waitUntil: 'networkidle' }); diff --git a/src/replay.js b/src/replay.js index 05c632d..7b4c448 100644 --- a/src/replay.js +++ b/src/replay.js @@ -6,7 +6,7 @@ // // Usage: designlang replay [--duration 5] [--out dir] -import { chromium } from 'playwright'; +import { launchChromium } from './browser.js'; import { mkdirSync, existsSync, readdirSync, statSync, renameSync, unlinkSync, rmdirSync } from 'fs'; import { resolve, join } from 'path'; import { spawnSync } from 'child_process'; @@ -35,7 +35,7 @@ export async function recordReplay(url, opts = {}) { const videoDir = join(outDir, `.playwright-video-${Date.now()}`); mkdirSync(videoDir, { recursive: true }); - const browser = await chromium.launch({ headless: true }); + const browser = await launchChromium({ headless: true }); const context = await browser.newContext({ viewport: { width, height }, recordVideo: { dir: videoDir, size: { width, height } }, diff --git a/src/site.js b/src/site.js index f36d145..db112bb 100644 --- a/src/site.js +++ b/src/site.js @@ -6,12 +6,12 @@ // caller and passed in, so we never re-extract it. Per-page failures are // recorded, never thrown — one dead route can't kill the run. -import { chromium } from 'playwright'; +import { launchChromium } from './browser.js'; import { discoverCanonicalPages, collectLinks } from './multipage.js'; import { synthesizeSite } from './site-synthesis.js'; export async function crawlSite({ homepageUrl, homepageDesign, maxPages = 6, extract, crawlerOptions = {}, onProgress }) { - const browser = await chromium.launch({ headless: true }); + const browser = await launchChromium({ headless: true }); const context = await browser.newContext({ viewport: { width: crawlerOptions.width || 1280, height: crawlerOptions.height || 800 }, colorScheme: 'light', diff --git a/src/verify/index.js b/src/verify/index.js index 6c4896d..46b909d 100644 --- a/src/verify/index.js +++ b/src/verify/index.js @@ -5,7 +5,7 @@ // diff against the real crop → fidelity %. Aggregate to a site score, with // per-token-family attribution so the number is explained, not asserted. -import { chromium } from 'playwright'; +import { launchChromium } from '../browser.js'; import { mkdirSync, writeFileSync } from 'fs'; import { join } from 'path'; import { extractDesignLanguage } from '../index.js'; @@ -68,7 +68,7 @@ export async function verifyDesign(url, opts = {}) { const design = opts.design || await extractDesignLanguage(url, opts.browserOpts || {}); const tokens = tokensFromDesign(design); - const browser = await chromium.launch({ headless: true, ...(opts.channel && { channel: opts.channel }) }); + const browser = await launchChromium({ headless: true, ...(opts.channel && { channel: opts.channel }) }); const results = []; try { const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 2, colorScheme: 'light' }); diff --git a/tests/browser.test.js b/tests/browser.test.js new file mode 100644 index 0000000..b6b731e --- /dev/null +++ b/tests/browser.test.js @@ -0,0 +1,56 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { launchChromium, INSTALL_HINT } from '../src/browser.js'; + +const missingBundled = () => new Error("browserType.launch: Executable doesn't exist at /tmp/chromium/chrome"); +const missingChrome = () => new Error("browserType.launch: Chromium distribution 'chrome' is not found at /opt/google/chrome/chrome"); + +function fakeChromium(results) { + const calls = []; + return { + calls, + async launch(opts) { + calls.push(opts); + const r = results[calls.length - 1]; + if (r instanceof Error) throw r; + return r; + }, + }; +} + +describe('launchChromium', () => { + it('uses the bundled browser when it is installed', async () => { + const chromium = fakeChromium([{ id: 'bundled' }]); + const browser = await launchChromium({ headless: true }, { chromium }); + assert.equal(browser.id, 'bundled'); + assert.equal(chromium.calls.length, 1); + }); + + it('falls back to system Chrome when the bundled browser is missing', async () => { + const chromium = fakeChromium([missingBundled(), { id: 'chrome' }]); + const browser = await launchChromium({ headless: true }, { chromium }); + assert.equal(browser.id, 'chrome'); + assert.deepEqual(chromium.calls[1], { headless: true, channel: 'chrome' }); + }); + + it('names the fix when no browser is available', async () => { + const chromium = fakeChromium([missingBundled(), missingChrome()]); + await assert.rejects(launchChromium({}, { chromium }), (err) => { + assert.equal(err.code, 'BROWSER_UNAVAILABLE'); + assert.ok(err.message.includes(INSTALL_HINT)); + return true; + }); + }); + + it('does not second-guess an explicit channel', async () => { + const chromium = fakeChromium([missingChrome()]); + await assert.rejects(launchChromium({ channel: 'chrome' }, { chromium }), { code: 'BROWSER_UNAVAILABLE' }); + assert.equal(chromium.calls.length, 1); + }); + + it('rethrows unrelated launch errors untouched', async () => { + const boom = new Error('Target page, context or browser has been closed'); + const chromium = fakeChromium([boom]); + await assert.rejects(launchChromium({}, { chromium }), (err) => err === boom); + }); +});