mirror of
https://github.com/aaddrick/claude-desktop-debian.git
synced 2026-05-17 00:26:21 +03:00
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:
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
199
tools/test-harness/src/runners/T07_topbar_renders.spec.ts
Normal file
199
tools/test-harness/src/runners/T07_topbar_renders.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
111
tools/test-harness/src/runners/T08_hide_to_tray_on_close.spec.ts
Normal file
111
tools/test-harness/src/runners/T08_hide_to_tray_on_close.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
179
tools/test-harness/src/runners/T09_autostart_via_xdg.spec.ts
Normal file
179
tools/test-harness/src/runners/T09_autostart_via_xdg.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
100
tools/test-harness/src/runners/T12_gpu_feature_status.spec.ts
Normal file
100
tools/test-harness/src/runners/T12_gpu_feature_status.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user