mirror of
https://github.com/aaddrick/claude-desktop-debian.git
synced 2026-05-17 00:26:21 +03:00
test(harness): session 6 runner + niri-native focus-shifter primitive (1 new spec + 1 new primitive, 80% → 82% coverage)
Coverage 61/76 → 62/76. One new spec + one new primitive land. Per
session 5 recon, the niri IPC contract is stable in --json mode and
the API sketch in plan-doc was directly implementable.
New primitive (lib/input-niri.ts):
Wayland-native focus-shifter sibling of lib/input.ts. Niri-only by
design — strict XDG_CURRENT_DESKTOP === 'niri' gate via
isNiriSession(). Exports mirror the X11 sibling's shape:
- focusOtherWindow(title): three-step chain — niri msg --json windows
→ app_id !== 'Claude' filter + title match → niri msg action
focus-window --id <u64> → honest readback via getFocusedWindowId()
using retryUntil(3s/100ms). The readback is load-bearing: niri's
focus-window action exits 0 even when the compositor refuses
activation; only the focused-window IPC is the honest answer
(mirrors lib/input.ts's xprop verification reasoning).
- spawnMarkerWindow(title): backgrounded foot --title <T> -e sleep
600 with detached:false (matches lib/input.ts's xterm pattern —
parent-death cleanup beats the marginal robustness of detached
spawn). 500ms grace before SIGKILL fallback.
- getFocusedWindowId(): parses niri msg --json focused-window to
number | null (niri u64 IDs are numeric, unlike X11's hex strings).
- isNiriSession(): pure XDG_CURRENT_DESKTOP env check.
- NiriIpcUnavailable / FootUnavailable typed errors for clean
testInfo.skip() integration in consumers.
Defensive unwrapOk helper handles both the older
{Ok: {FocusedWindow: ...}} Result-style JSON envelope and newer
bare-payload responses; if a third niri version ships a different
shape, the parser falls through to null rather than crashing. The
app_id !== 'Claude' guard prevents the focus shift from accidentally
targeting Claude's own window.
Untested-on-real-Niri caveat: landed against session 5 recon notes,
not a live niri session. KDE-W typecheck + skip-via-row-gate confirms
the file is well-formed; the first real Niri sweep will confirm (a)
the Ok-wrapper unwrap covers the niri version on the row, (b)
Claude's literal app_id value is 'Claude', (c) foot is on the target
row's PATH.
Cross-compositor expansion deliberately not built — sway / hyprland /
river each have completely different IPCs and would each get their
own per-compositor file, not bolted into input-niri.ts. With S14 the
only consumer, a lib/input-wayland.ts dispatcher would be ceremony
(matches the threshold-driven extraction discipline of
lib/electron-mocks.ts and lib/input.ts).
New spec (S14):
S14 (Quick Entry shortcut fires from any focus on Niri) — Tier 2
known-failing detector. Near-clone of S11 with imports swapped to
lib/input-niri.js and the row gate flipped from ['GNOME-X', 'Ubu-X']
to ['Niri']. Same five-phase shape: setup → mainVisible ready →
foot marker spawn → focus loop with NiriIpcUnavailable /
FootUnavailable sticky-error short-circuits → Ctrl+Alt+Space press
+ assert popup.visible. Single-shot s14-diagnostics JSON attachment
mirrors S11's shape with activeWidBeforeFocus / activeWidAfterFocus
typed number | null per the niri u64 ID contract.
Currently a known-failing detector per case-doc S14 (Failed to call
BindShortcuts (error code 5) on Niri); same shape as S12's GNOME-W
--enable-features=GlobalShortcutsPortal detector — the spec encodes
the contract and will start passing on Niri rows once the upstream /
Chromium-side portal issue resolves, without any spec edit.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
393
tools/test-harness/src/lib/input-niri.ts
Normal file
393
tools/test-harness/src/lib/input-niri.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
// Focus-shifter primitive for "Quick Entry shortcut fires from any
|
||||
// focus" (S14) on Niri sessions — the Wayland-native sibling of
|
||||
// lib/input.ts. The runner needs to (a) spawn a sacrificial window
|
||||
// with a known title, (b) shove keyboard focus to it, then (c) press
|
||||
// the global shortcut and observe whether the QE popup appears
|
||||
// regardless of focus.
|
||||
//
|
||||
// Niri only — by design.
|
||||
// - There is no portable focus-injection on native Wayland. Each
|
||||
// compositor exposes a different IPC: niri msg here, swaymsg for
|
||||
// Sway, hyprctl for Hyprland, riverctl for River. The libei-based
|
||||
// "input emulation" portal is the long-term cross-compositor
|
||||
// answer but isn't widely deployed (KDE/GNOME are getting it,
|
||||
// niri/sway/hypr are not yet). We pay one file per compositor
|
||||
// until a second consumer surfaces the dispatcher need; a
|
||||
// hypothetical lib/input-wayland.ts would just switch on
|
||||
// XDG_CURRENT_DESKTOP and delegate. With only S14 consuming this,
|
||||
// a dispatcher would be ceremony.
|
||||
// - lib/input.ts (X11) and this file are independent: they don't
|
||||
// share a focus-id type — niri window IDs are u64 numerics, X11
|
||||
// WIDs are hex strings. Callers handle one or the other based on
|
||||
// session detection; nothing crosses the boundary.
|
||||
//
|
||||
// Why niri msg --json over plain text: the niri wiki explicitly
|
||||
// contracts the JSON output as stable while the plain-text form is
|
||||
// described as unstable / human-readable-only. A test harness that
|
||||
// regex-greps human-readable IPC output is one niri release away
|
||||
// from a quiet break.
|
||||
//
|
||||
// Why we verify post-focus via niri msg focused-window: niri msg
|
||||
// action focus-window exits 0 even when the focus didn't actually
|
||||
// land (the action queues into the compositor and a competing input
|
||||
// event or a closing window can race it). The only honest answer is
|
||||
// to read focused-window back out and compare IDs. This mirrors
|
||||
// lib/input.ts's xprop-readback paragraph but for niri's IPC. ~3s
|
||||
// budget covers slow compositor paths; anything beyond is a refusal
|
||||
// not a slow ack — surface as an error so S14 sees it.
|
||||
//
|
||||
// Why foot for the marker terminal: it's the niri-default in many
|
||||
// distros (Fedora niri spin, several Arch derivatives), accepts
|
||||
// --title <T> verbatim with no de-escaping surprises, and ships in
|
||||
// most niri setups so a single binary covers the common case. We
|
||||
// deliberately don't fall back to alacritty / kitty — the X11
|
||||
// primitive uses xterm-only and the simplicity is worth more than
|
||||
// the marginal robustness; an environment without foot can install
|
||||
// it the same way an X11 environment without xterm installs xterm.
|
||||
//
|
||||
// Why detached:false on the marker spawn: keep the foot child in the
|
||||
// parent's process group so the OS cleans it up if the test crashes.
|
||||
// (Session 5 recon sketched detached:true; lib/input.ts uses
|
||||
// detached:false and is the safer pattern — a leaked terminal past a
|
||||
// crashed test run is worse than a marker that dies cleanly with its
|
||||
// parent.)
|
||||
//
|
||||
// No fixed sleeps. The verification poll uses retryUntil so a fast
|
||||
// compositor finishes in ~50ms while a slow one gets the full budget.
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { retryUntil } from './retry.js';
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
// Caller catches this and calls test.skip() — it's an environment
|
||||
// gap (not a Niri session, or niri msg not on PATH), not a
|
||||
// regression. Subclassing Error gives consumers a clean
|
||||
// `instanceof` check without parsing message strings.
|
||||
export class NiriIpcUnavailable extends Error {
|
||||
constructor(message?: string) {
|
||||
super(
|
||||
message ??
|
||||
'niri msg IPC unavailable: either this is not a Niri ' +
|
||||
'session (XDG_CURRENT_DESKTOP !== "niri") or the ' +
|
||||
'`niri` binary is missing from PATH. Install the ' +
|
||||
'`niri-ipc` / `niri` package, or skip on this row.',
|
||||
);
|
||||
this.name = 'NiriIpcUnavailable';
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors lib/input.ts's XdotoolUnavailable — the install command is
|
||||
// the actually-useful part of the error. Consumers should usually
|
||||
// skip rather than fail; the absence of foot is an environment
|
||||
// configuration issue, not a Claude Desktop regression.
|
||||
export class FootUnavailable extends Error {
|
||||
constructor(message?: string) {
|
||||
super(
|
||||
message ??
|
||||
'foot binary not found on PATH. Install with ' +
|
||||
'`dnf install foot` / `apt install foot`.',
|
||||
);
|
||||
this.name = 'FootUnavailable';
|
||||
}
|
||||
}
|
||||
|
||||
// Single source of truth for the Niri / not-Niri branch. Pure env
|
||||
// check, no process spawn — matches the simplicity of isX11Session()
|
||||
// in lib/input.ts. A `niri msg version` probe would be more
|
||||
// authoritative (catches the case where someone manually overrides
|
||||
// XDG_CURRENT_DESKTOP) but adds a fork-per-call cost that's
|
||||
// disproportionate to how rare the override is in practice.
|
||||
//
|
||||
// The literal string 'niri' is the value niri itself sets in
|
||||
// XDG_CURRENT_DESKTOP per its own documentation; we trust that and
|
||||
// nothing else (no case-folding, no startswith).
|
||||
export function isNiriSession(): boolean {
|
||||
return process.env.XDG_CURRENT_DESKTOP === 'niri';
|
||||
}
|
||||
|
||||
// Niri's --json output for several IPC calls is wrapped in a
|
||||
// Result-style envelope: `{"Ok": <payload>}`. Newer/older niri
|
||||
// versions sometimes return the bare payload. Defensively unwrap one
|
||||
// layer of `.Ok` if present, then return the payload as-is. Returns
|
||||
// null if the input is null/undefined.
|
||||
function unwrapOk(value: unknown): unknown {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === 'object' && value !== null && 'Ok' in value) {
|
||||
return (value as { Ok: unknown }).Ok;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Shape of a niri window row, restricted to the fields we use. The
|
||||
// real schema has more (workspace_id, is_floating, etc.) — we don't
|
||||
// commit to those.
|
||||
interface NiriWindow {
|
||||
id: number;
|
||||
title: string | null;
|
||||
app_id: string | null;
|
||||
is_focused?: boolean;
|
||||
}
|
||||
|
||||
// Read the currently-focused niri window via `niri msg --json
|
||||
// focused-window`.
|
||||
//
|
||||
// Returns null on:
|
||||
// - Non-Niri session (gated out by isNiriSession()).
|
||||
// - niri binary missing / spawn ENOENT — analogous to lib/input.ts
|
||||
// returning null on xprop spawn failure rather than throwing.
|
||||
// focusOtherWindow's poll fails through to its own timeout.
|
||||
// - JSON parse failure or unexpected shape (defensive — should
|
||||
// not happen against a healthy niri but the cost of a null
|
||||
// return is one re-poll).
|
||||
// - No focused window (e.g. all workspaces empty).
|
||||
export async function getFocusedWindowId(): Promise<number | null> {
|
||||
if (!isNiriSession()) return null;
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = await exec('niri', [
|
||||
'msg',
|
||||
'--json',
|
||||
'focused-window',
|
||||
]));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Two known wrappings: `{Ok: {FocusedWindow: <window>}}` (older)
|
||||
// and the bare window object (newer). Try unwrapping in order.
|
||||
const okUnwrapped = unwrapOk(parsed);
|
||||
let candidate: unknown = okUnwrapped;
|
||||
if (
|
||||
typeof okUnwrapped === 'object' &&
|
||||
okUnwrapped !== null &&
|
||||
'FocusedWindow' in okUnwrapped
|
||||
) {
|
||||
candidate = (okUnwrapped as { FocusedWindow: unknown }).FocusedWindow;
|
||||
}
|
||||
if (
|
||||
typeof candidate !== 'object' ||
|
||||
candidate === null ||
|
||||
!('id' in candidate)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const id = (candidate as { id: unknown }).id;
|
||||
if (typeof id !== 'number' || !Number.isFinite(id)) return null;
|
||||
return id;
|
||||
}
|
||||
|
||||
// Resolve a window title to its niri ID via `niri msg --json
|
||||
// windows`. The list is `Vec<Window>`; we filter on title match AND
|
||||
// app_id !== 'Claude' so we never accidentally pick the test target
|
||||
// itself. Returns null on zero matches; returns the first match's
|
||||
// ID on multi-match (mirrors xdotool's first-match behavior in
|
||||
// lib/input.ts).
|
||||
async function resolveWindowIdByTitle(
|
||||
title: string,
|
||||
): Promise<number | null> {
|
||||
const { stdout } = await exec('niri', ['msg', '--json', 'windows']);
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Same Ok-wrapping defense as getFocusedWindowId.
|
||||
const unwrapped = unwrapOk(parsed);
|
||||
if (!Array.isArray(unwrapped)) return null;
|
||||
for (const row of unwrapped as NiriWindow[]) {
|
||||
if (
|
||||
row &&
|
||||
typeof row === 'object' &&
|
||||
typeof row.id === 'number' &&
|
||||
row.title === title &&
|
||||
row.app_id !== 'Claude'
|
||||
) {
|
||||
return row.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Shift Niri focus to the first window whose title matches `title`
|
||||
// and whose app_id is not 'Claude' (so we never target Claude's own
|
||||
// window), then verify the shift actually took.
|
||||
//
|
||||
// Throws:
|
||||
// - NiriIpcUnavailable when not a Niri session, or niri binary
|
||||
// missing.
|
||||
// - Plain Error when no window matches (caller's bug — forgot to
|
||||
// spawn the marker, or used the wrong title).
|
||||
// - Plain Error when niri msg returns 0 but focused-window never
|
||||
// reflects the focus change within ~3s (compositor refused the
|
||||
// activation; this is the diagnostic path S14 wants surfaced,
|
||||
// not swallowed).
|
||||
export async function focusOtherWindow(title: string): Promise<void> {
|
||||
if (!isNiriSession()) {
|
||||
throw new NiriIpcUnavailable();
|
||||
}
|
||||
|
||||
let targetId: number | null;
|
||||
try {
|
||||
targetId = await resolveWindowIdByTitle(title);
|
||||
} catch (err) {
|
||||
const e = err as { code?: string | number };
|
||||
if (e.code === 'ENOENT') throw new NiriIpcUnavailable();
|
||||
throw err;
|
||||
}
|
||||
if (targetId === null) {
|
||||
throw new Error(
|
||||
`focusOtherWindow: no Niri window matches title ${JSON.stringify(title)} ` +
|
||||
'(with app_id != "Claude"). Did the marker window finish ' +
|
||||
'mapping? Caller should await spawnMarkerWindow + a short ' +
|
||||
'readiness poll before calling focusOtherWindow.',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await exec('niri', [
|
||||
'msg',
|
||||
'action',
|
||||
'focus-window',
|
||||
'--id',
|
||||
String(targetId),
|
||||
]);
|
||||
} catch (err) {
|
||||
const e = err as { code?: string | number };
|
||||
if (e.code === 'ENOENT') throw new NiriIpcUnavailable();
|
||||
throw err;
|
||||
}
|
||||
|
||||
const matched = await retryUntil(
|
||||
async () => {
|
||||
const active = await getFocusedWindowId();
|
||||
return active === targetId ? true : null;
|
||||
},
|
||||
{ timeout: 3_000, interval: 100 },
|
||||
);
|
||||
if (!matched) {
|
||||
throw new Error(
|
||||
'focusOtherWindow: niri msg action focus-window returned 0 ' +
|
||||
`but focused-window never settled to id=${targetId} ` +
|
||||
`for title ${JSON.stringify(title)}. Compositor may have ` +
|
||||
'refused the activation request.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle returned from spawnMarkerWindow. Lifecycle is owned by the
|
||||
// caller — the test that spawned it must kill() in afterEach (or
|
||||
// equivalent), otherwise the foot terminal leaks past the test run.
|
||||
export interface MarkerWindow {
|
||||
pid: number;
|
||||
title: string;
|
||||
kill(): Promise<void>;
|
||||
}
|
||||
|
||||
// Spawn a long-lived foot terminal with a known title, suitable as
|
||||
// a focus target on a Niri session. Backgrounded with detached:false
|
||||
// so the parent test process owns its lifetime — if the test
|
||||
// crashes, the OS cleans up the child when the parent dies.
|
||||
//
|
||||
// Throws FootUnavailable if foot isn't on PATH (both at spawn-throw
|
||||
// time AND via the 'error' event, mirroring lib/input.ts's redundant
|
||||
// ENOENT handling — Node delivers ENOENT through different paths
|
||||
// across versions).
|
||||
export async function spawnMarkerWindow(
|
||||
title: string,
|
||||
): Promise<MarkerWindow> {
|
||||
const { spawn } = await import('node:child_process');
|
||||
|
||||
let child;
|
||||
try {
|
||||
// `sleep 600` keeps the foot terminal alive for 10min — longer
|
||||
// than any reasonable single test, short enough that a leaked
|
||||
// terminal self-cleans within the sweep. foot's --title sets
|
||||
// the window title field that niri's windows list reports.
|
||||
child = spawn('foot', ['--title', title, '-e', 'sleep', '600'], {
|
||||
detached: false,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
} catch (err) {
|
||||
const e = err as { code?: string | number };
|
||||
if (e.code === 'ENOENT') {
|
||||
throw new FootUnavailable();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const earlyError = await new Promise<Error | null>((resolve) => {
|
||||
const onError = (err: Error) => {
|
||||
child.removeListener('spawn', onSpawn);
|
||||
resolve(err);
|
||||
};
|
||||
const onSpawn = () => {
|
||||
child.removeListener('error', onError);
|
||||
resolve(null);
|
||||
};
|
||||
child.once('error', onError);
|
||||
child.once('spawn', onSpawn);
|
||||
});
|
||||
if (earlyError) {
|
||||
const e = earlyError as Error & { code?: string | number };
|
||||
if (e.code === 'ENOENT') {
|
||||
throw new FootUnavailable();
|
||||
}
|
||||
throw earlyError;
|
||||
}
|
||||
|
||||
const pid = child.pid;
|
||||
if (typeof pid !== 'number') {
|
||||
throw new Error(
|
||||
'spawnMarkerWindow: child.pid was undefined after spawn',
|
||||
);
|
||||
}
|
||||
|
||||
let killed = false;
|
||||
const kill = async (): Promise<void> => {
|
||||
if (killed) return;
|
||||
killed = true;
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
// SIGTERM with a short grace period before SIGKILL. foot
|
||||
// honors SIGTERM cleanly; the SIGKILL fallback is for the
|
||||
// pathological "child wedged in a syscall" case.
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
child.once('exit', () => resolve());
|
||||
});
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
// Process may have died between the check and the kill.
|
||||
}
|
||||
const graceMs = 500;
|
||||
const timedOut = await Promise.race([
|
||||
exited.then(() => false),
|
||||
new Promise<boolean>((resolve) =>
|
||||
setTimeout(() => resolve(true), graceMs),
|
||||
),
|
||||
]);
|
||||
if (timedOut) {
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch {
|
||||
// Already dead.
|
||||
}
|
||||
await exited;
|
||||
}
|
||||
};
|
||||
|
||||
return { pid, title, kill };
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { launchClaude } from '../lib/electron.js';
|
||||
import { skipUnlessRow } from '../lib/row.js';
|
||||
import { QuickEntry } from '../lib/quickentry.js';
|
||||
import {
|
||||
focusOtherWindow,
|
||||
getFocusedWindowId,
|
||||
spawnMarkerWindow,
|
||||
NiriIpcUnavailable,
|
||||
FootUnavailable,
|
||||
type MarkerWindow,
|
||||
} from '../lib/input-niri.js';
|
||||
import { captureSessionEnv, readLauncherLog } from '../lib/diagnostics.js';
|
||||
import { sleep } from '../lib/retry.js';
|
||||
|
||||
// S14 — Quick Entry shortcut fires from any focus on Niri
|
||||
// (XDG portal BindShortcuts path). Backs the S14 row in
|
||||
// docs/testing/cases/shortcuts-and-input.md (severity: Critical
|
||||
// for Niri users).
|
||||
//
|
||||
// What this catches vs what it doesn't
|
||||
// ------------------------------------
|
||||
// On Niri the launcher special-cases the app to native Wayland
|
||||
// (`scripts/launcher-common.sh:41-44`), so upstream's
|
||||
// `globalShortcut.register` (`index.js:499416`) routes through
|
||||
// Electron's `xdg-desktop-portal` `BindShortcuts` path inside
|
||||
// Chromium rather than an X11 grab. The case-doc records this
|
||||
// path as currently failing on Niri:
|
||||
// `Failed to call BindShortcuts (error code 5)`. So this spec
|
||||
// is a known-failing detector — the shape mirrors S12's
|
||||
// `--enable-features=GlobalShortcutsPortal` GNOME-W detector:
|
||||
// the assertion encodes the contract, and the test will start
|
||||
// passing automatically once the upstream / portal-side issue
|
||||
// is resolved on Niri without any spec edit.
|
||||
//
|
||||
// The user-visible symptom (Quick Entry shortcut doesn't fire
|
||||
// on Niri) is the same as #404 (mutter XWayland key-grab on
|
||||
// GNOME-W) but the root cause is different: Niri is wlroots
|
||||
// Wayland with no XWayland by default, so the X11-side
|
||||
// `lib/input.ts` focus-shifter cannot exercise this path.
|
||||
// `lib/input-niri.ts` is the substrate — `niri msg --json`
|
||||
// for the focus-injection + readback chain, `foot --title` for
|
||||
// the Wayland-native marker window. The mutter / GNOME-W
|
||||
// regression detector remains a separate primitive gap (libei
|
||||
// when broadly available, or a per-compositor mutter-IPC
|
||||
// primitive — neither shipped).
|
||||
//
|
||||
// Row gate
|
||||
// --------
|
||||
// Niri only. Other Wayland rows (KDE-W, GNOME-W, Ubu-W) each
|
||||
// need their own compositor IPC and stay manual / matrix-cell-
|
||||
// from-doc until a libei-based primitive lands.
|
||||
|
||||
test.setTimeout(60_000);
|
||||
|
||||
test('S14 — Quick Entry shortcut fires from any focus (Niri Wayland path)', async ({}, testInfo) => {
|
||||
skipUnlessRow(testInfo, ['Niri']);
|
||||
testInfo.annotations.push({ type: 'severity', description: 'Critical' });
|
||||
testInfo.annotations.push({
|
||||
type: 'surface',
|
||||
description: 'XDG Desktop Portal BindShortcuts',
|
||||
});
|
||||
|
||||
// Single-shot diagnostic record. We attach this once at the
|
||||
// end (or on early throw) rather than spreading five separate
|
||||
// attachments — mirrors S31's results shape so matrix-regen
|
||||
// has one well-known JSON to scrape per spec.
|
||||
const diag: {
|
||||
sessionEnv: Record<string, string>;
|
||||
markerTitle: string | null;
|
||||
activeWidBeforeFocus: number | null;
|
||||
activeWidAfterFocus: number | null;
|
||||
popupState: unknown;
|
||||
openError: string | null;
|
||||
focusError: string | null;
|
||||
launcherLogTail: string | null;
|
||||
} = {
|
||||
sessionEnv: captureSessionEnv(),
|
||||
markerTitle: null,
|
||||
activeWidBeforeFocus: null,
|
||||
activeWidAfterFocus: null,
|
||||
popupState: null,
|
||||
openError: null,
|
||||
focusError: null,
|
||||
launcherLogTail: null,
|
||||
};
|
||||
|
||||
const attachDiag = async () => {
|
||||
await testInfo.attach('s14-diagnostics', {
|
||||
body: JSON.stringify(diag, null, 2),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
};
|
||||
|
||||
const useHostConfig = process.env.CLAUDE_TEST_USE_HOST_CONFIG === '1';
|
||||
const app = await launchClaude({
|
||||
isolation: useHostConfig ? null : undefined,
|
||||
});
|
||||
|
||||
let marker: MarkerWindow | null = null;
|
||||
try {
|
||||
// `mainVisible` is the cheapest level that gives us a
|
||||
// registered global shortcut. Upstream registers via
|
||||
// globalShortcut.register early in main-process startup
|
||||
// (build-reference index.js:499416), but we still want
|
||||
// the main window mapped so the popup-construction path
|
||||
// has something to anchor to.
|
||||
const { inspector } = await app.waitForReady('mainVisible');
|
||||
const qe = new QuickEntry(inspector);
|
||||
await qe.installInterceptor();
|
||||
|
||||
// Capture pre-focus active window id for the diagnostic
|
||||
// record. On a healthy Niri session this is the Claude
|
||||
// main window (we just `mainVisible`-readied it). If
|
||||
// null, `niri msg` is unavailable or there is no focused
|
||||
// window — neither blocks the test, just less useful
|
||||
// diagnostics.
|
||||
diag.activeWidBeforeFocus = await getFocusedWindowId();
|
||||
|
||||
// Marker title is unique-per-test to avoid colliding with
|
||||
// any leftover foot from a previous run (foot exits its
|
||||
// `sleep 600` after 10min so leaks are bounded, but a
|
||||
// re-run inside that window would otherwise match the
|
||||
// stale window).
|
||||
const markerTitle =
|
||||
`claude-test-s14-marker-${testInfo.testId}-${Date.now()}`;
|
||||
diag.markerTitle = markerTitle;
|
||||
|
||||
try {
|
||||
marker = await spawnMarkerWindow(markerTitle);
|
||||
} catch (err) {
|
||||
// Most likely cause: foot not on PATH. The primitive
|
||||
// throws `FootUnavailable` with the install hint. Skip
|
||||
// rather than fail — this is an environment gap.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
diag.focusError = `spawnMarkerWindow: ${msg}`;
|
||||
await attachDiag();
|
||||
testInfo.skip(
|
||||
true,
|
||||
'foot not installed; required for the focus-shift target. ' +
|
||||
`Underlying: ${msg}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// `focusOtherWindow` queries `niri msg --json windows`
|
||||
// once and throws if there are zero matches; only the
|
||||
// post-focus focused-window verification has its own
|
||||
// retry. So we need a brief readiness poll for the
|
||||
// marker window to actually appear in the niri window
|
||||
// list before we attempt the focus shift — and the focus
|
||||
// shift itself must eventually succeed within the budget.
|
||||
//
|
||||
// We capture the LAST error (rather than rethrowing on
|
||||
// the first) so the diagnostic carries the real cause if
|
||||
// every attempt fails. NiriIpcUnavailable / FootUnavailable
|
||||
// are sticky — they won't change between retries — so we
|
||||
// short-circuit out on the first occurrence and skip.
|
||||
let focusOk = false;
|
||||
let lastFocusErr: unknown = null;
|
||||
let earlySkipReason: string | null = null;
|
||||
const focusBudgetMs = 5_000;
|
||||
const focusStart = Date.now();
|
||||
while (Date.now() - focusStart < focusBudgetMs) {
|
||||
try {
|
||||
await focusOtherWindow(markerTitle);
|
||||
focusOk = true;
|
||||
break;
|
||||
} catch (err) {
|
||||
lastFocusErr = err;
|
||||
if (err instanceof NiriIpcUnavailable) {
|
||||
earlySkipReason =
|
||||
'NiriIpcUnavailable on a row that was ' +
|
||||
'supposed to be Niri-gated. Check NIRI_SOCKET / ' +
|
||||
'`niri msg` availability.';
|
||||
break;
|
||||
}
|
||||
if (err instanceof FootUnavailable) {
|
||||
earlySkipReason =
|
||||
'foot not installed; required for the ' +
|
||||
'focus-shift step. ' +
|
||||
(err instanceof Error ? err.message : String(err));
|
||||
break;
|
||||
}
|
||||
// "no window matches" (marker not yet listed by
|
||||
// niri) or "focus-window action did not stick" —
|
||||
// both can resolve on retry. Brief pause then loop.
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
if (earlySkipReason) {
|
||||
diag.focusError =
|
||||
lastFocusErr instanceof Error
|
||||
? lastFocusErr.message
|
||||
: String(lastFocusErr);
|
||||
await attachDiag();
|
||||
testInfo.skip(true, earlySkipReason);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!focusOk) {
|
||||
const msg =
|
||||
lastFocusErr instanceof Error
|
||||
? lastFocusErr.message
|
||||
: String(lastFocusErr);
|
||||
diag.focusError = msg;
|
||||
diag.launcherLogTail = await readLauncherLog();
|
||||
await attachDiag();
|
||||
throw new Error(
|
||||
`focusOtherWindow failed within ${focusBudgetMs}ms: ${msg}`,
|
||||
);
|
||||
}
|
||||
|
||||
// At this point focus is on the marker foot. Capture the
|
||||
// post-focus focused-window id — should equal the
|
||||
// marker's id, not Claude's. (We don't have a clean way
|
||||
// to fetch the marker's id independently here without
|
||||
// re-running `niri msg`; the value-vs-pre comparison in
|
||||
// the diagnostic is sufficient evidence of the shift.)
|
||||
diag.activeWidAfterFocus = await getFocusedWindowId();
|
||||
|
||||
// Now press the global shortcut. The whole point of S14:
|
||||
// even though the marker foot holds focus (and Claude
|
||||
// does not), the portal-routed BindShortcuts grab should
|
||||
// fire the popup. Currently known-failing per case-doc
|
||||
// S14 (`Failed to call BindShortcuts (error code 5)`).
|
||||
try {
|
||||
await qe.openAndWaitReady();
|
||||
} catch (err) {
|
||||
diag.openError = err instanceof Error ? err.message : String(err);
|
||||
diag.popupState = await qe.getPopupState();
|
||||
diag.launcherLogTail = await readLauncherLog();
|
||||
await attachDiag();
|
||||
throw err;
|
||||
}
|
||||
|
||||
const popupState = await qe.getPopupState();
|
||||
diag.popupState = popupState;
|
||||
diag.launcherLogTail = await readLauncherLog();
|
||||
await attachDiag();
|
||||
|
||||
// Single critical assertion: popup exists AND is visible
|
||||
// after the shortcut press from non-Claude focus. A null
|
||||
// state means the BrowserWindow was never constructed —
|
||||
// the portal grab didn't fire. visible === false means
|
||||
// it constructed but show() was suppressed (the upstream
|
||||
// lHn() short-circuit, or a regression in the visibility
|
||||
// flow). Either is a fail for S14's contract.
|
||||
expect(
|
||||
popupState && popupState.visible,
|
||||
'Quick Entry popup is visible after shortcut press from ' +
|
||||
'non-Claude focus (Niri Wayland path)',
|
||||
).toBe(true);
|
||||
} finally {
|
||||
// Marker foot cleanup is idempotent. Always run before
|
||||
// app.close() so the kill happens even if the spec
|
||||
// throws between the two.
|
||||
if (marker) {
|
||||
await marker.kill().catch(() => {
|
||||
// best-effort — process may already be dead
|
||||
});
|
||||
}
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user