diff --git a/scripts/unlighthouse/chrome.ts b/scripts/unlighthouse/chrome.ts new file mode 100644 index 0000000..7407eb3 --- /dev/null +++ b/scripts/unlighthouse/chrome.ts @@ -0,0 +1,20 @@ +import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +/** + * GitHub's ubuntu-24.04 runners disable unprivileged user namespaces via + * AppArmor, so Chrome aborts at launch ("No usable sandbox!"). Returns a shell + * wrapper that runs `chrome` with --no-sandbox, for use as CHROME_PATH / + * PUPPETEER_EXECUTABLE_PATH. Only used in CI, where the browser loads our own + * static build and nothing untrusted. + */ +export function noSandboxWrapper(chrome: string): string { + const dir = mkdtempSync(join(tmpdir(), 'voidflow-chrome-')) + const wrapper = join(dir, 'chrome-no-sandbox') + // Single-quote the path for sh; escape any embedded single quotes. + const quoted = `'${chrome.replaceAll("'", `'\\''`)}'` + writeFileSync(wrapper, `#!/bin/sh\nexec ${quoted} --no-sandbox "$@"\n`) + chmodSync(wrapper, 0o755) + return wrapper +} diff --git a/scripts/unlighthouse/run.ts b/scripts/unlighthouse/run.ts index d20a03c..678cba2 100755 --- a/scripts/unlighthouse/run.ts +++ b/scripts/unlighthouse/run.ts @@ -17,6 +17,7 @@ import { existsSync, readdirSync, statSync } from 'node:fs' import { homedir } from 'node:os' import { join, resolve } from 'node:path' import { parseArgs } from 'node:util' +import { noSandboxWrapper } from './chrome.ts' const { values } = parseArgs({ options: { @@ -94,9 +95,21 @@ const site = `http://localhost:${server.port}` const chrome = findPlaywrightChromium() const env: Record = { ...process.env } if (chrome) { - env.CHROME_PATH = chrome - env.PUPPETEER_EXECUTABLE_PATH = chrome - console.log(`Using Chromium: ${chrome}`) + // CI runners (ubuntu-24.04) can't create Chrome's sandbox; see chrome.ts. + // Set VOIDFLOW_CHROME_SANDBOX=1 to keep the sandbox on. + const launcher = + process.platform === 'linux' && + process.env.CI && + !process.env.VOIDFLOW_CHROME_SANDBOX + ? noSandboxWrapper(chrome) + : chrome + env.CHROME_PATH = launcher + env.PUPPETEER_EXECUTABLE_PATH = launcher + console.log( + launcher === chrome + ? `Using Chromium: ${chrome}` + : `Using Chromium: ${chrome} (--no-sandbox for CI)`, + ) } else { console.log('No Playwright Chromium found; relying on system Chrome lookup.') } diff --git a/test/unlighthouse-chrome.test.ts b/test/unlighthouse-chrome.test.ts new file mode 100644 index 0000000..7fb67e4 --- /dev/null +++ b/test/unlighthouse-chrome.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'bun:test' +import { chmodSync, mkdtempSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { noSandboxWrapper } from '../scripts/unlighthouse/chrome.ts' + +// A stand-in "chrome" that prints the arguments it receives. +function fakeChrome(name = 'chrome') { + const dir = mkdtempSync(join(tmpdir(), 'voidflow-fake-')) + const bin = join(dir, name) + writeFileSync(bin, '#!/bin/sh\nfor a in "$@"; do echo "arg:$a"; done\n') + chmodSync(bin, 0o755) + return bin +} + +async function run(bin: string, ...args: string[]) { + const proc = Bun.spawn([bin, ...args], { stdout: 'pipe' }) + return (await new Response(proc.stdout).text()).trim().split('\n') +} + +describe('noSandboxWrapper', () => { + test('is executable and runs chrome with --no-sandbox first', async () => { + const wrapper = noSandboxWrapper(fakeChrome()) + expect(statSync(wrapper).mode & 0o111).not.toBe(0) + expect(await run(wrapper, '--headless', 'about:blank')).toEqual([ + 'arg:--no-sandbox', + 'arg:--headless', + 'arg:about:blank', + ]) + }) + + test('handles chrome paths with spaces and quotes', async () => { + const wrapper = noSandboxWrapper(fakeChrome("Google Chrome 'x'")) + expect(await run(wrapper, '--flag')).toEqual(['arg:--no-sandbox', 'arg:--flag']) + }) +})