diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d126d7..05a8323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- **`shots capture` / `render` find Playwright's chromium in a project's CommonJS playwright** whether it comes on the + namespace or under `default`; when the module has no `chromium.launch`, the error names the resolved file and its + exports (was "Cannot read properties of undefined (reading 'launch')"). + ## 0.8.18 ยท 2026-10-20 - **No more "Reparsing as ES module" warning** for a `.ts` config in a CommonJS project (Node's diff --git a/cli/shots/browser.ts b/cli/shots/browser.ts index 6176f67..e009c05 100644 --- a/cli/shots/browser.ts +++ b/cli/shots/browser.ts @@ -26,6 +26,9 @@ import { compiled } from '../lib/runner'; import * as rt from '../lib/rt'; // eslint-disable-next-line @typescript-eslint/no-explicit-any +/** A playwright module that resolved but does not have the API: named, so the error says which file it was. */ +class PlaywrightShapeError extends Error {} + async function chromium(root: string): Promise { for (const base of [root, rt.packageRoot()]) { try { @@ -34,8 +37,13 @@ async function chromium(root: string): Promise { // cache (~/.bun/install/cache), whose browser build was never downloaded ("headless shell: not installed" on // Linux CI, where no Google Chrome hides it); storebound's own copy is next if (!isInstalledPackage(p)) continue; - return (await rt.importPath(p)).chromium; - } catch { /* next */ } + // playwright's entry is CommonJS: depending on the runtime and version the ESM import puts chromium on the + // namespace or only under default (a project's playwright 1.57 under a global Bun install gave undefined) + const m = await rt.importPath(p); + const c = m?.chromium ?? m?.default?.chromium; + if (!c || typeof c.launch !== 'function') throw new PlaywrightShapeError(`playwright at ${p} has no chromium.launch (exports: ${Object.keys(m ?? {}).slice(0, 8).join(', ') || 'none'}${m?.default ? `; default: ${Object.keys(m.default).slice(0, 8).join(', ')}` : ''})`); + return c; + } catch (e) { if (e instanceof PlaywrightShapeError) throw e; /* next */ } } // only reached in the compiled sidecar: the bundled playwright-core (not a runtime dependency of the npm package) if (compiled()) return (await import('playwright-core')).chromium; diff --git a/tests/capture-review-account.test.ts b/tests/capture-review-account.test.ts index 6b4e418..934bed1 100644 --- a/tests/capture-review-account.test.ts +++ b/tests/capture-review-account.test.ts @@ -5,7 +5,7 @@ import { run as check } from '../cli/check'; import { BASE, capture, jsonOut, project, restore } from './helpers/store-mock'; afterEach(restore); -const withAuth = (baseUrl: string, ref: string) => ({ ...BASE, play: undefined, shots: { capture: { baseUrl, auth: { request: { url: '/api/login', method: 'POST', body: { email: `\${cred:${ref}}`, password: `\${cred:${ref.replace('username', 'password').replace('email', 'password')}}` } } } } } }) as typeof BASE; +const withAuth = (baseUrl: string, ref: string) => ({ ...BASE, play: undefined, shots: { capture: { baseUrl, auth: { request: { url: '/api/login', method: 'POST', body: { email: `\${cred:${ref}}`, password: `\${cred:${ref.replace('username', 'password').replace('email', 'password')}}` } } } } } }) as unknown as typeof BASE; const notes = async (cfg: typeof BASE) => { const p = project(cfg, { '.gitignore': '.storebound/\n' }); const j = jsonOut((await capture(() => check(p.ctx(['--json'], ['json'])))).out); return JSON.stringify(j); }; test('local capture with reviewDemoAccount gets a note; captureAuth or a remote stack does not', async () => { diff --git a/tests/playwright-resolve.test.ts b/tests/playwright-resolve.test.ts new file mode 100644 index 0000000..812476d --- /dev/null +++ b/tests/playwright-resolve.test.ts @@ -0,0 +1,28 @@ +// A project's playwright is CommonJS: its chromium may come through the ESM import only under `default`. A playwright +// 1.57 project under a global Bun install gave undefined and "Cannot read properties of undefined (reading 'launch')". +import { expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { chromiumFor } from '../cli/shots/browser'; + +const fakePlaywright = (body: string): string => { + const dir = mkdtempSync(join(tmpdir(), 'sb-pw-')); + const pkg = join(dir, 'node_modules', 'playwright'); + mkdirSync(pkg, { recursive: true }); + writeFileSync(join(pkg, 'package.json'), JSON.stringify({ name: 'playwright', version: '1.57.0', main: 'index.js' })); + writeFileSync(join(pkg, 'index.js'), body); + writeFileSync(join(dir, 'package.json'), '{"name":"app","private":true}'); + return dir; +}; + +test('chromium from a CommonJS playwright, named or under default; a wrong shape names the file', async () => { + const named = fakePlaywright('module.exports = { chromium: { launch() { return "ok"; } } };'); + const viaDefault = fakePlaywright('const api = { chromium: { launch() { return "ok"; } } }; module.exports = { __esModule: true, default: api };'); + const broken = fakePlaywright('module.exports = { firefox: {} };'); + try { + expect(typeof (await chromiumFor(named)).launch).toBe('function'); + expect(typeof (await chromiumFor(viaDefault)).launch).toBe('function'); + await expect(chromiumFor(broken)).rejects.toThrow(/node_modules[\\/]playwright[\\/]index\.js has no chromium\.launch/); + } finally { for (const d of [named, viaDefault, broken]) rmSync(d, { recursive: true, force: true }); } +});