test(harness): Tier 2 runners (9 single-launch / hermetic-auth probes)

Single launchClaude() + inspector + Electron-API or window-state
assertion. Each runner asserts a contract that requires the app to
actually be running.

Specs landed:

- T05 — claude:// URL delivers via app.on('second-instance')
  (Tier 3 delivery probe: xdg-open fires the URL, the running app's
  hook captures it). Uses isolation: null because the SingletonLock
  collision must route to the same user-data dir.
- T06 — globalShortcut.isRegistered('Ctrl+Alt+Space') returns true
  after waitForReady('mainVisible')
- T07 — five topbar buttons render with non-zero rects. First spec
  to exercise createIsolation({ seedFromHost: true }) — kills host
  Claude, copies auth allowlist (Cookies, Local State, Local Storage,
  IndexedDB, etc.) into per-test tmpdir, runs hermetically against
  signed-in account, tmpdir destroyed on close.
- T08 — MainWindow.setState('close') fires the wrapper's close
  interceptor; window hidden, proc still alive
- T09 — setLoginItemSettings({ openAtLogin }) writes/removes
  $XDG_CONFIG_HOME/autostart/claude-desktop.desktop
- T12 — app.getGPUFeatureStatus() returns populated object;
  reaching mainVisible proves the renderer didn't crash
- T14b — second invocation under same isolation exits cleanly via
  requestSingleInstanceLock early-return; primary pid stays alive
- S07 — under CLAUDE_HARNESS_USE_WAYLAND=1, spawned Electron has
  --ozone-platform=wayland on argv (skips when env unset)
- S17 — shell-path-worker overlays the user's login-shell PATH onto
  a deliberately-scrubbed env. Re-forks shellPathWorker.js via
  utilityProcess.fork + MessageChannelMain to observe the worker
  output directly (the main-process FX() merger only fills undefined
  keys, so reading process.env.PATH after a non-undefined override
  wouldn't observe the effect).

T05 originally planned as a Tier 2 isDefaultProtocolClient probe
but reshaped — that runtime call is a no-op in the harness because
ELECTRON_FORCE_IS_PACKAGED=true makes app.getName() resolve to
"Claude" (not "claude-desktop"), so the xdg-mime shellout fails
silently. Real registration is install-time via the .desktop file
MimeType= line. T05 ships as the delivery probe instead.

T07 originally deferred to Tier 3 ("topbar is React-rendered SPA")
but the harness's seedFromHost primitive (isolation.ts:37-44, never
exercised before this commit) lifts it back to Tier 2.

Co-Authored-By: Claude <claude@anthropic.com>
This commit is contained in:
aaddrick
2026-05-03 14:42:32 -04:00
parent bebe83d194
commit 11ab62afcd
9 changed files with 1514 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
import { test, expect } from '@playwright/test';
import { launchClaude } from '../lib/electron.js';
import { skipUnlessRow } from '../lib/row.js';
import { readPidArgv, argvHasFlag } from '../lib/argv.js';
import { readLauncherLog, captureSessionEnv } from '../lib/diagnostics.js';
import { retryUntil } from '../lib/retry.js';
// S07 — `CLAUDE_USE_WAYLAND=1` opt-in path works.
//
// Backs S07 in docs/testing/cases/shortcuts-and-input.md.
//
// Case-doc anchors:
// scripts/launcher-common.sh:28-29 — `CLAUDE_USE_WAYLAND=1` opt-in
// (sets `use_x11_on_wayland=false`, taking the native-Wayland
// branch in build_electron_args).
// scripts/launcher-common.sh:100-111 — native-Wayland Electron flags:
// `--enable-features=UseOzonePlatform,WaylandWindowDecorations`,
// `--ozone-platform=wayland`, `--enable-wayland-ime`,
// `--wayland-text-input-version=3`, plus `GDK_BACKEND=wayland`.
//
// What this asserts: when the harness's Wayland mode is engaged
// (`CLAUDE_HARNESS_USE_WAYLAND=1`), the spawned Electron's argv
// contains `--ozone-platform=wayland` and `CLAUDE_USE_WAYLAND=1` is
// exported into the spawn env. That mirrors the launcher's
// CLAUDE_USE_WAYLAND=1 branch — same flag set is emitted (see
// LAUNCHER_INJECTED_FLAGS_WAYLAND in src/lib/electron.ts:134-141).
//
// Gating choice — harness-mode vs launcher-script:
//
// The harness deliberately bypasses the launcher script (CDP-gate
// reasons — see lib/electron.ts:102-117), so it constructs its own
// flag set. Setting `extraEnv: { CLAUDE_USE_WAYLAND: '1' }` would
// only affect the child env, not the harness's flag selector. To
// exercise the Wayland branch end-to-end the harness exposes
// `CLAUDE_HARNESS_USE_WAYLAND=1`, which:
// 1. swaps to LAUNCHER_INJECTED_FLAGS_WAYLAND (the same flag
// set the launcher's Wayland branch emits), and
// 2. exports `CLAUDE_USE_WAYLAND=1` + `GDK_BACKEND=wayland` into
// the child env.
//
// This test asserts that contract. When CLAUDE_HARNESS_USE_WAYLAND
// is unset we skip — the harness's X11 default doesn't model the
// CLAUDE_USE_WAYLAND opt-in path. Run the suite with
// `CLAUDE_HARNESS_USE_WAYLAND=1 npx playwright test ...` to
// activate the assertion.
//
// Row gate: native-Wayland-capable rows only. KDE-W is intentionally
// included even though the case-doc Applies-to lists wlroots rows
// (Sway/Niri/Hypr) — KDE Plasma Wayland can also run native Wayland
// when CLAUDE_USE_WAYLAND=1 is set, and KDE-W is the harness's CI
// row, so we want this to be exercisable there.
test.setTimeout(45_000);
test('S07 — CLAUDE_USE_WAYLAND opt-in surfaces in Electron argv', async ({}, testInfo) => {
testInfo.annotations.push({ type: 'severity', description: 'Should' });
testInfo.annotations.push({
type: 'surface',
description: 'Display backend / Wayland opt-in',
});
skipUnlessRow(testInfo, [
'Sway',
'Niri',
'Hypr-O',
'Hypr-N',
'GNOME-W',
'KDE-W',
]);
if (process.env.CLAUDE_HARNESS_USE_WAYLAND !== '1') {
test.skip(
true,
'S07 requires CLAUDE_HARNESS_USE_WAYLAND=1 (the harness ' +
'Wayland-mode that mirrors the launcher CLAUDE_USE_WAYLAND ' +
'branch). Re-run with the env set.',
);
return;
}
await testInfo.attach('session-env', {
body: JSON.stringify(captureSessionEnv(), null, 2),
contentType: 'application/json',
});
await testInfo.attach('harness-env', {
body: JSON.stringify(
{
CLAUDE_HARNESS_USE_WAYLAND:
process.env.CLAUDE_HARNESS_USE_WAYLAND ?? null,
CLAUDE_USE_WAYLAND: process.env.CLAUDE_USE_WAYLAND ?? null,
},
null,
2,
),
contentType: 'application/json',
});
const useHostConfig = process.env.CLAUDE_TEST_USE_HOST_CONFIG === '1';
const app = await launchClaude({
isolation: useHostConfig ? null : undefined,
});
try {
// Don't waitForX11Window — under native Wayland the app is
// going through Ozone-Wayland directly, no XWayland window
// appears. /proc/$pid/cmdline is populated by exec(), so we
// just need the spawned Electron to stay alive long enough
// to read it. Poll for non-null + non-empty argv.
const argv = await retryUntil(
async () => {
const a = await readPidArgv(app.pid);
return a && a.length > 0 ? a : null;
},
{ timeout: 15_000, interval: 250 },
);
await testInfo.attach('electron-argv', {
body: JSON.stringify(argv, null, 2),
contentType: 'application/json',
});
expect(argv, 'could read /proc/$pid/cmdline').not.toBeNull();
// Launcher log is only populated when the launcher script
// runs; the harness spawns Electron directly. Capture the
// log if it happens to exist (host-leftover from an earlier
// real-launcher run) for diagnostic context only.
const log = await readLauncherLog();
if (log) {
const tail = log.split('\n').slice(-50).join('\n');
await testInfo.attach('launcher-log-tail', {
body: tail,
contentType: 'text/plain',
});
}
const ozoneWayland = argvHasFlag(argv ?? [], '--ozone-platform=wayland');
const useOzone = argvHasFlag(
argv ?? [],
'--enable-features=UseOzonePlatform',
);
await testInfo.attach('flag-presence', {
body: JSON.stringify(
{
'--ozone-platform=wayland': ozoneWayland,
'--enable-features=UseOzonePlatform': useOzone,
note:
'When CLAUDE_HARNESS_USE_WAYLAND=1 the harness ' +
'must emit the same Electron flag set as the ' +
'launcher script\'s CLAUDE_USE_WAYLAND=1 branch.',
},
null,
2,
),
contentType: 'application/json',
});
expect(
ozoneWayland,
'spawned Electron has --ozone-platform=wayland on argv',
).toBe(true);
expect(
useOzone,
'spawned Electron has --enable-features=UseOzonePlatform ' +
'(co-emitted with the wayland ozone flag)',
).toBe(true);
} finally {
await app.close();
}
});

View File

@@ -0,0 +1,233 @@
import { test, expect } from '@playwright/test';
import { launchClaude } from '../lib/electron.js';
import { captureSessionEnv } from '../lib/diagnostics.js';
// S17 — App launched from `.desktop` inherits shell-profile PATH.
//
// Upstream's shell-path-worker (`shellPathWorker.js`) is forked at
// `app.on('ready')` and runs the user's login shell with `-l -i`,
// printing PATH between sentinels (mac-style env inheritance, now
// applied on Linux too — see index.js:259300 for SLr() / NLr() and
// shellPathWorker.js:205 for extractPathFromShell()).
//
// We launch the app with a deliberately-scrubbed PATH so the
// worker's contribution is visible against a clean baseline. We
// CANNOT just read `process.env.PATH` afterwards: the merge in
// FX() (`index.js:259311`) is gated on `process.env[A] === void 0`,
// so a caller-provided PATH is never overwritten by the worker.
// The bundled f2t module is closure-scoped and not reachable from
// outside.
//
// Workaround: from the inspector we re-fork the same shell-path
// worker via `utilityProcess.fork`, mirroring NLr() exactly, and
// observe the worker's `envResult` message. That gives us the
// worker's resolved PATH directly — same machinery the app uses,
// but with an observable result port.
// Scrubbed baseline: enough system paths for Electron to find its
// helper binaries (zygote, GPU, sandbox shim) but with no user-profile
// entries (`~/.local/bin`, `~/.npm-global/bin`, `~/bin`, `~/.cargo/bin`,
// etc.). Going tighter (e.g. `/usr/bin:/bin`) starves the renderer of
// system tools and the main window never reports visible.
const SCRUBBED_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
interface WorkerResult {
ok: boolean;
path?: string;
error?: string;
durationMs: number;
}
test('S17 — App inherits shell-profile PATH on `.desktop` invocation', async ({}, testInfo) => {
// App startup (~5-10s) + inspector attach (~1s) + login-shell PATH
// extraction (1-3s; can be 5s on a cold zsh w/ oh-my-zsh) + slack.
test.setTimeout(150_000);
testInfo.annotations.push({ type: 'severity', description: 'Critical' });
testInfo.annotations.push({
type: 'surface',
description: 'Shell PATH / shell-path worker',
});
// Worker is gated on SHELL existing + pointing at a real binary
// (`shellPathWorker.js:187` getSafeShell()). On hosts without a
// SHELL we have nothing to assert — skip rather than false-fail.
if (!process.env.SHELL) {
testInfo.skip(true, 'SHELL unset on host — shell-path worker has no shell to fork');
return;
}
await testInfo.attach('host-session-env', {
body: JSON.stringify(
{
...captureSessionEnv(),
SHELL: process.env.SHELL,
HOME: process.env.HOME,
},
null,
2,
),
contentType: 'application/json',
});
await testInfo.attach('scrubbed-path', {
body: SCRUBBED_PATH,
contentType: 'text/plain',
});
const app = await launchClaude({
extraEnv: { PATH: SCRUBBED_PATH },
});
try {
const { inspector } = await app.waitForReady('mainVisible');
// Capture what the main process sees as PATH right after
// startup. By the FX()-merge contract this should equal the
// scrubbed value (caller-provided PATH wins over worker
// merge); we attach it for diagnostic completeness so a
// future regression where the merge starts overwriting is
// visible against this anchor.
const mainProcessPath = await inspector.evalInMain<string>(`
return process.env.PATH || '';
`);
await testInfo.attach('main-process-path', {
body: mainProcessPath,
contentType: 'text/plain',
});
// Fork the shell-path worker the app ships with, mirroring
// NLr() at index.js:259349. utilityProcess.fork + a
// MessageChannelMain pair, init the worker, request
// 'getEnvironment', read back the envResult.PATH. The
// worker runs the user's login shell which can take 1-3s on
// a cold zsh — budget 10s to absorb that plus fork latency.
// One bounded shot, no retry: a worker hang or dead-spawn
// here is a real failure, not a transient.
const workerResult = await inspector.evalInMain<WorkerResult>(
`
const path = process.mainModule.require('node:path');
const fs = process.mainModule.require('node:fs');
const { utilityProcess, MessageChannelMain } =
process.mainModule.require('electron');
const workerPath = path.join(
process.resourcesPath,
'app.asar',
'.vite',
'build',
'shell-path-worker',
'shellPathWorker.js',
);
if (!fs.existsSync(workerPath)) {
return {
ok: false,
error: 'worker not found at ' + workerPath,
durationMs: 0,
};
}
const start = Date.now();
return await new Promise((resolve) => {
let done = false;
const child = utilityProcess.fork(workerPath, [], {
serviceName: 'S17 shell-path probe',
});
const { port1, port2 } = new MessageChannelMain();
const finish = (v) => {
if (done) return;
done = true;
clearTimeout(timer);
try { port1.close(); } catch (_) {}
try { child.kill(); } catch (_) {}
resolve({ ...v, durationMs: Date.now() - start });
};
const timer = setTimeout(() => finish({
ok: false,
error: 'worker probe timed out after 10000ms',
}), 10000);
port1.on('message', (e) => {
if (e.data && e.data.type === 'envResult') {
finish({
ok: true,
path: (e.data.env && e.data.env.PATH) || '',
});
} else if (e.data && e.data.type === 'error') {
finish({ ok: false, error: e.data.message });
}
});
port1.start();
child.once('spawn', () => {
child.postMessage({ type: 'init' }, [port2]);
port1.postMessage({ type: 'getEnvironment' });
});
child.once('exit', (code) => {
finish({
ok: false,
error: 'worker exited before envResult, code=' + code,
});
});
});
`,
15_000,
);
await testInfo.attach('worker-result', {
body: JSON.stringify(workerResult, null, 2),
contentType: 'application/json',
});
expect(
workerResult.ok,
`shell-path worker fork succeeded (error=${workerResult.error})`,
).toBe(true);
const settledPath = workerResult.path ?? '';
await testInfo.attach('settled-path', {
body: settledPath,
contentType: 'text/plain',
});
// Diff the segments so the failure log shows exactly what
// the worker contributed (or didn't).
const scrubbedSet = new Set(SCRUBBED_PATH.split(':'));
const settledSegments = settledPath.split(':').filter(Boolean);
const added = settledSegments.filter((s) => !scrubbedSet.has(s));
await testInfo.attach('path-diff', {
body: JSON.stringify(
{
scrubbed: SCRUBBED_PATH.split(':'),
settled: settledSegments,
added,
},
null,
2,
),
contentType: 'application/json',
});
// If the host's shell rc adds nothing to PATH (clean
// install, no profile customisations) the worker has
// nothing to surface and the assertion below would
// false-fail. Skip with a clear note rather than fail.
if (settledPath === SCRUBBED_PATH || added.length === 0) {
testInfo.skip(
true,
'host shell profile contributes no PATH additions ' +
'beyond the scrubbed baseline — worker has nothing to ' +
'extract on this host',
);
return;
}
expect(
settledPath,
'worker-resolved PATH expanded beyond the scrubbed baseline',
).not.toBe(SCRUBBED_PATH);
expect(
added.length,
'worker added at least one PATH segment from shell profile',
).toBeGreaterThan(0);
} finally {
await app.close();
}
});

View File

@@ -0,0 +1,214 @@
import { test, expect } from '@playwright/test';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { launchClaude } from '../lib/electron.js';
import { killHostClaude } from '../lib/host-claude.js';
import { retryUntil } from '../lib/retry.js';
import { captureSessionEnv } from '../lib/diagnostics.js';
const exec = promisify(execFile);
// T05 — `claude://` URL delivers to the running app via xdg-open.
//
// Tier-3 delivery probe. The earlier Tier-2 attempt
// (`app.isDefaultProtocolClient('claude')`) doesn't work in the
// harness: ELECTRON_FORCE_IS_PACKAGED=true makes `app.getName()`
// resolve to `Claude`, so the runtime registration call is a no-op
// and the API can't tell us anything useful. Instead we drive the
// real OS path: install a `second-instance` listener in the main
// process, fire `xdg-open 'claude://test/<marker>'` from a separate
// process, and verify the URL appears in the captured argv.
//
// Routing: `xdg-open` resolves `x-scheme-handler/claude` to
// `claude-desktop.desktop` and execs claude-desktop. The new
// process calls `app.requestSingleInstanceLock()` (upstream
// build-reference/app-extracted/.vite/build/index.js:525162),
// loses to our running instance, and the primary's
// `app.on('second-instance', ...)` handler at index.js:525163-525172
// fires with the spawned child's argv. The URL is in that argv —
// `uPn(t)` extracts it and routes to `fCA(r)` → `bEe(...)`.
//
// Why isolation: null. xdg-open's spawn always lands under the
// user's `~/.config/Claude` (the SingletonLock path is fixed in
// `app.getPath('userData')`, derived from XDG_CONFIG_HOME at
// child-process spawn time — we can't influence the spawned
// child's env from here). For the SingletonLock collision to route
// the URL to OUR instance, OUR instance must hold the lock at
// `~/.config/Claude/SingletonLock`. Default isolation gives us a
// tmpdir lock, so xdg-open's child wouldn't collide with us — it'd
// either start as a fresh primary (if no host claude-desktop is
// running) or route to the host's actual claude-desktop. Sharing
// host config is the only way the second-instance hook fires.
//
// Side effect: this test runs against the real `~/.config/Claude`
// and any host claude-desktop must be killed first. The URL is a
// synthetic `claude://test/<marker>` that hits `bEe()`'s default
// branch (no Preview/Hotkey/DebugHandoff host match) — no
// navigation, no destructive side effect.
test.setTimeout(60_000);
test('T05 — claude:// URL delivers to running app via xdg-open', async ({}, testInfo) => {
testInfo.annotations.push({ type: 'severity', description: 'Smoke' });
testInfo.annotations.push({
type: 'surface',
description: 'URL scheme / protocol delivery',
});
// Skip cleanly when the prerequisites aren't on this host.
try {
await exec('which', ['xdg-open']);
} catch {
test.skip(true, 'xdg-open not available');
return;
}
const xdgMime = await exec('xdg-mime', [
'query',
'default',
'x-scheme-handler/claude',
])
.then((r) => r.stdout.trim())
.catch(() => '');
if (!xdgMime.includes('claude-desktop')) {
test.skip(
true,
`claude:// not registered as default scheme handler (xdg-mime: "${xdgMime}")`,
);
return;
}
await testInfo.attach('xdg-mime', {
body: xdgMime,
contentType: 'text/plain',
});
await testInfo.attach('session-env', {
body: JSON.stringify(captureSessionEnv(), null, 2),
contentType: 'application/json',
});
// xdg-open's spawned child binds the SingletonLock at
// `~/.config/Claude/SingletonLock`; we must hold that lock so
// the child loses and routes via second-instance instead of
// becoming a fresh primary. Kill any host instance first, then
// launch with `isolation: null` so OUR XDG_CONFIG_HOME matches
// the child's.
await killHostClaude();
const app = await launchClaude({ isolation: null });
const marker = `t05-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 8)}`;
const url = `claude://test/${marker}`;
try {
const { inspector } = await app.waitForReady('mainVisible');
// Install a main-process hook that captures every
// second-instance payload into a global. The handler
// signature is (event, argv, cwd, additionalData) per
// Electron docs and the upstream call site at index.js
// :525163.
await inspector.evalInMain<null>(`
const { app } = process.mainModule.require('electron');
global.__T05_argvCaptures = global.__T05_argvCaptures || [];
if (!global.__T05_handlerInstalled) {
app.on('second-instance', (event, argv, cwd) => {
global.__T05_argvCaptures.push({
argv,
cwd,
ts: Date.now(),
});
});
global.__T05_handlerInstalled = true;
}
return null;
`);
// Fire the URL from a separate process. xdg-open execs
// claude-desktop with the URL on argv; that child loses
// the SingletonLock to us and routes via second-instance.
// Capture exec output so a failure mode where xdg-open
// itself errored shows up in the attached diagnostics.
let xdgOpenStdout = '';
let xdgOpenStderr = '';
let xdgOpenError: string | null = null;
try {
const r = await exec('xdg-open', [url], { timeout: 10_000 });
xdgOpenStdout = r.stdout;
xdgOpenStderr = r.stderr;
} catch (err) {
const e = err as {
stdout?: string;
stderr?: string;
message?: string;
};
xdgOpenStdout = e.stdout ?? '';
xdgOpenStderr = e.stderr ?? '';
xdgOpenError = e.message ?? String(err);
}
// Poll the captured argv list until our marker shows up.
// 10s is generous: xdg-open returns immediately, the spawned
// claude-desktop reaches `app.on('ready', ...)` in ~2-4s on
// a warm cache, and `requestSingleInstanceLock()` losing
// fires the parent's second-instance synchronously.
interface Capture {
argv: string[];
cwd: string;
ts: number;
}
const captured = await retryUntil<Capture>(
async () => {
const dump = await inspector.evalInMain<Capture[]>(`
return global.__T05_argvCaptures || [];
`);
return (
dump.find((c) =>
(c.argv ?? []).some((a) => a.includes(marker)),
) ?? null
);
},
{ timeout: 10_000, interval: 250 },
);
const allCaptures = await inspector.evalInMain<Capture[]>(`
return global.__T05_argvCaptures || [];
`);
await testInfo.attach('marker', {
body: marker,
contentType: 'text/plain',
});
await testInfo.attach('url', {
body: url,
contentType: 'text/plain',
});
await testInfo.attach(
'xdg-open',
{
body: JSON.stringify(
{
stdout: xdgOpenStdout,
stderr: xdgOpenStderr,
error: xdgOpenError,
},
null,
2,
),
contentType: 'application/json',
},
);
await testInfo.attach('captured-second-instance', {
body: JSON.stringify(allCaptures, null, 2),
contentType: 'application/json',
});
expect(
captured,
`second-instance handler should fire with argv containing "${marker}"`,
).toBeTruthy();
} finally {
await app.close();
}
});

View File

@@ -0,0 +1,83 @@
import { test, expect } from '@playwright/test';
import { launchClaude } from '../lib/electron.js';
import { captureSessionEnv } from '../lib/diagnostics.js';
// T06 — Quick Entry global shortcut is registered after main visible.
//
// Tier 2 form of T06 (case-doc:
// docs/testing/cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused).
// The shortcut-delivery half (press → popup appears) is covered by
// S29 (lazy-create from tray), S30 (post-exit no-op), and S31 (submit
// reaches new chat). T06 here is purely the registration-state probe:
// after the app is visible, `globalShortcut.isRegistered(accelerator)`
// must return true. Registration succeeds even on portal-grabbed
// Wayland sessions; only delivery is portal-gated, so this assertion
// applies to all rows.
//
// Accelerator string is hardcoded to "Ctrl+Alt+Space" per the
// case-doc Code anchor (build-reference index.js:499376 — `ort`
// default accelerator: `"Ctrl+Alt+Space"` non-mac, `"Alt+Space"` on
// mac). Linux always takes the non-mac branch. If the user remaps
// the shortcut via Settings, this test would fail; the harness
// always launches into a fresh isolated config (no remap).
// 90s test timeout matches waitForReady's own default budget — main
// visibility on a fresh isolation can take ~30-50s on a cold cache
// (Electron unpack + claude.ai initial nav).
test.setTimeout(90_000);
test('T06 — Quick Entry global shortcut is registered after main visible', async ({}, testInfo) => {
testInfo.annotations.push({ type: 'severity', description: 'Critical' });
testInfo.annotations.push({
type: 'surface',
description: 'Quick Entry / global shortcut',
});
// No skipUnlessRow — applies to all rows. Registration succeeds
// even where delivery is portal-gated; T06's contract is the
// registration state alone.
await testInfo.attach('session-env', {
body: JSON.stringify(captureSessionEnv(), null, 2),
contentType: 'application/json',
});
const useHostConfig = process.env.CLAUDE_TEST_USE_HOST_CONFIG === '1';
const app = await launchClaude({
isolation: useHostConfig ? null : undefined,
});
try {
// mainVisible — registration happens during upstream's
// `app.on('ready')` chain (build-reference index.js:499416,
// 525287-525290), which lands before the main BrowserWindow
// becomes visible. Querying after mainVisible guarantees the
// register() call has run.
const { inspector } = await app.waitForReady('mainVisible');
const result = await inspector.evalInMain<{
accelerator: string;
isRegistered: boolean;
}>(`
const { globalShortcut } = process.mainModule.require('electron');
const accelerator = 'Ctrl+Alt+Space';
return {
accelerator,
isRegistered: globalShortcut.isRegistered(accelerator),
};
`);
await testInfo.attach('shortcut-registration', {
body: JSON.stringify(result, null, 2),
contentType: 'application/json',
});
expect(
result.isRegistered,
`globalShortcut.isRegistered('${result.accelerator}') is true ` +
'after main visible',
).toBe(true);
} finally {
await app.close();
}
});

View File

@@ -0,0 +1,199 @@
import { test, expect } from '@playwright/test';
import { launchClaude } from '../lib/electron.js';
import { createIsolation, type Isolation } from '../lib/isolation.js';
import { captureSessionEnv } from '../lib/diagnostics.js';
import { retryUntil } from '../lib/retry.js';
// T07 — In-app topbar renders + clickable.
//
// Path: seed auth from the host's signed-in Claude Desktop config into
// a per-test tmpdir, launch the app against that hermetic config, wait
// for `userLoaded` (claude.ai past /login — the topbar is rendered by
// claude.ai's authenticated SPA, not the shell), then DOM-probe the
// topbar via the `data-testid="topbar-windows-menu"` anchor documented
// in docs/learnings/linux-topbar-shim.md.
//
// Side effect of `seedFromHost: true`: the host's running Claude
// Desktop is killed (SIGTERM, then SIGKILL on holdouts). This is
// required because LevelDB / SQLite hold writer locks that would
// torn-page the seed copy. The host config dir itself is left
// untouched — only an allowlisted subset is copied into the tmpdir,
// which is rm -rf'd on test close. See lib/isolation.ts for the
// allowlist and lib/host-claude.ts for the kill semantics.
interface TopbarButton {
ariaLabel: string;
testId: string | null;
rect: { x: number; y: number; w: number; h: number };
visible: boolean;
}
interface TopbarSnapshot {
found: boolean;
containerSelector: string | null;
buttonCount: number;
buttons: TopbarButton[];
}
test('T07 — In-app topbar renders with clickable buttons', async ({}, testInfo) => {
testInfo.annotations.push({ type: 'severity', description: 'Smoke' });
testInfo.annotations.push({
type: 'surface',
description: 'Window chrome / in-app topbar',
});
// No skipUnlessRow — T07 applies to all rows on PR #538 builds.
await testInfo.attach('session-env', {
body: JSON.stringify(captureSessionEnv(), null, 2),
contentType: 'application/json',
});
// Seed auth from host: kills any running host Claude (writer-lock
// release for LevelDB / SQLite), then copies the auth-relevant
// subset of ~/.config/Claude into a per-test tmpdir. The host
// config never gets mutated, and the tmpdir is rm -rf'd on
// app.close(). Skip cleanly when no signed-in host config is
// available — createIsolation throws with a clear message in that
// case (no host dir, or dir present but missing the auth files).
let isolation: Isolation;
try {
isolation = await createIsolation({ seedFromHost: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
test.skip(true, `seedFromHost unavailable: ${msg}`);
return;
}
const app = await launchClaude({ isolation });
try {
// userLoaded gates on claude.ai URL past /login. With seeded
// auth this should fire well within the default budget on a
// warm cache; if the seed was stale and the renderer bounces
// to /login, postLoginUrl stays absent and we skip.
const ready = await app.waitForReady('userLoaded');
await testInfo.attach('claude-ai-url', {
body: ready.claudeAiUrl ?? '(no claude.ai webContents observed)',
contentType: 'text/plain',
});
if (!ready.postLoginUrl) {
test.skip(
true,
'seeded auth did not reach post-login URL — host config ' +
'may be stale (signed out, expired session, etc.)',
);
return;
}
await testInfo.attach('post-login-url', {
body: ready.postLoginUrl,
contentType: 'text/plain',
});
// Topbar probe: anchor on the `topbar-windows-menu` test id (the
// hamburger button — name reflects upstream's "this is for
// Windows" framing per linux-topbar-shim.md gate 3). Sibling
// buttons live in the same `div.absolute.top-0.inset-x-0`
// container per the click-state diagnostic in that learning.
// Fallback to `parentElement` if the closest() lookup misses
// (defensive — tailwind class regen could shift the container).
//
// Wrap in retryUntil because the renderer can still be mid-
// navigation when waitForReady('userLoaded') resolves (the gate
// polls URL only — it doesn't wait for SPA route settle), and a
// post-login client-side redirect during executeJavaScript
// surfaces as `Execution context was destroyed`. Each retry
// re-issues the eval against the now-current execution context.
const topbar = await retryUntil(
async () => {
try {
const r = await ready.inspector.evalInRenderer<TopbarSnapshot>(
'claude.ai',
`
(() => {
const menu = document.querySelector('[data-testid="topbar-windows-menu"]');
if (!menu) {
return { found: false, containerSelector: null, buttonCount: 0, buttons: [] };
}
const closest = menu.closest('div.absolute.top-0');
const container = closest ?? menu.parentElement;
if (!container) {
return { found: false, containerSelector: null, buttonCount: 0, buttons: [] };
}
const buttons = Array.from(container.querySelectorAll('button'));
return {
found: true,
containerSelector: closest
? 'div.absolute.top-0 (closest)'
: 'menu.parentElement (fallback)',
buttonCount: buttons.length,
buttons: buttons.map(b => {
const rect = b.getBoundingClientRect();
return {
ariaLabel: b.getAttribute('aria-label') ?? '',
testId: b.getAttribute('data-testid'),
rect: {
x: rect.x,
y: rect.y,
w: rect.width,
h: rect.height,
},
visible: rect.width > 0 && rect.height > 0,
};
}),
};
})()
`,
);
return r.found ? r : null;
} catch (err) {
// "Execution context was destroyed" during a route
// transition is benign — the next iteration runs
// against the new context.
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('context was destroyed')) return null;
throw err;
}
},
{ timeout: 15_000, interval: 500 },
);
if (!topbar) {
throw new Error(
'topbar probe never observed [data-testid="topbar-windows-menu"] ' +
'within 15s after userLoaded',
);
}
await testInfo.attach('topbar-snapshot', {
body: JSON.stringify(topbar, null, 2),
contentType: 'application/json',
});
expect(
topbar.found,
'data-testid="topbar-windows-menu" anchor was found in ' +
'claude.ai renderer (gate 3 / shim UA spoof active)',
).toBe(true);
// Case-doc lists five buttons (hamburger, sidebar toggle, search,
// back, forward) plus the Cowork ghost. The exact rendered count
// depends on whether the Cowork ghost is materialised at probe
// time, so assert the floor of five — the full button list is
// captured in the topbar-snapshot attachment for case-doc anchor
// refinement.
expect(
topbar.buttonCount,
'topbar container has at least 5 buttons',
).toBeGreaterThanOrEqual(5);
for (const btn of topbar.buttons) {
const id = btn.ariaLabel || btn.testId || '(unlabeled)';
expect(
btn.visible,
`topbar button "${id}" has non-zero bounding rect`,
).toBe(true);
}
} finally {
await app.close();
}
});

View File

@@ -0,0 +1,111 @@
import { test, expect } from '@playwright/test';
import { launchClaude } from '../lib/electron.js';
import { MainWindow } from '../lib/quickentry.js';
import { captureSessionEnv } from '../lib/diagnostics.js';
import { retryUntil } from '../lib/retry.js';
// T08 — Closing the main window hides to tray instead of quitting.
//
// On Linux, upstream's quit-on-last-window-closed handler at
// build-reference/app-extracted/.vite/build/index.js:525550-525552
// (`hA.app.on("window-all-closed", () => { Zr || Ap() })` — `Zr` is
// the darwin guard) would otherwise call into the quit path the
// first time the user clicks the X-button. PR #451 plumbed
// scripts/frame-fix-wrapper.js:178-185:
// this.on('close', e => {
// if (!result.app._quittingIntentionally && !this.isDestroyed()) {
// e.preventDefault();
// this.hide();
// }
// });
// armed by the `before-quit` handler at frame-fix-wrapper.js:370-374
// which sets `_quittingIntentionally = true` for the tray-Quit /
// Ctrl+Q / SIGTERM exits. So the X-button path takes the
// preventDefault + hide() branch; the tray-Quit path bypasses it.
//
// Test shape: launch, capture pre-state, fire `'close'` on the main
// BrowserWindow (MainWindow.setState('close') calls win.close(),
// which fires the same 'close' event the wrapper intercepts on a
// real X-button click), then assert the window flipped to invisible
// AND the Electron process is still running. The `'hide'` action
// would also flip visible:false but bypasses the wrapper — that's
// what S29 tests, and it deliberately does NOT exercise the
// regression-detection T08 cares about.
//
// Applies to all rows. No skipUnlessRow gate.
test.setTimeout(60_000);
test('T08 — Closing main window hides to tray, app stays alive', async ({}, testInfo) => {
testInfo.annotations.push({ type: 'severity', description: 'Smoke' });
testInfo.annotations.push({
type: 'surface',
description: 'Window chrome / close-to-tray',
});
await testInfo.attach('session-env', {
body: JSON.stringify(captureSessionEnv(), null, 2),
contentType: 'application/json',
});
const app = await launchClaude();
try {
const { inspector } = await app.waitForReady('mainVisible');
const mainWin = new MainWindow(inspector);
const before = await mainWin.getState();
await testInfo.attach('main-state-before-close', {
body: JSON.stringify(before, null, 2),
contentType: 'application/json',
});
expect(before, 'main window state reachable pre-close').toBeTruthy();
expect(before?.visible, 'main window visible before close').toBe(true);
// Fire the BrowserWindow 'close' event. The wrapper at
// frame-fix-wrapper.js:178-185 should preventDefault +
// hide() rather than letting the window destroy + the app
// quit via the 'window-all-closed' path.
await mainWin.setState('close');
// Poll for visible:false. The close-to-tray transition is
// synchronous in the wrapper's interceptor, but compositor
// side effects (unmap + isVisible() flip) can lag a beat —
// 5s is generous for the runtime check.
const after = await retryUntil(
async () => {
const s = await mainWin.getState();
return s && !s.visible ? s : null;
},
{ timeout: 5_000, interval: 200 },
);
await testInfo.attach('main-state-after-close', {
body: JSON.stringify(after, null, 2),
contentType: 'application/json',
});
await testInfo.attach('proc-state', {
body: JSON.stringify(
{
exitCode: app.process.exitCode,
signalCode: app.process.signalCode,
pid: app.pid,
},
null,
2,
),
contentType: 'application/json',
});
expect(after, 'main window state reachable post-close').toBeTruthy();
expect(after?.visible, 'main window hidden after close').toBe(false);
expect(
app.process.exitCode,
'app process did not quit (close-to-tray)',
).toBe(null);
expect(
app.process.signalCode,
'app process not killed by signal',
).toBe(null);
} finally {
await app.close();
}
});

View File

@@ -0,0 +1,179 @@
import { test, expect } from '@playwright/test';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { launchClaude } from '../lib/electron.js';
import { retryUntil } from '../lib/retry.js';
import { captureSessionEnv } from '../lib/diagnostics.js';
// T09 — Autostart via XDG.
//
// frame-fix-wrapper.js installs a setLoginItemSettings shim on Linux
// (Electron's openAtLogin is a no-op there — electron/electron#15198).
// The shim resolves $XDG_CONFIG_HOME/autostart/claude-desktop.desktop
// (falling back to ~/.config when the env var is unset/empty) and
// writes a spec-compliant [Desktop Entry] block on `openAtLogin: true`,
// unlinking it on `openAtLogin: false`.
//
// Default isolation gives a per-test XDG_CONFIG_HOME, so the autostart
// file lands inside the sandbox — no host-level cleanup needed.
//
// Code anchors:
// scripts/frame-fix-wrapper.js:566 — autostartPath construction
// scripts/frame-fix-wrapper.js:601 — buildAutostartContent()
// scripts/frame-fix-wrapper.js:627 — setLoginItemSettings shim
// Cold-start + waitForReady('mainVisible') alone has a 90s budget,
// so the default 60s test timeout is too tight. Two inspector evals
// add a few hundred ms each; 120s gives margin without masking real
// hangs.
test.setTimeout(120_000);
test('T09 — Autostart via XDG writes/removes desktop entry', async (
{},
testInfo,
) => {
testInfo.annotations.push({ type: 'severity', description: 'Should' });
testInfo.annotations.push({
type: 'surface',
description: 'Autostart / login item',
});
// All Linux rows — no skipUnlessRow.
await testInfo.attach('session-env', {
body: JSON.stringify(captureSessionEnv(), null, 2),
contentType: 'application/json',
});
const app = await launchClaude();
try {
await testInfo.attach('isolation-env', {
body: JSON.stringify(app.isolation?.env ?? null, null, 2),
contentType: 'application/json',
});
const xdgConfigHome = app.isolation?.env.XDG_CONFIG_HOME;
expect(
xdgConfigHome,
'isolation provides XDG_CONFIG_HOME',
).toBeTruthy();
const autostartPath = join(
xdgConfigHome!,
'autostart',
'claude-desktop.desktop',
);
await testInfo.attach('autostart-path', {
body: autostartPath,
contentType: 'text/plain',
});
// Don't gate on 'mainVisible' — that requires a claude.ai
// webContents to exist, which depends on network reachability
// and isn't relevant to the autostart shim (installed at
// frame-fix-wrapper module-load time, well before the renderer
// loads claude.ai). All we need is the inspector attached.
await app.waitForX11Window();
const inspector = await app.attachInspector();
// Sanity: file should not exist before the toggle. The shim only
// writes on explicit setLoginItemSettings calls.
const initiallyPresent = existsSync(autostartPath);
await testInfo.attach('initial-existence', {
body: String(initiallyPresent),
contentType: 'text/plain',
});
expect(
initiallyPresent,
'autostart file absent before any toggle',
).toBe(false);
// Capture the wrapper's view of XDG_CONFIG_HOME and shim binding.
// On failure this answers two questions immediately: did the env
// var propagate into the spawned process, and is the wrapper's
// setLoginItemSettings substitution still in place. If wrapperEnv
// .xdg is null but isolation-env had it set, the env didn't reach
// Electron — diagnose at launchClaude. If isFn is true but the
// file never lands, the wrapper substitution is being undone (or
// the path-construction comment in this file is out of date).
const wrapperEnv = await inspector.evalInMain<{
xdg: string | null;
home: string;
isFn: boolean;
xdgKeys: string[];
}>(`
const os = process.mainModule.require('os');
const { app } = process.mainModule.require('electron');
return {
xdg: process.env.XDG_CONFIG_HOME ?? null,
home: os.homedir(),
isFn: typeof app.setLoginItemSettings === 'function',
xdgKeys: Object.keys(process.env).filter(k => k.startsWith('XDG_')),
};
`);
await testInfo.attach('wrapper-env', {
body: JSON.stringify(wrapperEnv, null, 2),
contentType: 'application/json',
});
// Toggle on.
await inspector.evalInMain<null>(`
const { app } = process.mainModule.require('electron');
app.setLoginItemSettings({ openAtLogin: true });
return null;
`);
// Filesystem write is synchronous in the shim, but the eval
// resolves before the Node fs.writeFileSync syscall settles
// against any FUSE-backed tmpdir. retryUntil returns null on
// timeout, so use a truthy sentinel to distinguish "found" from
// "timed out".
const enabled = await retryUntil(
async () => (existsSync(autostartPath) ? 'present' : null),
{ timeout: 3_000, interval: 100 },
);
await testInfo.attach('post-enable-existence', {
body: String(existsSync(autostartPath)),
contentType: 'text/plain',
});
expect(
enabled,
'autostart file written after openAtLogin: true',
).toBe('present');
const desktopEntry = readFileSync(autostartPath, 'utf8');
await testInfo.attach('desktop-entry', {
body: desktopEntry,
contentType: 'text/plain',
});
expect(
desktopEntry,
'desktop entry has [Desktop Entry] header',
).toMatch(/^\[Desktop Entry\]/m);
expect(desktopEntry, 'desktop entry has Type= line').toMatch(
/^Type=Application/m,
);
expect(desktopEntry, 'desktop entry has Exec= line').toMatch(/^Exec=.+/m);
expect(desktopEntry, 'desktop entry has Name= line').toMatch(/^Name=.+/m);
// Toggle off.
await inspector.evalInMain<null>(`
const { app } = process.mainModule.require('electron');
app.setLoginItemSettings({ openAtLogin: false });
return null;
`);
const disabled = await retryUntil(
async () => (!existsSync(autostartPath) ? 'gone' : null),
{ timeout: 3_000, interval: 100 },
);
await testInfo.attach('post-disable-existence', {
body: String(existsSync(autostartPath)),
contentType: 'text/plain',
});
expect(
disabled,
'autostart file removed after openAtLogin: false',
).toBe('gone');
} finally {
await app.close();
}
});

View File

@@ -0,0 +1,100 @@
import { test, expect } from '@playwright/test';
import { launchClaude } from '../lib/electron.js';
import { captureSessionEnv } from '../lib/diagnostics.js';
// T12 — WebGL warn-only on Linux: GPU acceleration may be limited
// (virtio-gpu in VMs, hybrid-GPU laptops, blocklisted drivers) but
// the app must still launch and render the main UI without
// crashing. Per the case-doc, the chrome://gpu page is informational
// — there's no hard "enabled" requirement, just "doesn't crash and
// no feature breaks".
//
// The case-doc steps point a human at chrome://gpu via DevTools.
// Automating chrome:// navigation against a live BrowserView is
// blocked by Electron's chrome-scheme guard, so this runner does the
// equivalent capture from the main process via
// `app.getGPUFeatureStatus()` (and `app.getGPUInfo('basic')` for
// vendor/renderer breadcrumbs). The hard signal is "we got past
// waitForReady('mainVisible') and read the status without the
// renderer dying"; the JSON capture is the matrix-regen artifact.
//
// Code anchors driving the assertion shape:
// - index.js:524809 — upstream gates `disableHardwareAcceleration`
// on a user toggle, never passes `--ignore-gpu-blocklist` /
// `--use-gl=*`, so chrome://gpu reflects Chromium's stock
// blocklist behaviour.
// - index.js:500571 — only `webgl:!1` override is scoped to the
// in-memory feedback popup; main UI does not disable WebGL.
//
// Applies to all rows. No skipUnlessRow gate.
// Default 60s test timeout doesn't leave any margin around
// waitForReady('mainVisible')'s 90s budget. Cold-start GPU
// initialisation on virtio-gpu / blocklisted-driver rows is the
// reason that budget exists.
test.setTimeout(120_000);
test('T12 — GPU feature status captured, no crash', async ({}, testInfo) => {
testInfo.annotations.push({ type: 'severity', description: 'Could' });
testInfo.annotations.push({
type: 'surface',
description: 'Platform integration / GPU',
});
await testInfo.attach('session-env', {
body: JSON.stringify(captureSessionEnv(), null, 2),
contentType: 'application/json',
});
const app = await launchClaude();
try {
// 'mainVisible' rather than 'window' because the load-bearing
// claim is "main UI rendered" — if the GPU stack were broken
// hard enough to block compositing, MainWindow.getState()
// wouldn't report visible:true and we'd fail here, before
// the GPU probe runs.
const { inspector } = await app.waitForReady('mainVisible');
const gpuStatus = await inspector.evalInMain<Record<string, string>>(`
const { app } = process.mainModule.require('electron');
return app.getGPUFeatureStatus();
`);
await testInfo.attach('gpu-feature-status', {
body: JSON.stringify(gpuStatus, null, 2),
contentType: 'application/json',
});
// `getGPUInfo('basic')` is async and returns vendor / device /
// driver fields. 'complete' is much heavier (full Chromium
// GPU diagnostic dump) and not needed for the matrix
// breadcrumb — 'basic' is the documented default for
// per-row capture.
const gpuInfo = await inspector.evalInMain<unknown>(`
const { app } = process.mainModule.require('electron');
return await app.getGPUInfo('basic');
`);
await testInfo.attach('gpu-info-basic', {
body: JSON.stringify(gpuInfo, null, 2),
contentType: 'application/json',
});
// Sanity assertion: `getGPUFeatureStatus()` returned a populated
// object. An empty result would mean the API itself broke (a
// real regression worth catching), distinct from any individual
// feature being blocklisted (which the case-doc explicitly
// allows on VM / hybrid-GPU rows).
//
// We deliberately do NOT assert any specific feature key is
// 'enabled' — case-doc T12 calls out that webgl/webgl2 may
// report blocklisted on virtio-gpu and hybrid GPUs and that's
// expected. Reaching this line at all means waitForReady
// already proved the renderer is alive; the JSON capture is
// the load-bearing artifact for matrix regen.
expect(
Object.keys(gpuStatus).length,
'app.getGPUFeatureStatus() returned a populated object',
).toBeGreaterThan(0);
} finally {
await app.close();
}
});

View File

@@ -0,0 +1,228 @@
import { test, expect } from '@playwright/test';
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname } from 'node:path';
import { launchClaude } from '../lib/electron.js';
// T14b — Second invocation exits and focuses existing window
// (runtime pair of T14a's file-probe).
//
// docs/testing/cases/launch.md T14 expects: when the app is
// already running and a second invocation happens, the second
// invocation exits and the existing window receives focus — no
// new pid stays alive. Code anchors at
// build-reference/app-extracted/.vite/build/index.js:525162-525173
// (`hA.app.requestSingleInstanceLock()` + `app.on('second-instance', ...)`)
// and :525204-525207 (early-return in `app.on('ready', ...)` when the
// lock is lost — this is the path the second spawn takes to exit).
//
// Shape: launch the app under per-test isolation, then spawn a
// SECOND Electron with the SAME isolation env so both procs collide
// on the same SingletonLock under <configHome>/Claude. The second
// spawn should call `app.requestSingleInstanceLock()`, lose, hit
// the early-return in the `ready` handler and exit on its own. We
// observe via exit(code, signal) on the second proc, then re-check
// the primary pid is still alive via /proc/<pid>.
//
// Replicating the install-resolution logic inline (mirrors H01) keeps
// this spec independent of `launchClaude`'s internal spawn shape.
// We do NOT want to call `launchClaude()` for the second invocation —
// that would attach a second inspector, fight signal handlers, and
// register a second cleanup. Raw `spawn()` is the right primitive:
// observe the gate fire, then walk away.
const DEFAULT_INSTALL_PATHS: { electron: string; asar: string }[] = [
{
electron: '/usr/lib/claude-desktop/node_modules/electron/dist/electron',
asar: '/usr/lib/claude-desktop/node_modules/electron/dist/resources/app.asar',
},
{
electron: '/opt/Claude/node_modules/electron/dist/electron',
asar: '/opt/Claude/node_modules/electron/dist/resources/app.asar',
},
];
function resolveInstallInline(): { electron: string; asar: string } {
const envBin = process.env.CLAUDE_DESKTOP_ELECTRON;
const envAsar = process.env.CLAUDE_DESKTOP_APP_ASAR;
if (envBin && envAsar) return { electron: envBin, asar: envAsar };
for (const candidate of DEFAULT_INSTALL_PATHS) {
if (existsSync(candidate.electron) && existsSync(candidate.asar)) {
return candidate;
}
}
throw new Error(
'Could not locate claude-desktop install. Set CLAUDE_DESKTOP_ELECTRON ' +
'and CLAUDE_DESKTOP_APP_ASAR, or install the deb/rpm package.',
);
}
function pidAlive(pid: number): boolean {
// /proc/<pid> existence is the cheapest liveness check on Linux.
// `process.kill(pid, 0)` would also work but throws on ESRCH which
// makes the call site noisier for no benefit here.
return existsSync(`/proc/${pid}`);
}
test.setTimeout(60_000);
test('T14b — Second invocation exits and focuses existing window', async ({}, testInfo) => {
testInfo.annotations.push({ type: 'severity', description: 'Critical' });
testInfo.annotations.push({
type: 'surface',
description: 'App lifecycle / single instance',
});
const start = Date.now();
const app = await launchClaude();
const firstPid = app.pid;
// Capture the isolation env up front — `app.close()` cleans up the
// tmpdir, so we need a snapshot to drive the second spawn while the
// primary is still running. `isolation` is null only when the caller
// passed `isolation: null`; the default constructs a fresh handle.
if (!app.isolation) {
throw new Error(
'T14b expects launchClaude default isolation; ' +
'app.isolation is null. Did the harness defaults change?',
);
}
const isolationEnv = { ...app.isolation.env };
let secondPid: number | null = null;
let secondExitCode: number | null = null;
let secondSignal: NodeJS.Signals | null = null;
let secondTimedOut = false;
let firstAliveAfter = false;
try {
await app.waitForReady('mainVisible');
// Build the second-spawn argv + env. Mirror launchClaude()'s
// LAUNCHER_INJECTED_FLAGS_X11 / LAUNCHER_INJECTED_ENV (lib/
// electron.ts:123-146) so both procs look the same to the
// SingletonLock check — the only difference is that this one
// is started after the first holds the lock.
const { electron: electronBin, asar } = resolveInstallInline();
const appDir = dirname(dirname(dirname(dirname(electronBin))));
const argv = [
'--disable-features=CustomTitlebar',
'--ozone-platform=x11',
'--no-sandbox',
asar,
];
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) {
if (v !== undefined) env[k] = v;
}
// SAME isolation env as the running primary. SingletonLock lives
// under <configHome>/Claude — both procs must point there for
// requestSingleInstanceLock() to collide.
for (const [k, v] of Object.entries(isolationEnv)) {
env[k] = v;
}
env.ELECTRON_FORCE_IS_PACKAGED = 'true';
env.ELECTRON_USE_SYSTEM_TITLE_BAR = '1';
env.CI = '1';
const proc = spawn(electronBin, argv, {
cwd: appDir,
env,
stdio: 'ignore',
detached: false,
});
secondPid = proc.pid ?? null;
if (!secondPid) {
throw new Error('Failed to spawn second Electron — no pid');
}
// 10s budget. The second-instance early-return path (index.js
// :525204-525207) fires on `app.on('ready', ...)`, which lands
// well within Electron startup (~2-4s on a warm cache). If we
// blow past 10s the gate didn't fire — kill hard and fail.
await Promise.race([
new Promise<void>((resolve) => {
proc.once('exit', (code, signal) => {
secondExitCode = code;
secondSignal = signal;
resolve();
});
}),
new Promise<void>((resolve) => {
setTimeout(() => {
secondTimedOut = true;
resolve();
}, 10_000);
}),
]);
if (secondTimedOut && proc.exitCode === null && proc.signalCode === null) {
// Gate didn't fire — kill the rogue second proc so we don't
// leave two Electrons fighting over the same userData dir.
proc.kill('SIGKILL');
await new Promise<void>((resolve) => {
proc.once('exit', () => resolve());
setTimeout(() => resolve(), 2_000);
});
}
firstAliveAfter = pidAlive(firstPid);
} finally {
await app.close();
}
const elapsedMs = Date.now() - start;
await testInfo.attach('pids', {
body: JSON.stringify(
{
firstPid,
secondPid,
firstAliveAfterSecondSpawn: firstAliveAfter,
},
null,
2,
),
contentType: 'application/json',
});
await testInfo.attach('second-spawn-exit', {
body: JSON.stringify(
{
exitCode: secondExitCode,
signalCode: secondSignal,
timedOut: secondTimedOut,
elapsedMs,
note:
'Second instance is expected to exit on its own via the ' +
'early-return path in app.on("ready") at ' +
'build-reference/app-extracted/.vite/build/index.js:525204-525207 ' +
'when requestSingleInstanceLock() loses to the primary. ' +
'timedOut=true means the gate did not fire — second-instance ' +
'wiring may be broken.',
},
null,
2,
),
contentType: 'application/json',
});
if (secondTimedOut) {
throw new Error(
'Second-instance gate did not fire within 10s — second Electron ' +
'stayed alive under the same isolation as the primary. ' +
'requestSingleInstanceLock() / app.on("second-instance", ...) ' +
'wiring may be broken (index.js:525162-525173).',
);
}
expect(
secondSignal,
'second instance exited on its own, not by signal from us',
).toBe(null);
expect(
firstAliveAfter,
'primary pid still alive after the second spawn exited',
).toBe(true);
});