Skip to content

Commit ec4c41f

Browse files
committed
Create centralized test harness with singleton isolation and capture
1 parent 4438f30 commit ec4c41f

3 files changed

Lines changed: 407 additions & 0 deletions

File tree

tests/test-utils.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* Central test harness for the agenticoding extension.
3+
*
4+
* Every non-E2E test that touches module-level singletons starts with
5+
* `const h = createTestHarness()` and ends with `h.teardown()`. One call
6+
* replaces the singleton container atomically and captures console output —
7+
* no per-test patches.
8+
*
9+
* Usage:
10+
*
11+
* const h = createTestHarness();
12+
* // test body — use h.warnings
13+
* h.teardown();
14+
*
15+
* With beforeEach/afterEach:
16+
*
17+
* describe("spawn", () => {
18+
* let h: TestHarness;
19+
* beforeEach(() => { h = createTestHarness(); });
20+
* afterEach(() => { h.teardown(); });
21+
* });
22+
*/
23+
24+
import { AsyncLocalStorage } from "node:async_hooks";
25+
import {
26+
__setSingletons,
27+
createWriteLock,
28+
getSingletons,
29+
type RuntimeSingletons,
30+
} from "../runtime-singletons.js";
31+
import { SpawnFrameScheduler } from "../spawn/renderer.js";
32+
33+
// ── Types ─────────────────────────────────────────────────────────────
34+
35+
export interface TestHarness {
36+
/** Captured console.warn and console.error calls. */
37+
warnings: Array<{ level: string; args: unknown[] }>;
38+
/** Restore console, clear scheduler, reset write lock. */
39+
teardown: () => void;
40+
}
41+
42+
// ── Factory ───────────────────────────────────────────────────────────
43+
44+
/**
45+
* Create a fresh test harness. Every test that needs isolation calls this.
46+
*
47+
* CRITICAL: ESM static imports resolve before any module body runs. This means
48+
* spawn/renderer.ts registers the production frame scheduler at import time,
49+
* and createTestHarness() (called in beforeEach) always wins because tests
50+
* import this module after production modules. Never use dynamic import() to
51+
* load spawn/renderer.ts after createTestHarness() — the production scheduler
52+
* would overwrite the test one.
53+
*/
54+
export function createTestHarness(): TestHarness {
55+
const previousSingletons = getSingletons();
56+
const singletons: RuntimeSingletons = {
57+
writeLock: createWriteLock(),
58+
writeContext: new AsyncLocalStorage<true>(),
59+
frameScheduler: new SpawnFrameScheduler(),
60+
};
61+
const warnings: Array<{ level: string; args: unknown[] }> = [];
62+
const originalWarn = console.warn;
63+
const originalError = console.error;
64+
65+
// Atomic swap: replace the production singleton container (write lock,
66+
// context, frame scheduler) in one call.
67+
__setSingletons(singletons);
68+
69+
// Capture console output for assertions without noisy passing-test output.
70+
console.warn = (...args: unknown[]) => {
71+
warnings.push({ level: "warn", args });
72+
};
73+
console.error = (...args: unknown[]) => {
74+
warnings.push({ level: "error", args });
75+
};
76+
77+
return {
78+
warnings,
79+
teardown: () => {
80+
// Restore singletons first so the harness scheduler is current.
81+
// Then clear it to release any dirty components before disposal.
82+
__setSingletons(previousSingletons);
83+
singletons.frameScheduler.clear();
84+
console.warn = originalWarn;
85+
console.error = originalError;
86+
},
87+
};
88+
}

tests/unit/helpers.ts

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
// ── Shared test helpers ──────────────────────────────────────────
2+
// Imported by other test files via `./helpers.js`
3+
// Includes createTestPI(), test utilities, theme constants, etc.
4+
5+
import type { Theme } from "@earendil-works/pi-coding-agent";
6+
import assert from "node:assert/strict";
7+
8+
export const theme = {
9+
fg: (_name: string, text: string) => text,
10+
bold: (text: string) => text,
11+
} as unknown as Theme;
12+
13+
export const ansiTheme = {
14+
fg: (_name: string, text: string) => `\u001b[38;5;245m${text}\u001b[39m`,
15+
bg: (_name: string, text: string) => `\u001b[48;5;236m${text}\u001b[49m`,
16+
bold: (text: string) => text,
17+
} as unknown as Theme;
18+
19+
export function createRenderContext(overrides: Record<string, unknown> = {}): Record<string, unknown> {
20+
return {
21+
expanded: false,
22+
showImages: true,
23+
toolCallId: "tool-call-1",
24+
lastComponent: undefined,
25+
invalidate: () => {},
26+
...overrides,
27+
};
28+
}
29+
30+
export function createSession(messages: any[]) {
31+
return {
32+
messages,
33+
subscribe: () => () => {},
34+
getToolDefinition: () => undefined,
35+
sessionManager: { getCwd: () => process.cwd() },
36+
abort: async () => {},
37+
} as unknown as import("@earendil-works/pi-coding-agent").AgentSession;
38+
}
39+
40+
export function createSubscribableSession(messages: any[] = []) {
41+
let handler: ((event: any) => void) | undefined;
42+
return {
43+
session: {
44+
messages,
45+
subscribe: (cb: (event: any) => void) => {
46+
handler = cb;
47+
return () => { handler = undefined; };
48+
},
49+
getToolDefinition: () => undefined,
50+
sessionManager: { getCwd: () => process.cwd() },
51+
abort: async () => {},
52+
} as unknown as import("@earendil-works/pi-coding-agent").AgentSession,
53+
emit: (event: any) => handler?.(event),
54+
};
55+
}
56+
57+
export function stripAnsi(text: string): string {
58+
return text.replace(/\u001b\[[0-9;]*m/g, "").replace(/\u001b\][^\u0007]*\u0007/g, "");
59+
}
60+
61+
export function getRenderedLine(lines: string[], match: (plain: string) => boolean): string {
62+
const line = lines.find(candidate => match(stripAnsi(candidate)));
63+
assert.ok(line);
64+
return line;
65+
}
66+
67+
export function getLineContaining(lines: string[], text: string): string {
68+
const line = lines.find(candidate => candidate.includes(text));
69+
assert.ok(line);
70+
return line;
71+
}
72+
73+
export function assertShellBackgroundPreserved(line: string): void {
74+
assert.equal(line.includes("\u001b[0m"), false);
75+
assert.match(line, /\u001b\[48;/);
76+
}
77+
78+
export function createDeferred() {
79+
let resolve!: () => void;
80+
const promise = new Promise<void>((r) => { resolve = r; });
81+
return { promise, resolve };
82+
}
83+
84+
type Handler = (args: any, ctx: any) => any;
85+
86+
export function createTestPI() {
87+
const _handlers = new Map<string, any[]>();
88+
const _tools = new Map<string, any>();
89+
const _commands = new Map<string, any>();
90+
const _activeTools: string[] = [];
91+
const _allToolNames: string[] = [];
92+
const _toolSources = new Map<string, string>();
93+
const _sentUserMessages: Array<{ content: string; options: any }> = [];
94+
const _appendedEntries: Array<{ customType: string; data: any }> = [];
95+
96+
const obj = {
97+
registerCommand: (name: string, def: any) => { _commands.set(name, def); },
98+
registerTool: (def: any) => { _tools.set(def.name, def); },
99+
on: (event: string, handler: any) => {
100+
const h = _handlers.get(event) ?? [];
101+
h.push(handler);
102+
_handlers.set(event, h);
103+
},
104+
getActiveTools: () => [..._activeTools],
105+
getAllTools: () =>
106+
(_allToolNames.length ? _allToolNames : [..._activeTools]).map((name) => ({
107+
name,
108+
description: "",
109+
parameters: {},
110+
sourceInfo: {
111+
path: `<${_toolSources.get(name) ?? "builtin"}:${name}>`,
112+
source: _toolSources.get(name) ?? "builtin",
113+
scope: "temporary",
114+
origin: "top-level",
115+
},
116+
})),
117+
getThinkingLevel: () => "medium" as const,
118+
setThinkingLevel: () => {},
119+
sendUserMessage: (content: string, options?: any) => {
120+
_sentUserMessages.push({ content, options });
121+
},
122+
appendEntry: (customType: string, data: any) => {
123+
_appendedEntries.push({ customType, data });
124+
},
125+
setActiveTools: (tools: string[]) => {
126+
_activeTools.length = 0;
127+
_activeTools.push(...tools);
128+
for (const tool of tools) {
129+
if (!_toolSources.has(tool)) _toolSources.set(tool, "builtin");
130+
}
131+
},
132+
setToolSource: (name: string, source: string) => {
133+
_toolSources.set(name, source);
134+
},
135+
setAllTools: (tools: string[]) => {
136+
_allToolNames.length = 0;
137+
_allToolNames.push(...tools);
138+
for (const tool of tools) {
139+
if (!_toolSources.has(tool)) _toolSources.set(tool, "builtin");
140+
}
141+
},
142+
sendMessage: () => Promise.resolve(),
143+
setSessionName: () => {},
144+
getSessionName: () => undefined,
145+
exec: () => Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }),
146+
getCommands: () => [],
147+
setModel: () => Promise.resolve(true),
148+
registerProvider: () => {},
149+
registerShortcut: () => {},
150+
registerFlag: () => {},
151+
getFlag: () => undefined,
152+
registerMessageRenderer: () => {},
153+
setLabel: () => {},
154+
setEditorText: () => {},
155+
get commands() { return _commands; },
156+
get tools() { return _tools; },
157+
get handlers() { return _handlers; },
158+
get activeTools() { return _activeTools; },
159+
set activeTools(tools: string[]) {
160+
_activeTools.length = 0;
161+
_activeTools.push(...tools);
162+
},
163+
get sentUserMessages() { return _sentUserMessages; },
164+
get appendedEntries() { return _appendedEntries; },
165+
get allToolNames() { return _allToolNames; },
166+
get toolSources() { return _toolSources; },
167+
};
168+
return obj;
169+
}
170+
171+
// ── ExtensionAPI compile-time check ──────────────────────────────
172+
// If ExtensionAPI adds new required members, this fails at compile
173+
// time — forcing the test PI factory to be updated in sync.
174+
type _TestPICoversExtensionAPI = typeof createTestPI extends () => import("@earendil-works/pi-coding-agent").ExtensionAPI ? true : never;
175+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
176+
const _testPIVerified: _TestPICoversExtensionAPI = true;
177+
178+
export const EMPTY_USAGE = {
179+
input: 0,
180+
output: 0,
181+
cacheRead: 0,
182+
cacheWrite: 0,
183+
totalTokens: 0,
184+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
185+
};
186+
187+
export function createTestAssistantMessage(model: any, content: any[], stopReason = "stop") {
188+
return {
189+
role: "assistant",
190+
content,
191+
api: model.api,
192+
provider: model.provider,
193+
model: model.id,
194+
usage: EMPTY_USAGE,
195+
stopReason,
196+
timestamp: Date.now(),
197+
};
198+
}
199+
200+
export function createTestAssistantStream(message: any): any {
201+
return {
202+
async *[Symbol.asyncIterator]() {
203+
yield { type: "done", reason: message.stopReason, message };
204+
},
205+
result: async () => message,
206+
};
207+
}
208+
209+
export function messageText(message: any): string {
210+
return (message.content ?? [])
211+
.map((block: any) => block.type === "text" ? block.text : JSON.stringify(block))
212+
.join("\n");
213+
}
214+
215+
// ── TUI context factory ───────────────────────────────────────────────
216+
217+
export function makeTUICtx(
218+
overrides: Partial<{
219+
percent: number | null;
220+
hasUI: boolean;
221+
record: { statuses: Map<string, string | undefined>; widgets: Map<string, string[] | undefined> };
222+
}> = {},
223+
): any {
224+
const record = overrides.record ?? { statuses: new Map(), widgets: new Map() };
225+
const hasUI = overrides.hasUI ?? true;
226+
const percent = overrides.percent !== undefined ? overrides.percent : null;
227+
return {
228+
hasUI,
229+
ui: {
230+
theme: {
231+
fg: (name: string, text: string) => `[${name}:${text}]`,
232+
},
233+
setStatus: (key: string, status: string | undefined) => { record.statuses.set(key, status); },
234+
setWidget: (key: string, content: string[] | undefined) => { record.widgets.set(key, content); },
235+
},
236+
getContextUsage: () => (percent !== null ? { percent } : null),
237+
};
238+
}

0 commit comments

Comments
 (0)