mirror of
https://github.com/aaddrick/claude-desktop-debian.git
synced 2026-05-17 00:26:21 +03:00
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:
309
tools/test-harness/eipc-registry-probe.ts
Normal file
309
tools/test-harness/eipc-registry-probe.ts
Normal 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);
|
||||
});
|
||||
295
tools/test-harness/src/lib/eipc.ts
Normal file
295
tools/test-harness/src/lib/eipc.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user