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:
aaddrick
2026-05-03 19:19:21 -04:00
parent 88f3bd5941
commit 34e9077dd2
2 changed files with 659 additions and 0 deletions

View 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 };
}

View File

@@ -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();
}
});