159 lines
10 KiB
TypeScript
159 lines
10 KiB
TypeScript
// The one browser resolver (cli/shots/browser.ts): which executable each launch kind uses, checked against a fake
|
|
// Playwright cache layout, never launching a real browser. Regression: with only chromium_headless_shell-<rev> in the
|
|
// cache, `console fill --fresh-profile` reported "Executable doesn't exist ... chromium-<rev>" although Google Chrome
|
|
// was installed (the Chrome error was hidden behind the first one; that Chrome failed because an earlier run still
|
|
// held the profile).
|
|
import { afterAll, describe, expect, test } from 'bun:test';
|
|
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
import { hostname, tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { openConsoleBrowser } from '../cli/console/session';
|
|
import { chromiumFor, installedBrowsers, isInstalledPackage, launch, launchCandidates, launchPersistent, persistentExecutable, profileHolder } from '../cli/shots/browser';
|
|
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
|
|
const tmp = mkdtempSync(join(tmpdir(), 'sb-browser-resolve-'));
|
|
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
|
|
|
|
const CHROME = '/opt/google/chrome/chrome'; // channel "chrome" on linux
|
|
|
|
/** A Playwright cache with the given builds (dirs) and a fake chromium whose launches are recorded. */
|
|
function fakeCache(name: string, builds: string[], opts: { chrome?: boolean; fail?: (o: any) => Error | null } = {}) {
|
|
const cache = join(tmp, name);
|
|
for (const b of builds) {
|
|
const exe = b.startsWith('chromium-') ? join(cache, b, 'chrome-linux', 'chrome') : join(cache, b, 'chrome-linux', 'headless_shell');
|
|
mkdirSync(join(exe, '..'), { recursive: true });
|
|
writeFileSync(exe, '');
|
|
}
|
|
const fullPath = join(cache, 'chromium-1187', 'chrome-linux', 'chrome');
|
|
const shellPresent = builds.some(b => b.startsWith('chromium_headless_shell-'));
|
|
const calls: any[] = [];
|
|
const start = (o: any) => {
|
|
calls.push(o);
|
|
const f = opts.fail?.(o);
|
|
if (f) throw f;
|
|
// what Playwright does: no channel + headless -> the headless shell; channel chromium / no channel headed -> full build
|
|
const exe = o.executablePath ?? (o.channel === 'chrome' ? (opts.chrome ? CHROME : null) : (!o.channel && o.headless !== false) ? (shellPresent ? 'shell' : null) : existsSync(fullPath) ? fullPath : null);
|
|
if (!exe) throw new Error(`browserType.launch: Executable doesn't exist at ${fullPath}`);
|
|
return { exe, pages: () => [], newPage: async () => ({ close: async () => {} }), on: () => {}, close: async () => {} };
|
|
};
|
|
const pw = { executablePath: () => fullPath, launch: async (o: any) => start(o), launchPersistentContext: async (_dir: string, o: any) => start(o) };
|
|
const exists = (p: string) => p === CHROME ? !!opts.chrome : existsSync(p);
|
|
const installed = installedBrowsers(pw, {}, exists, 'linux');
|
|
return { pw, calls, installed };
|
|
}
|
|
|
|
describe('launch order', () => {
|
|
test('visible / persistent: Google Chrome, then full Chromium, never the headless shell', () => {
|
|
expect(launchCandidates('visible', { storeboundChrome: null, chrome: CHROME, chromium: '/c' }).map(c => c.opts)).toEqual([{ channel: 'chrome' }, { channel: 'chromium' }]);
|
|
expect(launchCandidates('visible', { storeboundChrome: null, chrome: null, chromium: null })).toEqual([]);
|
|
});
|
|
test('headless: headless shell, full Chromium, Google Chrome', () => {
|
|
expect(launchCandidates('headless', { storeboundChrome: null, chrome: CHROME, chromium: '/c' }).map(c => c.opts)).toEqual([{}, { channel: 'chromium' }, { channel: 'chrome' }]);
|
|
});
|
|
test('STOREBOUND_CHROME first for every kind but the Chrome-only copy mode', () => {
|
|
const i = { storeboundChrome: '/x/chrome', chrome: CHROME, chromium: null };
|
|
expect(launchCandidates('headless', i)[0]!.opts).toEqual({ executablePath: '/x/chrome' });
|
|
expect(launchCandidates('visible', i)[0]!.opts).toEqual({ executablePath: '/x/chrome' });
|
|
expect(launchCandidates('chrome', i).map(c => c.opts)).toEqual([{ channel: 'chrome' }]);
|
|
});
|
|
});
|
|
|
|
describe('fake Playwright cache', () => {
|
|
test('headless shell only + Google Chrome: console (persistent) uses Chrome, renders use the shell', async () => {
|
|
const f = fakeCache('shell-only', ['chromium_headless_shell-1187'], { chrome: true });
|
|
expect(f.installed.chromium).toBeNull();
|
|
const ctx = await launchPersistent(tmp, join(tmp, 'p1'), { headless: false }, { pw: f.pw, installed: f.installed });
|
|
expect(ctx.exe).toBe(CHROME);
|
|
expect(f.calls).toEqual([{ headless: false, channel: 'chrome' }]);
|
|
// a test asking for headless still gets a real Chrome, not the shell
|
|
const h = await launchPersistent(tmp, join(tmp, 'p1'), { headless: true }, { pw: f.pw, installed: f.installed });
|
|
expect(h.exe).toBe(CHROME);
|
|
const r = await launch(tmp, { headless: true }, { pw: f.pw, installed: f.installed });
|
|
expect(r.exe).toBe('shell');
|
|
});
|
|
test('full Chromium only: console uses it via channel "chromium"', async () => {
|
|
const f = fakeCache('full-only', ['chromium-1187']);
|
|
const ctx = await launchPersistent(tmp, join(tmp, 'p2'), { headless: true }, { pw: f.pw, installed: f.installed });
|
|
expect(ctx.exe).toBe(f.installed.chromium!);
|
|
expect(f.calls[0].channel).toBe('chromium');
|
|
});
|
|
test('headless render without the shell falls back to full Chromium', async () => {
|
|
const f = fakeCache('render-full', ['chromium-1187'], { chrome: true });
|
|
const r = await launch(tmp, { headless: true }, { pw: f.pw, installed: f.installed });
|
|
expect(r.exe).toBe(f.installed.chromium!);
|
|
});
|
|
test('nothing usable: a clear error naming what was looked for and the fix', async () => {
|
|
const f = fakeCache('empty', ['chromium_headless_shell-1187']);
|
|
const e = await launchPersistent(tmp, join(tmp, 'p3'), {}, { pw: f.pw, installed: f.installed }).catch(x => x as Error);
|
|
expect((e as Error).message).toContain('no usable browser');
|
|
expect((e as Error).message).toContain('Google Chrome');
|
|
expect((e as Error).message).toContain('never the headless shell');
|
|
expect((e as Error).message).toContain('bunx playwright install chromium');
|
|
expect((e as Error).message).toContain('storebound doctor');
|
|
expect(f.calls).toEqual([]);
|
|
});
|
|
test('an installed browser that fails reports its own error, not an earlier "Executable doesn\'t exist"', async () => {
|
|
const f = fakeCache('chrome-fails', ['chromium_headless_shell-1187'], { chrome: true, fail: o => o.channel === 'chrome' ? new Error('Target page, context or browser has been closed') : null });
|
|
const e = await launchPersistent(tmp, join(tmp, 'p4'), {}, { pw: f.pw, installed: f.installed }).catch(x => x as Error);
|
|
expect((e as Error).message).toMatch(/^Google Chrome .* did not start: Target page/);
|
|
expect((e as Error).message).not.toContain("Executable doesn't exist");
|
|
});
|
|
});
|
|
|
|
describe('profile held by another browser', () => {
|
|
test('a SingletonLock naming a live process is reported before any launch', async () => {
|
|
const dir = join(tmp, 'locked');
|
|
mkdirSync(dir);
|
|
symlinkSync(`${hostname()}-${process.pid}`, join(dir, 'SingletonLock'));
|
|
expect(profileHolder(dir)).toBe(process.pid);
|
|
const f = fakeCache('locked-cache', [], { chrome: true });
|
|
const e = await launchPersistent(tmp, dir, {}, { pw: f.pw, installed: f.installed }).catch(x => x as Error);
|
|
expect((e as Error).message).toContain(`is still open in another browser (process ${process.pid})`);
|
|
expect(f.calls).toEqual([]);
|
|
});
|
|
test('a stale lock (dead process, other host) is ignored', () => {
|
|
const dir = join(tmp, 'stale');
|
|
mkdirSync(dir);
|
|
symlinkSync(`${hostname()}-999999`, join(dir, 'SingletonLock'));
|
|
expect(profileHolder(dir, () => false)).toBeNull();
|
|
expect(profileHolder(dir, () => true, 'another-host')).toBeNull();
|
|
expect(profileHolder(join(tmp, 'no-such-dir'))).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('console fill', () => {
|
|
test('the window starts STOREBOUND_CHROME first, then Google Chrome, then full Chromium; the copy only Google Chrome', () => {
|
|
const i = { storeboundChrome: '/x/Chrome for Testing', chrome: CHROME, chromium: '/c' };
|
|
expect(persistentExecutable('visible', i).path).toBe('/x/Chrome for Testing');
|
|
expect(persistentExecutable('visible', { ...i, storeboundChrome: null }).path).toBe(CHROME);
|
|
expect(persistentExecutable('visible', { storeboundChrome: null, chrome: null, chromium: '/c' }).path).toBe('/c');
|
|
expect(persistentExecutable('chrome', i).path).toBe(CHROME);
|
|
expect(() => persistentExecutable('chrome', { ...i, chrome: null })).toThrow('Install Google Chrome.');
|
|
expect(() => persistentExecutable('visible', { storeboundChrome: null, chrome: null, chromium: null }, { STOREBOUND_CHROME: '/gone' })).toThrow('STOREBOUND_CHROME /gone: no such file');
|
|
});
|
|
test('openConsoleBrowser with nothing installed: the resolver error, nothing launched', async () => {
|
|
const f = fakeCache('none', []);
|
|
const e = await openConsoleBrowser(tmp, { store: 'apple', fresh: true, profileDir: join(tmp, 'sb-profile'), say: () => {}, launchDeps: { pw: f.pw, installed: { storeboundChrome: null, chrome: null, chromium: null } } }).catch(x => x as Error);
|
|
expect((e as Error).message).toContain('no usable browser');
|
|
expect(f.calls).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('playwright resolution', () => {
|
|
test('a project without playwright uses storebound\'s installed copy, never a Bun auto-install of the newest one', async () => {
|
|
// regression (Linux CI): `shots capture` in a temp project resolved playwright through Bun's auto-install (the
|
|
// newest version, in ~/.bun/install/cache) whose browser build was not downloaded: "headless shell: not installed"
|
|
expect(isInstalledPackage('/p/node_modules/playwright/index.mjs')).toBe(true);
|
|
expect(isInstalledPackage('C:\\p\\node_modules\\playwright\\index.mjs')).toBe(true);
|
|
expect(isInstalledPackage('/root/.bun/install/cache/playwright@1.64.0@@@1/index.mjs')).toBe(false);
|
|
const bare = mkdtempSync(join(tmpdir(), 'sb-no-playwright-'));
|
|
try {
|
|
// in a child whose cwd has no node_modules, like a real run in such a project (auto-install is on there)
|
|
const mod = JSON.stringify(join(import.meta.dir, '..', 'cli', 'shots', 'browser.ts'));
|
|
const r = Bun.spawnSync(['bun', '-e', `const { chromiumFor } = await import(${mod}); console.log((await chromiumFor(process.cwd())).executablePath());`], { cwd: bare });
|
|
const own = await chromiumFor(join(import.meta.dir, '..'));
|
|
expect(r.stdout.toString().trim()).toBe(own.executablePath());
|
|
} finally { rmSync(bare, { recursive: true, force: true }); }
|
|
});
|
|
});
|