|
| 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