Google binds its session cookies to the device (Device Bound Session Credentials) and signs out sessions whose cookies look stolen, which can sign the owner out of their real Chrome. The default is now storebound's own profile per store; storebound starts Chrome itself (--user-data-dir, --remote-debugging-port=0, no --enable-automation, navigator.webdriver false) and attaches over CDP. --use-chrome-session google|apple is an explicit opt-in that warns. /signin/rejected is said plainly.
189 lines
12 KiB
TypeScript
189 lines
12 KiB
TypeScript
// Playwright is an optional peer dependency of storebound, resolved from the project (or the storebound folder), so a
|
|
// project that never renders screenshots never downloads a browser. The compiled desktop sidecar carries its own
|
|
// playwright-core (tools/build-sidecars.ts bundles it) and renders with the Chrome for Testing the desktop app manages
|
|
// (STOREBOUND_CHROME), so desktop users need neither playwright nor a browser in their project.
|
|
// Every browser storebound launches goes through ONE resolver (`launchCandidates` / `withCandidates`), which checks the
|
|
// exact executable each candidate would use before trying it:
|
|
// - headless renders and captures: STOREBOUND_CHROME, Playwright's headless shell, Playwright's full Chromium
|
|
// (channel "chromium"), then Google Chrome (channel "chrome").
|
|
// - visible / persistent windows (`console fill`): STOREBOUND_CHROME, Google Chrome (channel "chrome"), then
|
|
// Playwright's full Chromium (channel "chromium"). Never the headless shell: it has no window, and it drops session
|
|
// cookies (no --restore-last-session), so a sign-in would not survive.
|
|
// - "chrome" (console fill's opt-in copy of the owner's Chrome sign-in: only real Google Chrome can decrypt those
|
|
// cookies with its own Keychain key): Google Chrome only.
|
|
// `console fill` itself does not launch through Playwright: it takes the executable from `persistentExecutable` (same
|
|
// order) and starts Chrome on its own (cli/console/session.ts), so the window carries no automation switches.
|
|
// A candidate that is not installed is skipped; one that IS installed but fails to start stops the search with its own
|
|
// error (an earlier "Executable doesn't exist" never hides it). A persistent profile another browser still holds (its
|
|
// SingletonLock names a live process) is reported as such before anything is launched.
|
|
// Sources (checked October 2026): Playwright BrowserType.launch / launchPersistentContext `channel` ("chrome";
|
|
// "chromium" = the full Chromium build, new headless mode) https://playwright.dev/docs/browsers#chromium-new-headless-mode ;
|
|
// Google Chrome channel locations https://playwright.dev/docs/browsers#google-chrome--microsoft-edge
|
|
import { existsSync, readlinkSync } from 'node:fs';
|
|
import { hostname } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { compiled } from '../lib/runner';
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
async function chromium(root: string): Promise<any> {
|
|
for (const base of [root, join(import.meta.dir, '../..')]) {
|
|
try {
|
|
const p = Bun.resolveSync('playwright', base);
|
|
return (await import(p)).chromium;
|
|
} catch { /* 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;
|
|
throw new Error('playwright not found: in your project run `bun add -d playwright` (and `bunx playwright install chromium` if Google Chrome is not installed)');
|
|
}
|
|
|
|
/** Playwright's chromium (same resolution as `launch`), for connectOverCDP. */
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export const chromiumFor = (root: string): Promise<any> => chromium(root);
|
|
|
|
/** What a launch is for: a headless render, a visible / persistent window, or real Google Chrome only. */
|
|
export type LaunchKind = 'headless' | 'visible' | 'chrome';
|
|
|
|
/** The executables on this machine (null: not there). */
|
|
export interface Installed {
|
|
/** STOREBOUND_CHROME when set and present. */
|
|
storeboundChrome: string | null;
|
|
/** Google Chrome where Playwright's channel "chrome" looks for it. */
|
|
chrome: string | null;
|
|
/** Playwright's full Chromium build (chromium.executablePath()) when present. */
|
|
chromium: string | null;
|
|
}
|
|
|
|
export interface Candidate {
|
|
label: string;
|
|
/** Merged into the launch options. */
|
|
opts: Record<string, unknown>;
|
|
}
|
|
|
|
/** Where Playwright's channel "chrome" finds Google Chrome (stable). */
|
|
export function chromeChannelPaths(platform: string = process.platform, env: Record<string, string | undefined> = process.env): string[] {
|
|
if (platform === 'darwin') return ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'];
|
|
if (platform === 'win32') return [env.LOCALAPPDATA, env.PROGRAMFILES, env['PROGRAMFILES(X86)']].filter((d): d is string => !!d).map(d => join(d, 'Google', 'Chrome', 'Application', 'chrome.exe'));
|
|
return ['/opt/google/chrome/chrome'];
|
|
}
|
|
|
|
/** The executables present, as the resolver sees them. `pw`: Playwright's chromium. */
|
|
export function installedBrowsers(
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
pw: any, env: Record<string, string | undefined> = process.env, exists: (p: string) => boolean = existsSync, platform: string = process.platform,
|
|
): Installed {
|
|
let full: string | null = null;
|
|
try { const p = pw?.executablePath?.(); if (typeof p === 'string' && p && exists(p)) full = p; } catch { /* not resolvable */ }
|
|
return {
|
|
storeboundChrome: env.STOREBOUND_CHROME && exists(env.STOREBOUND_CHROME) ? env.STOREBOUND_CHROME : null,
|
|
chrome: chromeChannelPaths(platform, env).find(p => exists(p)) ?? null,
|
|
chromium: full,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The launch order for `kind` (see the header), only candidates whose executable is present. The headless shell has
|
|
* no public path: it is always tried first for headless renders (skipped quietly when it is not downloaded).
|
|
*/
|
|
export function launchCandidates(kind: LaunchKind, i: Installed): Candidate[] {
|
|
const out: Candidate[] = [];
|
|
if (i.storeboundChrome && kind !== 'chrome') out.push({ label: `STOREBOUND_CHROME (${i.storeboundChrome})`, opts: { executablePath: i.storeboundChrome } });
|
|
const chrome: Candidate | null = i.chrome ? { label: `Google Chrome (${i.chrome})`, opts: { channel: 'chrome' } } : null;
|
|
const full: Candidate | null = i.chromium ? { label: `Playwright Chromium (${i.chromium})`, opts: { channel: 'chromium' } } : null;
|
|
if (kind === 'headless') {
|
|
out.push({ label: 'Playwright headless shell', opts: {} });
|
|
if (full) out.push(full);
|
|
if (chrome) out.push(chrome);
|
|
} else if (kind === 'visible') {
|
|
if (chrome) out.push(chrome);
|
|
if (full) out.push(full);
|
|
} else if (chrome) out.push(chrome);
|
|
return out;
|
|
}
|
|
|
|
const LOOKED_FOR: Record<LaunchKind, string> = {
|
|
headless: 'STOREBOUND_CHROME, Playwright\'s headless shell or Chromium, Google Chrome',
|
|
visible: 'STOREBOUND_CHROME, Google Chrome, Playwright\'s full Chromium; never the headless shell (no window, no kept sign-in)',
|
|
chrome: 'Google Chrome',
|
|
};
|
|
|
|
/** Playwright's "no such browser" errors (build not downloaded, Chrome channel not installed). */
|
|
export const notInstalled = (e: unknown): boolean =>
|
|
/Executable doesn't exist|distribution '[^']+' is not found|Failed to launch .* because executable/i.test((e as Error | undefined)?.message ?? '');
|
|
|
|
/**
|
|
* Tries the candidates of `kind` in order with `start(opts)`. A missing browser moves on; an installed browser that
|
|
* fails stops with its own error (labelled). Nothing usable: an error naming what was looked for, and the fix.
|
|
*/
|
|
export async function withCandidates<T>(kind: LaunchKind, i: Installed, start: (opts: Record<string, unknown>) => Promise<T>, env: Record<string, string | undefined> = process.env): Promise<T> {
|
|
const skipped: string[] = [];
|
|
if (env.STOREBOUND_CHROME && !i.storeboundChrome && kind !== 'chrome') skipped.push(`STOREBOUND_CHROME ${env.STOREBOUND_CHROME}: no such file`);
|
|
for (const c of launchCandidates(kind, i)) {
|
|
try { return await start(c.opts); } catch (e) {
|
|
if (notInstalled(e)) { skipped.push(`${c.label}: not installed`); continue; }
|
|
throw new Error(`${c.label} did not start: ${(e as Error).message.split('\n')[0]}`, { cause: e });
|
|
}
|
|
}
|
|
throw new Error(`no usable browser (looked for ${LOOKED_FOR[kind]})${skipped.length ? `; ${skipped.join('; ')}` : ''}. `
|
|
+ (kind === 'chrome' ? 'Install Google Chrome.' : 'Install Google Chrome, or run `bunx playwright install chromium` in your project; `storebound doctor` lists what is missing.'));
|
|
}
|
|
|
|
/**
|
|
* The live process (pid) that still holds a Chromium profile: its SingletonLock ("<host>-<pid>") names a running
|
|
* process on this machine. Else null (also on Windows, which has no symlink lock).
|
|
*/
|
|
export function profileHolder(userDataDir: string, alive: (pid: number) => boolean = pidAlive, host: string = hostname()): number | null {
|
|
let target: string;
|
|
try { target = readlinkSync(join(userDataDir, 'SingletonLock')); } catch { return null; }
|
|
const m = target.match(/^(.*)-(\d+)$/);
|
|
if (!m || m[1] !== host) return null;
|
|
const pid = Number(m[2]);
|
|
return alive(pid) ? pid : null;
|
|
}
|
|
function pidAlive(pid: number): boolean {
|
|
try { process.kill(pid, 0); return true; } catch (e) { return (e as NodeJS.ErrnoException).code === 'EPERM'; }
|
|
}
|
|
|
|
/** Test seam: Playwright's chromium and the installed browsers (default: resolved from `root` and this machine). */
|
|
export interface LaunchDeps {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
pw?: any;
|
|
installed?: Installed;
|
|
}
|
|
|
|
/**
|
|
* The executable `console fill` starts itself (no Playwright launch, so no automation switches): kind "visible" =
|
|
* STOREBOUND_CHROME, Google Chrome, then Playwright's full Chromium; kind "chrome" = Google Chrome only. Never the
|
|
* headless shell. Nothing installed: the same error as withCandidates.
|
|
*/
|
|
export function persistentExecutable(kind: 'visible' | 'chrome', i: Installed, env: Record<string, string | undefined> = process.env): { label: string; path: string } {
|
|
const list: { label: string; path: string }[] = [];
|
|
if (kind === 'visible' && i.storeboundChrome) list.push({ label: `STOREBOUND_CHROME (${i.storeboundChrome})`, path: i.storeboundChrome });
|
|
if (i.chrome) list.push({ label: `Google Chrome (${i.chrome})`, path: i.chrome });
|
|
if (kind === 'visible' && i.chromium) list.push({ label: `Playwright Chromium (${i.chromium})`, path: i.chromium });
|
|
if (list[0]) return list[0];
|
|
const skipped = env.STOREBOUND_CHROME && !i.storeboundChrome && kind !== 'chrome' ? `; STOREBOUND_CHROME ${env.STOREBOUND_CHROME}: no such file` : '';
|
|
throw new Error(`no usable browser (looked for ${LOOKED_FOR[kind]})${skipped}. `
|
|
+ (kind === 'chrome' ? 'Install Google Chrome.' : 'Install Google Chrome, or run `bunx playwright install chromium` in your project; `storebound doctor` lists what is missing.'));
|
|
}
|
|
|
|
/** A browser for renders and captures (headless unless `headless: false`). */
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export async function launch(root: string, opts: Record<string, unknown>, deps: LaunchDeps = {}): Promise<any> {
|
|
const c = deps.pw ?? await chromium(root);
|
|
const kind: LaunchKind = opts.headless === false ? 'visible' : 'headless';
|
|
return withCandidates(kind, deps.installed ?? installedBrowsers(c), o => c.launch({ ...opts, ...o }));
|
|
}
|
|
|
|
/**
|
|
* A persistent browser profile (cookies of a console sign-in the owner did by hand stay there between runs; storebound
|
|
* never reads them). Used by `storebound console fill`: always a real Chrome / Chromium (kind "visible", also when a
|
|
* test asks for headless), or Google Chrome only (kind "chrome", the copy of the owner's sign-in).
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export async function launchPersistent(root: string, userDataDir: string, opts: Record<string, unknown>, deps: LaunchDeps & { kind?: 'visible' | 'chrome' } = {}): Promise<any> {
|
|
const holder = profileHolder(userDataDir);
|
|
if (holder) throw new Error(`the browser profile ${userDataDir} is still open in another browser (process ${holder}), probably an earlier storebound run that waits for its window to close. Close that window (or end that run) and start again`);
|
|
const c = deps.pw ?? await chromium(root);
|
|
return withCandidates(deps.kind ?? 'visible', deps.installed ?? installedBrowsers(c), o => c.launchPersistentContext(userDataDir, { ...opts, ...o }));
|
|
}
|