mirror of
https://github.com/Manavarya09/design-extract.git
synced 2026-09-19 02:41:14 +08:00
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:
+22
-7
@@ -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();
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user