diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml
index f92a44c..b686993 100644
--- a/.forgejo/workflows/ci.yml
+++ b/.forgejo/workflows/ci.yml
@@ -61,7 +61,16 @@ jobs:
rustup component add clippy
bun --version && cargo --version
bun install --frozen-lockfile
- - name: Headless Chromium (desktop boot check)
+ # Playwright's Chromium and headless shell (~280 MB), keyed by the locked playwright version; the system
+ # libraries and fonts (--with-deps) are installed on every run, the job container starts clean
+ - uses: actions/cache@v4
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
+ restore-keys: playwright-${{ runner.os }}-
+ # the job runs as root in a container: storebound adds --no-sandbox to the Chromium it starts itself there
+ # (cli/shots/browser.ts sandboxArgs), Playwright's own launches already pass it
+ - name: Headless Chromium (browser tests, desktop boot check)
run: bunx playwright install --with-deps chromium
- name: bun run ci
run: bun run ci
diff --git a/cli/console/session.ts b/cli/console/session.ts
index 7b262aa..bd8e99a 100644
--- a/cli/console/session.ts
+++ b/cli/console/session.ts
@@ -42,7 +42,7 @@
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
-import { chromiumFor, installedBrowsers, type LaunchDeps, persistentExecutable, profileHolder } from '../shots/browser';
+import { chromiumFor, installedBrowsers, type LaunchDeps, persistentExecutable, profileHolder, sandboxArgs } from '../shots/browser';
/* eslint-disable @typescript-eslint/no-explicit-any */
@@ -250,10 +250,12 @@ export const AUTOMATION_SWITCHES = ['--enable-automation', '--remote-debugging-p
/**
* The command line of the Chrome storebound starts itself for the console window: its own user data dir, a DevTools
* port the OS picks (written to
/DevToolsActivePort, loopback only), session restore, no automation markers, no
- * first-run dialogs. Nothing else: Chrome keeps its own user agent, keychain and updates.
+ * first-run dialogs; --no-sandbox only where Chromium cannot start without it (root on Linux, a container:
+ * sandboxArgs). Nothing else: Chrome keeps its own user agent, keychain and updates.
*/
-export function consoleChromeArgs(userDataDir: string, o: { headless?: boolean } = {}): string[] {
- return [`--user-data-dir=${userDataDir}`, '--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check', ...KEEP_SESSION_ARGS, ...NO_AUTOMATION_ARGS, ...(o.headless ? ['--headless=new'] : [])];
+export function consoleChromeArgs(userDataDir: string, o: { headless?: boolean; sandbox?: string[] } = {}): string[] {
+ return [`--user-data-dir=${userDataDir}`, '--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check', ...KEEP_SESSION_ARGS, ...NO_AUTOMATION_ARGS,
+ ...(o.sandbox ?? sandboxArgs()), ...(o.headless ? ['--headless=new'] : [])];
}
/** One new tab for storebound; the tabs the session restore brought back are closed (they would compete for "the active tab"). */
diff --git a/cli/shots/browser.ts b/cli/shots/browser.ts
index 8d46c76..6ce5358 100644
--- a/cli/shots/browser.ts
+++ b/cli/shots/browser.ts
@@ -60,6 +60,18 @@ export interface Candidate {
opts: Record;
}
+/**
+ * Switches for a Chromium storebound starts itself (Bun.spawn, not Playwright's launch, which already passes
+ * --no-sandbox unless chromiumSandbox is set): Chromium refuses to run as root on Linux without --no-sandbox
+ * ("Running as root without --no-sandbox is not supported", crbug.com/638180), and inside a Docker / Podman container
+ * its user-namespace sandbox is usually blocked by seccomp, so CI containers (the Forgejo runner runs jobs as root)
+ * need it too. Never on macOS or Windows, never for a normal user outside a container.
+ */
+export function sandboxArgs(platform: string = process.platform, uid: number | undefined = process.getuid?.(), inContainer: () => boolean = runsInContainer): string[] {
+ return platform === 'linux' && (uid === 0 || inContainer()) ? ['--no-sandbox'] : [];
+}
+const runsInContainer = (): boolean => existsSync('/.dockerenv') || existsSync('/run/.containerenv');
+
/** Where Playwright's channel "chrome" finds Google Chrome (stable). */
export function chromeChannelPaths(platform: string = process.platform, env: Record = process.env): string[] {
if (platform === 'darwin') return ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'];
diff --git a/tests/capture-web.test.ts b/tests/capture-web.test.ts
index cb617c8..2376cfe 100644
--- a/tests/capture-web.test.ts
+++ b/tests/capture-web.test.ts
@@ -98,6 +98,8 @@ function project(cfg: ShipConfig, files: Record): { root: string
return { root, setConfig, cli: async args => {
const r = Bun.spawn(['bun', CLI, 'shots', 'capture', ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, NO_COLOR: '1' } });
const [out, err, code] = await Promise.all([new Response(r.stdout).text(), new Response(r.stderr).text(), r.exited]);
+ // failed frames are warnings on stderr (exit 1): show them, or a CI failure only says which file is missing
+ if (code !== 0) console.error(`storebound shots capture ${args.join(' ')} (exit ${code}):\n${err}`);
return { out, err, code };
} };
}
diff --git a/tests/console-session.test.ts b/tests/console-session.test.ts
index 340dc54..48e3e03 100644
--- a/tests/console-session.test.ts
+++ b/tests/console-session.test.ts
@@ -11,6 +11,7 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { APPLE_COPY_NOTE, AUTOMATION_SWITCHES, browserPlan, chromeDataDir, consoleChromeArgs, CONSOLE_HOSTS, cookieApplies, copySession, devToolsEndpoint, filterCookies, GOOGLE_COPY_WARNING, GOOGLE_REJECTED, googleRejected, NEVER_COPY, openConsoleBrowser, parseUseChromeSession, pickProfile, PROVIDER_HOSTS, sessionFiles } from '../cli/console/session';
+import { sandboxArgs } from '../cli/shots/browser';
import { capped, CLOSE_TIMEOUT_MS, LAUNCH_TIMEOUT_MS } from './helpers/browser';
const scratch = mkdtempSync(join(tmpdir(), 'sb-session-test-'));
@@ -133,6 +134,17 @@ describe('Google session safety (device-bound cookies)', () => {
expect(a).toContain('--user-data-dir=/p');
expect(a.some(x => /user-agent/i.test(x))).toBe(false);
expect(consoleChromeArgs('/p', { headless: true })).toContain('--headless=new');
+ expect(consoleChromeArgs('/p', { sandbox: [] })).not.toContain('--no-sandbox');
+ expect(consoleChromeArgs('/p', { sandbox: ['--no-sandbox'] })).toContain('--no-sandbox');
+ });
+ test('--no-sandbox only for root on Linux or inside a container; never on macOS, Windows or for a normal user', () => {
+ const no = () => false, yes = () => true;
+ expect(sandboxArgs('linux', 0, no)).toEqual(['--no-sandbox']);
+ expect(sandboxArgs('linux', 1000, yes)).toEqual(['--no-sandbox']);
+ expect(sandboxArgs('linux', 1000, no)).toEqual([]);
+ expect(sandboxArgs('darwin', 0, yes)).toEqual([]);
+ expect(sandboxArgs('darwin', 501, no)).toEqual([]);
+ expect(sandboxArgs('win32', undefined, yes)).toEqual([]);
});
});