test(harness): session 7 runners + eipc-registry primitive (4 new specs + 1 new primitive, 82% → 87% coverage)

Lands the eipc-registry exposer as Tier 2 runtime probe siblings of
session 3's Tier 1 fingerprints. Sessions 2-6 had marked the eipc
registry as closure-local — session 3 walked globalThis, found it
empty, and concluded the LocalSessions_$_* / CustomPlugins_$_* channels
weren't introspectable from main. Session 7 found the missing piece:
handlers DO go through Electron's stdlib IpcMainImpl, just on the
per-WebContents IPC scope (`webContents.ipc._invokeHandlers`,
Electron 17+) rather than the global ipcMain. Verified empirically
against a debugger-attached Claude — claude.ai webContents holds 490
handlers including all 117 LocalSessions + 16 CustomPlugins; global
ipcMain has the 3 chat-tab MCP-bridge handlers session 3 reported.

New primitive lib/eipc.ts (read-only by design):
- getEipcChannels — walks per-wc registries, filters by scope/iface
- findEipcChannel / findEipcChannels — case-doc-suffix lookup
- waitForEipcChannel / waitForEipcChannels — populate-on-init poll

Opaque on the $eipc_message$_<UUID>_$_ framing prefix (UUID has been
stable at c0eed8c9-… but the primitive doesn't pin it — match by
case-doc-anchored suffix).

Four new Tier 2 runtime probes paired with existing Tier 1 fingerprints
(T14a/T14b convention):
- T22b — LocalSessions_$_getPrChecks (PR monitoring)
- T31b — three-channel side-chat trio (load-bearing as a unit)
- T33b — two-channel plugin browser pair
- T38b — LocalSessions_$_openInEditor (Continue in IDE)

All four require seedFromHost (eipc handlers register on the claude.ai
webContents, which only exists post-login). Strictly stronger than
the bundle-string fingerprints — registry presence proves the upstream
code actually executed `e.ipc.handle(channel, fn)` during init, not
just that the constant is in the bundle.

All four pass on KDE-W (Plasma 6 Wayland, XWayland) — sequential
(workers: 1) at ~7.5s each, ~32s total.

Also adds tools/test-harness/eipc-registry-probe.ts as a re-runnable
read-only probe — connects to a debugger-attached Claude on port
9229, dumps per-wc IPC handler state with per-interface breakdown.
Useful when designing new probes or auditing for upstream drift.
Sibling of probe.ts (renderer-DOM) and grounding-probe.ts
(case-grounding).

Co-Authored-By: Claude <claude@anthropic.com>
This commit is contained in:
aaddrick
2026-05-03 20:13:00 -04:00
parent e038768daa
commit b9697c2d1e
6 changed files with 1094 additions and 0 deletions

View File

@@ -0,0 +1,309 @@
// Probe to verify whether the eipc channel registry (LocalSessions_$_*,
// CustomPlugins_$_*) is reachable from main via webContents.ipc._invokeHandlers
// instead of the empty-on-this-build globalThis.ipcMain._invokeHandlers.
//
// Run from tools/test-harness against a running claude-desktop with the
// main-process debugger enabled (Developer → Enable Main Process Debugger
// in the app menu, or `claude-desktop` was launched with --inspect):
// npx tsx eipc-registry-probe.ts
//
// Useful states to probe (re-run to compare):
// * fresh launch — whichever tab opens by default
// * /epitaxy with a Code session open
// * /chats with a chat thread open
// * cowork tab loaded
// The per-interface breakdown surfaces which interfaces register lazily
// vs eagerly — useful for designing the lib/eipc.ts primitive's wait
// semantics.
//
// Non-destructive — read-only enumeration of handler keys. Doesn't invoke
// anything, doesn't register anything, doesn't mutate state.
import { InspectorClient } from './src/lib/inspector.js';
import { writeFileSync } from 'node:fs';
interface InterfaceCount {
scope: string;
iface: string;
count: number;
sampleMethods: string[];
}
interface PerWcReport {
id: number;
url: string;
type: string;
hasIpc: boolean;
hasInvokeHandlers: boolean;
totalHandlers: number;
framedCount: number;
unframedCount: number;
scopes: string[];
byInterface: InterfaceCount[];
unframedSample: string[];
}
async function main() {
const client = await InspectorClient.connect(9229);
// Confirm globalThis.ipcMain._invokeHandlers is empty (or near-empty)
// — that's session 3's finding and we want it on the record alongside
// the per-wc reading for contrast.
const ipcMainReport = await client.evalInMain<{
hasIpcMain: boolean;
ipcMainKeys: string[];
ipcMainCount: number;
}>(`
const electron = process.mainModule.require('electron');
const ipcMain = electron.ipcMain;
const map = ipcMain && ipcMain._invokeHandlers;
if (!map) {
return { hasIpcMain: !!ipcMain, ipcMainKeys: [], ipcMainCount: 0 };
}
const keys = (typeof map.keys === 'function')
? Array.from(map.keys())
: Object.keys(map);
return {
hasIpcMain: true,
ipcMainKeys: keys,
ipcMainCount: keys.length,
};
`);
// Per-webContents enumeration with full framing parse:
// $eipc_message$_<UUID>_$_<scope>_$_<interface>_$_<method>
// Scope examples: claude.settings, claude.web, claude.app_internal.
// Interface examples: GlobalShortcut, LocalSessions, CustomPlugins.
// We group by scope.iface to show which feature areas are populated
// on each webContents — what registers eagerly vs on-tab-load.
const perWcReports = await client.evalInMain<PerWcReport[]>(`
const { webContents } = process.mainModule.require('electron');
const re = /^\\$eipc_message\\$_[0-9a-f-]+_\\$_([^_]+(?:\\.[^_]+)*)_\\$_([^_]+)_\\$_(.+)$/;
const all = webContents.getAllWebContents();
const out = [];
for (const w of all) {
const ipc = w.ipc;
const invokeMap = ipc && ipc._invokeHandlers;
let keys = [];
let hasInvokeHandlers = false;
if (invokeMap) {
hasInvokeHandlers = true;
if (typeof invokeMap.keys === 'function') {
keys = Array.from(invokeMap.keys());
} else {
keys = Object.keys(invokeMap);
}
}
const groups = new Map();
const scopes = new Set();
let framedCount = 0;
let unframedCount = 0;
const unframedSample = [];
for (const k of keys) {
const m = re.exec(k);
if (!m) {
unframedCount++;
if (unframedSample.length < 8) unframedSample.push(k);
continue;
}
framedCount++;
const scope = m[1];
const iface = m[2];
const method = m[3];
scopes.add(scope);
const groupKey = scope + '/' + iface;
let g = groups.get(groupKey);
if (!g) {
g = { scope, iface, count: 0, sampleMethods: [] };
groups.set(groupKey, g);
}
g.count++;
if (g.sampleMethods.length < 4) g.sampleMethods.push(method);
}
const byInterface = Array.from(groups.values())
.sort((a, b) => b.count - a.count);
out.push({
id: w.id,
url: w.getURL(),
type: w.getType ? w.getType() : 'unknown',
hasIpc: !!ipc,
hasInvokeHandlers,
totalHandlers: keys.length,
framedCount,
unframedCount,
scopes: Array.from(scopes).sort(),
byInterface,
unframedSample,
});
}
return out;
`);
// For each case-doc anchored channel, find which webContents (if any)
// hosts it. The framing prefix `$eipc_message$_<UUID>_$_claude.web_$_`
// is build-stable per session 2's T38 finding, so we match by suffix.
const expected = [
// T22 — gh PR check monitoring
'LocalSessions_$_getPrChecks',
// T31 — side chat trio
'LocalSessions_$_startSideChat',
'LocalSessions_$_sendSideChatMessage',
'LocalSessions_$_stopSideChat',
// T33 — plugin browser
'CustomPlugins_$_listMarketplaces',
'CustomPlugins_$_listAvailablePlugins',
// T38 — Continue in IDE
'LocalSessions_$_openInEditor',
];
const expectedReport = await client.evalInMain<
Array<{ suffix: string; foundOn: number[]; matchedKeys: string[] }>
>(`
const { webContents } = process.mainModule.require('electron');
const expected = ${JSON.stringify(expected)};
const all = webContents.getAllWebContents();
const out = [];
for (const suffix of expected) {
const foundOn = [];
const matchedKeys = [];
for (const w of all) {
const ipc = w.ipc;
const invokeMap = ipc && ipc._invokeHandlers;
if (!invokeMap) continue;
const keys = (typeof invokeMap.keys === 'function')
? Array.from(invokeMap.keys())
: Object.keys(invokeMap);
for (const k of keys) {
if (k.endsWith(suffix)) {
if (!foundOn.includes(w.id)) foundOn.push(w.id);
if (!matchedKeys.includes(k)) matchedKeys.push(k);
}
}
}
out.push({ suffix, foundOn, matchedKeys });
}
return out;
`);
// Snapshot the framing UUID(s) — useful to confirm build-stability
// across the per-wc registries (session 2 noted it as build-stable
// `c0eed8c9-...`).
const framingReport = await client.evalInMain<{
uuidsSeen: string[];
samplesPerUuid: Record<string, string[]>;
}>(`
const { webContents } = process.mainModule.require('electron');
const re = /^\\$eipc_message\\$_([0-9a-f-]+)_\\$_/;
const uuidsSeen = new Set();
const samples = {};
for (const w of webContents.getAllWebContents()) {
const ipc = w.ipc;
const invokeMap = ipc && ipc._invokeHandlers;
if (!invokeMap) continue;
const keys = (typeof invokeMap.keys === 'function')
? Array.from(invokeMap.keys())
: Object.keys(invokeMap);
for (const k of keys) {
const m = re.exec(k);
if (!m) continue;
const uuid = m[1];
uuidsSeen.add(uuid);
if (!samples[uuid]) samples[uuid] = [];
if (samples[uuid].length < 3) samples[uuid].push(k);
}
}
return {
uuidsSeen: Array.from(uuidsSeen),
samplesPerUuid: samples,
};
`);
console.log('=== globalThis.ipcMain._invokeHandlers (session 3 baseline) ===');
console.log(JSON.stringify(ipcMainReport, null, 2));
console.log('\n=== Per-webContents IPC registries ===');
console.log(JSON.stringify(perWcReports, null, 2));
console.log('\n=== Expected case-doc-anchored channel resolution ===');
console.log(JSON.stringify(expectedReport, null, 2));
console.log('\n=== Framing UUID(s) observed ===');
console.log(JSON.stringify(framingReport, null, 2));
// Cross-webContents per-interface deltas — useful when comparing
// "fresh launch" vs "after navigating to /epitaxy" vs "after opening
// cowork tab". Lists every (scope, iface) seen anywhere with the
// per-wc breakdown of which has it.
const interfaceAcrossWcs = (() => {
const matrix = new Map<string, Map<number, number>>();
for (const wc of perWcReports) {
for (const g of wc.byInterface) {
const key = `${g.scope}/${g.iface}`;
let row = matrix.get(key);
if (!row) {
row = new Map();
matrix.set(key, row);
}
row.set(wc.id, g.count);
}
}
const out: Array<{
interfaceKey: string;
perWc: Record<string, number>;
total: number;
}> = [];
for (const [key, row] of matrix) {
const perWc: Record<string, number> = {};
let total = 0;
for (const [wcId, count] of row) {
perWc[`wc${wcId}`] = count;
total += count;
}
out.push({ interfaceKey: key, perWc, total });
}
out.sort((a, b) => b.total - a.total);
return out;
})();
console.log('\n=== Interface presence across webContents ===');
console.log(JSON.stringify(interfaceAcrossWcs, null, 2));
const totalAll = perWcReports.reduce((a, r) => a + r.totalHandlers, 0);
const totalFramed = perWcReports.reduce((a, r) => a + r.framedCount, 0);
const totalUnframed = perWcReports.reduce((a, r) => a + r.unframedCount, 0);
const expectedFound = expectedReport.filter((e) => e.foundOn.length > 0).length;
const totalDistinctInterfaces = new Set(
perWcReports.flatMap((r) => r.byInterface.map((g) => `${g.scope}/${g.iface}`)),
).size;
console.log('\n=== Summary ===');
console.log(JSON.stringify({
webContentsCount: perWcReports.length,
webContentsUrls: perWcReports.map((r) => `wc${r.id}: ${r.url}`),
ipcMainHandlerCount: ipcMainReport.ipcMainCount,
perWcTotalHandlerCount: totalAll,
perWcFramedCount: totalFramed,
perWcUnframedCount: totalUnframed,
distinctInterfacesAcrossAllWcs: totalDistinctInterfaces,
expectedSuffixesFound: `${expectedFound} / ${expected.length}`,
framingUuidsObserved: framingReport.uuidsSeen.length,
}, null, 2));
const out = {
ipcMainReport,
perWcReports,
expectedReport,
framingReport,
interfaceAcrossWcs,
};
writeFileSync('/tmp/eipc-registry-probe.json', JSON.stringify(out, null, 2));
console.log('\nFull dump → /tmp/eipc-registry-probe.json');
client.close();
process.exit(0);
}
main().catch((err) => {
console.error('probe failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,295 @@
// "eipc" channel-registry primitive — runtime discovery of the custom
// `$eipc_message$_<UUID>_$_<scope>_$_<iface>_$_<method>` handlers
// registered on each per-webContents IPC scope.
//
// Why this exists
// ---------------
// Sessions 2-6 of the runner-implementation work treated the eipc
// registry as unreachable from main: the standard Electron
// `ipcMain._invokeHandlers` map only carries 3 chat-tab MCP-bridge
// handlers (`list-mcp-servers`, `connect-to-mcp-server`,
// `request-open-mcp-settings`); the 700+ `claude.web_$_*` /
// `claude.settings_$_*` etc. channels were assumed to be closure-
// local. Session 3's `globalThis` walk came up empty, which kept
// T22/T31/T33/T38 stuck as Tier 1 asar fingerprints rather than
// runtime registry probes.
//
// Session 7 found the missing piece: handlers DO go through
// Electron's stdlib `IpcMainImpl` — just not the GLOBAL `ipcMain`
// instance. Each `webContents` has its own `webContents.ipc` (per-
// `WebContents` IPC scope, introduced in Electron 17+), and that's
// where every `e.ipc.handle("$eipc_message$_..._$_<scope>_$_<iface>_$_<method>", fn)`
// call lands. Verified empirically against a debugger-attached
// running Claude:
// - find_in_page wc: 78 handlers (settings/find-in-page only)
// - main_window wc: 79 handlers (settings/title-bar only)
// - claude.ai wc: 490 handlers (full surface — including
// 117 LocalSessions, 16 CustomPlugins)
// - global ipcMain: 3 handlers (the chat-tab MCP-bridge trio)
//
// All `claude.web_$_*` interfaces (LocalSessions, CustomPlugins,
// CoworkSpaces, CoworkArtifacts, CoworkMemory, ClaudeCode, etc.)
// register on the claude.ai webContents. They're sticky across route
// changes — once registered (during webContents init), they don't
// deregister when the user navigates between /chats and /epitaxy.
// So the wait-for-channel poll just needs claude.ai to be alive +
// finished initial handler registration, NOT a specific route.
//
// What this primitive does NOT do
// -------------------------------
// Read-only enumeration. No invocation. The `_invokeHandlers` map
// holds the handler functions but they expect a synthesized
// `IpcMainInvokeEvent` with a real sender, and most claude.web_$_*
// handlers have side effects (`startSideChat` writes to the user's
// account; `openInEditor` shells out to a real editor). T22/T31/T33/T38
// only need handler PRESENCE — that's strictly stronger than the asar
// fingerprint (a handler registered at runtime is a handler that
// actually wired up, not just a string in the bundle).
//
// Future T35 Phase 2 / T37 Phase 2 may want invocation against
// read-only handlers like `claude.settings/MCP/getMcpServersConfig`
// or `claude.web/CoworkMemory/readGlobalMemory`. When that happens
// the right shape is a separate `invokeEipcChannel(inspector, suffix,
// args)` helper here — keep this file the single home for eipc surface
// abstractions. Don't add it speculatively until a consumer needs it
// (same anti-speculation rule as `lib/electron-mocks.ts` /
// `lib/input.ts` / `lib/input-niri.ts`).
//
// Framing opacity
// ---------------
// The `$eipc_message$_<UUID>_$_<scope>_$_<iface>_$_<method>` framing
// has been UUID-stable across builds (session 2 noted
// `c0eed8c9-c94a-4931-8cc3-3a08694e9863`; session 7 confirmed it's
// still that, single UUID across all 647 per-wc handlers). The
// primitive does not pin the UUID — match by suffix so a future
// build that rotates the UUID doesn't silently break every consuming
// spec. Suffix matching is also what the case-doc anchors use
// (`LocalSessions_$_getPrChecks` etc.), so consumers can pass the
// case-doc string verbatim.
import { retryUntil } from './retry.js';
import type { InspectorClient } from './inspector.js';
// One handler entry on a webContents. `suffix` is the part after the
// UUID — `<scope>_$_<iface>_$_<method>` — useful for dedup / display.
// `fullKey` is the full registry key including the framing prefix and
// UUID, kept for diagnostic attachments where the raw form matters
// (drift detection, regression triage). `webContentsId` lets a caller
// disambiguate when a future scope registers the same suffix on
// multiple webContents (today only `claude.settings/*` does this and
// every wc gets the same set; non-issue for current consumers).
export interface EipcChannel {
suffix: string;
fullKey: string;
webContentsId: number;
webContentsUrl: string;
}
export interface GetEipcChannelsOptions {
// Substring match on `webContents.getURL()`. Default: 'claude.ai'.
// Pass an empty string to enumerate every webContents.
urlFilter?: string;
// Optional scope filter — e.g. 'claude.web' to drop settings-
// scope handlers. Matched against the segment immediately after
// the UUID. Empty / undefined returns all scopes.
scope?: string;
// Optional interface filter — e.g. 'LocalSessions'. Matched
// against the segment after the scope. Empty / undefined returns
// all interfaces.
iface?: string;
}
// Internal: shape returned by the inspector eval below. Kept private
// so the `EipcChannel` interface above is the public type contract.
interface RawEntry {
wcId: number;
wcUrl: string;
fullKey: string;
}
// Enumerate every eipc-framed handler key registered on every matching
// webContents. The UUID is opaque to the caller — only the suffix
// (`<scope>_$_<iface>_$_<method>`) is exposed via the EipcChannel
// type. Filtering by `scope` / `iface` happens after the inspector
// eval (the eval keeps its filter set minimal so a single eval call
// covers every consumer's needs).
//
// Returns an empty array when no matching webContents exists (e.g.
// the spec called this before claude.ai loaded). Callers that need
// a "wait until present" semantic should use `waitForEipcChannel`
// instead.
export async function getEipcChannels(
inspector: InspectorClient,
opts: GetEipcChannelsOptions = {},
): Promise<EipcChannel[]> {
const urlFilter = opts.urlFilter ?? 'claude.ai';
const raw = await inspector.evalInMain<RawEntry[]>(`
const { webContents } = process.mainModule.require('electron');
const urlFilter = ${JSON.stringify(urlFilter)};
const out = [];
for (const wc of webContents.getAllWebContents()) {
const url = wc.getURL();
if (urlFilter && !url.includes(urlFilter)) continue;
const ipc = wc.ipc;
const map = ipc && ipc._invokeHandlers;
if (!map) continue;
const keys = (typeof map.keys === 'function')
? Array.from(map.keys())
: Object.keys(map);
for (const k of keys) {
out.push({ wcId: wc.id, wcUrl: url, fullKey: k });
}
}
return out;
`);
// Match the framing prefix and capture the suffix. Anything that
// doesn't match (e.g. a non-eipc handler that snuck onto a wc
// scope) gets filtered out — only eipc-framed entries are part of
// this primitive's contract.
const re = /^\$eipc_message\$_[0-9a-f-]+_\$_(.+)$/;
const out: EipcChannel[] = [];
for (const entry of raw) {
const m = re.exec(entry.fullKey);
if (!m) continue;
const suffix = m[1]!;
if (opts.scope) {
// Suffix shape: `<scope>_$_<iface>_$_<method>`. Anchor at
// the start so 'claude.web' matches but 'web' doesn't
// match `claude.settings` etc.
if (!suffix.startsWith(`${opts.scope}_$_`)) continue;
}
if (opts.iface) {
// Interface segment is after the scope — search for
// `_$_<iface>_$_` in the suffix. Anchored separators
// avoid accidentally matching a method name that happens
// to contain the iface string.
if (!suffix.includes(`_$_${opts.iface}_$_`)) continue;
}
out.push({
suffix,
fullKey: entry.fullKey,
webContentsId: entry.wcId,
webContentsUrl: entry.wcUrl,
});
}
return out;
}
export interface FindEipcChannelOptions {
// Substring match on `webContents.getURL()`. Default: 'claude.ai'.
urlFilter?: string;
}
// Locate the first registered handler whose suffix ends with
// `caseDocSuffix`. Designed so callers can pass the case-doc-anchored
// string verbatim — e.g. `LocalSessions_$_getPrChecks`. Returns null
// when no match exists (caller decides whether to fail, skip, or
// retry).
//
// This is a synchronous one-shot; for the populate-on-init wait, use
// `waitForEipcChannel` — it wraps this in a retryUntil.
export async function findEipcChannel(
inspector: InspectorClient,
caseDocSuffix: string,
opts: FindEipcChannelOptions = {},
): Promise<EipcChannel | null> {
const channels = await getEipcChannels(inspector, {
urlFilter: opts.urlFilter,
});
for (const ch of channels) {
if (ch.suffix.endsWith(caseDocSuffix)) return ch;
}
return null;
}
export interface WaitForEipcChannelOptions {
urlFilter?: string;
// Total budget for the poll. Default 15s — the claude.ai
// webContents' initial handler registration completes within a
// second of `userLoaded` on the dev box, so 15s leaves wide
// margin for slow-cache cases.
timeoutMs?: number;
intervalMs?: number;
}
// Poll until the named channel is registered, or the budget runs out.
// Use this when the spec just reached `waitForReady('userLoaded')` —
// the claude.ai webContents may exist but its handlers might not have
// finished registering yet. The poll is cheap (one inspector eval per
// tick + a string scan) so the default interval can be aggressive.
//
// Returns the EipcChannel on success, null on timeout. Callers that
// want a hard fail on timeout should `expect(channel, '...').not.toBeNull()`
// — the primitive doesn't throw because some specs want to surface
// missing-handler as a clean fail with diagnostics rather than an
// uncaught timeout.
export async function waitForEipcChannel(
inspector: InspectorClient,
caseDocSuffix: string,
opts: WaitForEipcChannelOptions = {},
): Promise<EipcChannel | null> {
return retryUntil(
() => findEipcChannel(inspector, caseDocSuffix, opts),
{
timeout: opts.timeoutMs ?? 15_000,
interval: opts.intervalMs ?? 250,
},
);
}
// Convenience: resolve a list of case-doc suffixes in one round-trip.
// Returns a Map keyed by the input suffix so callers can iterate the
// expected list and report per-suffix presence. Missing suffixes have
// `null` values.
//
// Single inspector call by design — the `getEipcChannels` cost is
// dominated by the eval round-trip, not the in-process filtering, so
// batching is strictly cheaper than N calls to `findEipcChannel`.
export async function findEipcChannels(
inspector: InspectorClient,
caseDocSuffixes: readonly string[],
opts: FindEipcChannelOptions = {},
): Promise<Map<string, EipcChannel | null>> {
const channels = await getEipcChannels(inspector, {
urlFilter: opts.urlFilter,
});
const out = new Map<string, EipcChannel | null>();
for (const suffix of caseDocSuffixes) {
const hit = channels.find((c) => c.suffix.endsWith(suffix));
out.set(suffix, hit ?? null);
}
return out;
}
// Wait until ALL of the listed suffixes are registered, or the budget
// runs out. Useful for trios like T31's side-chat (start/send/stop) —
// the trio is load-bearing as a unit; partial registration is a fail.
//
// Returns the resolved Map on full success. On timeout, returns the
// last-observed Map (some entries may be null) so callers can surface
// the partial state in their diagnostic attachment before failing.
export async function waitForEipcChannels(
inspector: InspectorClient,
caseDocSuffixes: readonly string[],
opts: WaitForEipcChannelOptions = {},
): Promise<Map<string, EipcChannel | null>> {
let lastSnapshot = new Map<string, EipcChannel | null>();
const result = await retryUntil(
async () => {
const snap = await findEipcChannels(
inspector,
caseDocSuffixes,
opts,
);
lastSnapshot = snap;
for (const v of snap.values()) if (v === null) return null;
return snap;
},
{
timeout: opts.timeoutMs ?? 15_000,
interval: opts.intervalMs ?? 250,
},
);
return result ?? lastSnapshot;
}

View File

@@ -0,0 +1,135 @@
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 { waitForEipcChannel } from '../lib/eipc.js';
// T22b — PR monitoring handler registered at runtime (Tier 2 sibling of
// T22's Tier 1 asar fingerprint).
//
// Backs T22 in docs/testing/cases/code-tab-workflow.md ("PR monitoring
// via `gh`"). T22 the Tier 1 fingerprint asserts the
// `LocalSessions_$_getPrChecks` channel name is a string in the bundled
// `index.js`. T22b the Tier 2 runtime probe asserts the matching
// handler is actually REGISTERED on the claude.ai webContents at
// runtime — a strictly stronger signal than string presence.
//
// Why the fingerprint is not enough
// ---------------------------------
// String presence in the bundle survives a half-applied refactor or a
// dead-code path that retains the constant but no longer wires it up.
// Runtime registration proves the upstream code actually executed
// `e.ipc.handle("$eipc_message$_..._$_LocalSessions_$_getPrChecks", fn)`
// during webContents init — a real handler function exists in
// `webContents.ipc._invokeHandlers` keyed by the framed channel
// name. If the wiring regresses (e.g. the `setImplementation` block
// that registers the LocalSessions interface throws on a side
// effect), the fingerprint still passes but T22b fails.
//
// Why this works (session 7 finding)
// ----------------------------------
// `claude.web_$_*` handlers register on `webContents.ipc` (the per-
// `WebContents` IPC scope, Electron 17+), NOT on the global
// `ipcMain`. Sessions 2-6 missed this — `ipcMain._invokeHandlers`
// only carries 3 chat-tab MCP-bridge handlers. The probe at
// `tools/test-harness/eipc-registry-probe.ts` confirmed the claude.ai
// webContents holds 117 LocalSessions methods + 16 CustomPlugins
// methods + the rest of the `claude.web` surface, and the registry is
// sticky across route changes (registers on init, persists). See
// `lib/eipc.ts` for the primitive that wraps the registry walk.
//
// Skip semantics
// --------------
// `seedFromHost: true` is required — without a signed-in claude.ai,
// the claude.ai webContents loads but bounces to /login and the eipc
// handler init never runs (or runs against a different surface). Hosts
// with no signed-in Claude Desktop skip cleanly via createIsolation's
// throw, mirroring T16's pattern.
//
// The `seedFromHost` side effect (kills the running host Claude
// Desktop to release LevelDB / SQLite writer locks) is documented in
// `lib/host-claude.ts`. The host config dir itself is left untouched.
test.setTimeout(60_000);
const EXPECTED_SUFFIX = 'LocalSessions_$_getPrChecks';
test('T22b — PR monitoring handler registered at runtime', async (
{},
testInfo,
) => {
testInfo.annotations.push({ type: 'severity', description: 'Critical' });
testInfo.annotations.push({
type: 'surface',
description: 'Code tab — CI status bar (eipc registry)',
});
// Applies to all rows. No skipUnlessRow gate — the eipc registry
// is platform-independent (Electron stdlib IPC, not an OS API).
await testInfo.attach('session-env', {
body: JSON.stringify(captureSessionEnv(), null, 2),
contentType: 'application/json',
});
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. Once that
// fires, the claude.ai webContents has finished its initial
// handler registration block (verified by session 7 probe:
// all 7 expected case-doc suffixes register at webContents
// init, not on first navigation).
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',
});
const channel = await waitForEipcChannel(
ready.inspector,
EXPECTED_SUFFIX,
);
await testInfo.attach('eipc-channel', {
body: JSON.stringify(
{
expectedSuffix: EXPECTED_SUFFIX,
resolved: channel,
},
null,
2,
),
contentType: 'application/json',
});
expect(
channel,
`[T22b] eipc channel ending in '${EXPECTED_SUFFIX}' is registered ` +
'on the claude.ai webContents (case-doc anchor index.js:464281 ' +
'GitHubPrManager / :464964 getPrChecks)',
).not.toBeNull();
} finally {
await app.close();
}
});

View File

@@ -0,0 +1,124 @@
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 { waitForEipcChannels } from '../lib/eipc.js';
// T31b — Side-chat handler trio registered at runtime (Tier 2 sibling
// of T31's Tier 1 asar fingerprint).
//
// Backs T31 in docs/testing/cases/code-tab-workflow.md ("Side chat
// opens" — `Ctrl+;` / `/btw` opens an overlay that forks the current
// Code-tab session, exchanges messages without polluting the main
// transcript, then closes cleanly). T31 the Tier 1 fingerprint
// asserts the three channel-name strings are present in bundled
// `index.js`. T31b the Tier 2 runtime probe asserts all three
// matching handlers are actually registered on the claude.ai
// webContents at runtime — strictly stronger than string presence.
//
// The trio is load-bearing as a UNIT — side chat is broken if any
// one of the three is missing. `waitForEipcChannels` (plural) holds
// the whole list against a single budget; the diagnostic attachment
// shows per-channel resolution so partial registration surfaces
// cleanly.
//
// See `lib/eipc.ts` for the eipc-registry primitive and the session 7
// finding that exposed it (`webContents.ipc._invokeHandlers`, not
// global `ipcMain._invokeHandlers`).
//
// Skip semantics
// --------------
// `seedFromHost: true` is required — without a signed-in claude.ai,
// the eipc handler init never completes. Mirrors T22b/T16's pattern:
// hosts with no signed-in Claude Desktop skip cleanly via
// createIsolation's throw.
test.setTimeout(60_000);
const EXPECTED_SUFFIXES = [
'LocalSessions_$_startSideChat',
'LocalSessions_$_sendSideChatMessage',
'LocalSessions_$_stopSideChat',
] as const;
test('T31b — Side-chat handler trio registered at runtime', async (
{},
testInfo,
) => {
testInfo.annotations.push({ type: 'severity', description: 'Should' });
testInfo.annotations.push({
type: 'surface',
description: 'Code tab — Side chat overlay (eipc registry)',
});
await testInfo.attach('session-env', {
body: JSON.stringify(captureSessionEnv(), null, 2),
contentType: 'application/json',
});
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 {
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',
});
const resolved = await waitForEipcChannels(
ready.inspector,
EXPECTED_SUFFIXES,
);
// Convert Map → object for the JSON attachment. Per-suffix entry
// shows resolved channel (or null) so a partial-registration
// failure surfaces "which two of three landed" without forcing
// the reader to diff strings.
const resolvedObj: Record<string, unknown> = {};
for (const suffix of EXPECTED_SUFFIXES) {
resolvedObj[suffix] = resolved.get(suffix);
}
await testInfo.attach('eipc-channels', {
body: JSON.stringify(
{
expectedSuffixes: EXPECTED_SUFFIXES,
resolved: resolvedObj,
},
null,
2,
),
contentType: 'application/json',
});
for (const suffix of EXPECTED_SUFFIXES) {
expect(
resolved.get(suffix),
`[T31b] eipc channel ending in '${suffix}' is registered on ` +
'the claude.ai webContents — load-bearing for the side-chat ' +
'trio (case-doc anchors index.js:487025 / :487265)',
).not.toBeNull();
}
} finally {
await app.close();
}
});

View File

@@ -0,0 +1,118 @@
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 { waitForEipcChannels } from '../lib/eipc.js';
// T33b — Plugin browser handler pair registered at runtime (Tier 2
// sibling of T33's Tier 1 asar fingerprint).
//
// Backs T33 in docs/testing/cases/extensibility.md ("Plugin browser"
// — click + → Plugins → Add plugin, marketplace listings appear,
// install completes end-to-end). T33 the Tier 1 fingerprint asserts
// the two channel-name strings are present in bundled `index.js`.
// T33b the Tier 2 runtime probe asserts both matching handlers are
// actually registered on the claude.ai webContents at runtime —
// strictly stronger than string presence.
//
// Both channels are needed for the browser to populate:
// `listMarketplaces` fetches the marketplace list, `listAvailablePlugins`
// fetches the per-marketplace plugin listings. Either missing breaks
// the modal silently. `waitForEipcChannels` (plural) holds the pair
// against a single budget.
//
// See `lib/eipc.ts` for the eipc-registry primitive and the session 7
// finding that exposed it (`webContents.ipc._invokeHandlers`, not
// global `ipcMain._invokeHandlers`).
//
// Skip semantics
// --------------
// `seedFromHost: true` is required — mirrors T22b / T31b / T16's
// pattern. Hosts with no signed-in Claude Desktop skip cleanly via
// createIsolation's throw.
test.setTimeout(60_000);
const EXPECTED_SUFFIXES = [
'CustomPlugins_$_listMarketplaces',
'CustomPlugins_$_listAvailablePlugins',
] as const;
test('T33b — Plugin browser handler pair registered at runtime', async (
{},
testInfo,
) => {
testInfo.annotations.push({ type: 'severity', description: 'Should' });
testInfo.annotations.push({
type: 'surface',
description: 'Plugin browser UI (eipc registry)',
});
await testInfo.attach('session-env', {
body: JSON.stringify(captureSessionEnv(), null, 2),
contentType: 'application/json',
});
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 {
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',
});
const resolved = await waitForEipcChannels(
ready.inspector,
EXPECTED_SUFFIXES,
);
const resolvedObj: Record<string, unknown> = {};
for (const suffix of EXPECTED_SUFFIXES) {
resolvedObj[suffix] = resolved.get(suffix);
}
await testInfo.attach('eipc-channels', {
body: JSON.stringify(
{
expectedSuffixes: EXPECTED_SUFFIXES,
resolved: resolvedObj,
},
null,
2,
),
contentType: 'application/json',
});
for (const suffix of EXPECTED_SUFFIXES) {
expect(
resolved.get(suffix),
`[T33b] eipc channel ending in '${suffix}' is registered on ` +
'the claude.ai webContents — load-bearing for the plugin ' +
'browser populate flow (case-doc anchors index.js:71392 ' +
'/ :71534 / :507176)',
).not.toBeNull();
}
} finally {
await app.close();
}
});

View File

@@ -0,0 +1,113 @@
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 { waitForEipcChannel } from '../lib/eipc.js';
// T38b — `LocalSessions.openInEditor` handler registered at runtime
// (Tier 2 sibling of T38's Tier 1 asar fingerprint).
//
// Backs T38 in docs/testing/cases/code-tab-handoff.md ("Continue in
// IDE" — click chooser → IDE opens at the working directory). T38 the
// Tier 1 fingerprint asserts the channel-name string is present in
// bundled `index.js`. T38b the Tier 2 runtime probe asserts the
// matching handler is actually registered on the claude.ai
// webContents at runtime — strictly stronger than string presence.
//
// Note vs T24
// -----------
// T24 (sibling test in code-tab-handoff.md) ships as a mock-then-call
// against the actual `shell.openExternal` egress. T24's assertion is
// strictly stronger than T38's static fingerprint AND than T38b's
// registry presence — it exercises the actual code path the IPC
// handler triggers. T38b's registry-presence check still has unique
// signal: it catches a regression where the upstream code path that
// REGISTERS the handler is removed (no IPC channel = no path to
// shell.openExternal at all), which would slip past T24 if T24 itself
// fell back to a skip on some other condition.
//
// See `lib/eipc.ts` for the eipc-registry primitive and the session 7
// finding that exposed it (`webContents.ipc._invokeHandlers`, not
// global `ipcMain._invokeHandlers`).
//
// Skip semantics
// --------------
// `seedFromHost: true` is required — mirrors T22b / T31b / T33b /
// T16's pattern. Hosts with no signed-in Claude Desktop skip cleanly
// via createIsolation's throw.
test.setTimeout(60_000);
const EXPECTED_SUFFIX = 'LocalSessions_$_openInEditor';
test('T38b — LocalSessions.openInEditor handler registered at runtime', async (
{},
testInfo,
) => {
testInfo.annotations.push({ type: 'severity', description: 'Should' });
testInfo.annotations.push({
type: 'surface',
description: 'Code tab — open in IDE (eipc registry)',
});
await testInfo.attach('session-env', {
body: JSON.stringify(captureSessionEnv(), null, 2),
contentType: 'application/json',
});
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 {
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',
});
const channel = await waitForEipcChannel(
ready.inspector,
EXPECTED_SUFFIX,
);
await testInfo.attach('eipc-channel', {
body: JSON.stringify(
{
expectedSuffix: EXPECTED_SUFFIX,
resolved: channel,
},
null,
2,
),
contentType: 'application/json',
});
expect(
channel,
`[T38b] eipc channel ending in '${EXPECTED_SUFFIX}' is registered ` +
'on the claude.ai webContents (case-doc anchor index.js:68816 ' +
'channel framing / :464011 shell.openExternal egress)',
).not.toBeNull();
} finally {
await app.close();
}
});