feat(install): drop the postinstall Chromium download; add install-browser and one launcher

postinstall downloaded Chromium on every npm install, which fails in CI, Docker
and behind proxies. Browsers are now installed on demand.

- designlang install-browser [--with-deps] installs the Chromium that matches
  the bundled playwright
- src/browser.js launchChromium(): bundled Chromium, then system Chrome, then a
  BROWSER_UNAVAILABLE error that names the fix; all 13 launch sites use it
- doctor, the extraction error and smithery.dockerfile point at install-browser
This commit is contained in:
Manav Arya Singh
2026-09-15 15:45:12 +04:00
parent ddd4ad1b2c
commit 037db816b4
17 changed files with 135 additions and 32 deletions
+22 -7
View File
@@ -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();
-1
View File
@@ -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"
+1 -1
View File
@@ -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"]
+32
View File
@@ -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;
}
}
+2 -1
View File
@@ -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
+2 -2
View File
@@ -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 },
+2 -2
View File
@@ -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();
+2 -2
View File
@@ -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();
+2 -2
View File
@@ -4,7 +4,7 @@
// component screenshots dir so output stays organised. Writes to
// `screenshots/responsive/<breakpoint>-<scheme>.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 },
+2 -2
View File
@@ -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 = [];
+2 -2
View File
@@ -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),
+2 -2
View File
@@ -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',
+2 -2
View File
@@ -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' });
+2 -2
View File
@@ -6,7 +6,7 @@
//
// Usage: designlang replay <url> [--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 } },
+2 -2
View File
@@ -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',
+2 -2
View File
@@ -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' });
+56
View File
@@ -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);
});
});