From 15462a7da763afc6f1fe364be9c4496ed88720d0 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Sun, 15 Mar 2026 19:27:39 -0600 Subject: [PATCH 1/5] test: add extension smoke test to catch import failures in CI Dynamically discovers all bundled extensions and verifies they can be imported without throwing. Catches missing imports, circular deps, and broken module resolution that tsc cannot detect since extensions are loaded at runtime via jiti. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tests/extension-smoke.test.ts | 70 +++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/tests/extension-smoke.test.ts diff --git a/src/tests/extension-smoke.test.ts b/src/tests/extension-smoke.test.ts new file mode 100644 index 000000000..cf41ecd7b --- /dev/null +++ b/src/tests/extension-smoke.test.ts @@ -0,0 +1,70 @@ +/** + * Extension Smoke Tests + * + * Verifies every bundled extension can be imported without throwing. + * Catches missing imports, circular dependencies, bad top-level code, + * and module resolution failures that tsc alone cannot detect (since + * extensions are loaded at runtime via jiti, not compiled by tsc). + * + * This test dynamically discovers all extension entry points using the + * same discovery logic as the loader, so new extensions are automatically + * covered without updating this file. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectRoot = join(fileURLToPath(import.meta.url), "..", "..", ".."); +const extensionsDir = join(projectRoot, "src", "resources", "extensions"); + +// Extensions that can't be smoke-tested in a plain Node process. +// Each entry documents WHY so we can remove it when the underlying issue is fixed. +const SKIP_EXTENSIONS = new Set([ + // core.js is a pre-compiled file (no .ts source) — the resolve-ts test hook + // rewrites .js→.ts imports and fails because core.ts doesn't exist. + // Works fine at runtime via jiti which loads core.js directly. + "browser-tools", + // Uses __dirname at module scope — valid in CJS/jiti but crashes in ESM test runner. + // TODO: fix voice/index.ts to use import.meta.dirname or fileURLToPath(import.meta.url) + "voice", +]); + +test("all bundled extensions can be imported without throwing", async () => { + const { discoverExtensionEntryPaths } = await import("../resource-loader.ts"); + const entryPaths = discoverExtensionEntryPaths(extensionsDir); + + assert.ok(entryPaths.length >= 10, `expected >=10 extensions, found ${entryPaths.length}`); + + const failures: { path: string; error: string }[] = []; + let skipped = 0; + + for (const entryPath of entryPaths) { + const relPath = entryPath.slice(extensionsDir.length + 1); + const extName = relPath.split("/")[0].replace(/\.ts$/, ""); + + if (SKIP_EXTENSIONS.has(extName)) { + skipped++; + continue; + } + + try { + await import(entryPath); + } catch (err) { + failures.push({ + path: relPath, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + if (failures.length > 0) { + const report = failures + .map((f) => ` ${f.path}: ${f.error}`) + .join("\n"); + assert.fail( + `${failures.length}/${entryPaths.length - skipped} extensions failed to import:\n${report}`, + ); + } +}); From 40dd80a41e3b4c84e3b658077dd6ae4d00fcc4ef Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Sun, 15 Mar 2026 19:35:07 -0600 Subject: [PATCH 2/5] fix(voice): replace __dirname with import.meta.dirname for ESM compat Co-Authored-By: Claude Opus 4.6 (1M context) --- src/resources/extensions/voice/index.ts | 7 ++++--- src/tests/extension-smoke.test.ts | 3 --- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/resources/extensions/voice/index.ts b/src/resources/extensions/voice/index.ts index 3c5698fa1..69b0069f3 100644 --- a/src/resources/extensions/voice/index.ts +++ b/src/resources/extensions/voice/index.ts @@ -7,9 +7,10 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as readline from "node:readline"; -const SWIFT_SRC = path.join(__dirname, "speech-recognizer.swift"); -const RECOGNIZER_BIN = path.join(__dirname, "speech-recognizer"); -const PYTHON_SCRIPT = path.join(__dirname, "speech-recognizer.py"); +const __extensionDir = import.meta.dirname!; +const SWIFT_SRC = path.join(__extensionDir, "speech-recognizer.swift"); +const RECOGNIZER_BIN = path.join(__extensionDir, "speech-recognizer"); +const PYTHON_SCRIPT = path.join(__extensionDir, "speech-recognizer.py"); const IS_DARWIN = process.platform === "darwin"; const IS_LINUX = process.platform === "linux"; diff --git a/src/tests/extension-smoke.test.ts b/src/tests/extension-smoke.test.ts index cf41ecd7b..1177a09ce 100644 --- a/src/tests/extension-smoke.test.ts +++ b/src/tests/extension-smoke.test.ts @@ -26,9 +26,6 @@ const SKIP_EXTENSIONS = new Set([ // rewrites .js→.ts imports and fails because core.ts doesn't exist. // Works fine at runtime via jiti which loads core.js directly. "browser-tools", - // Uses __dirname at module scope — valid in CJS/jiti but crashes in ESM test runner. - // TODO: fix voice/index.ts to use import.meta.dirname or fileURLToPath(import.meta.url) - "voice", ]); test("all bundled extensions can be imported without throwing", async () => { From d187a1ed2d5d51860ef1a6115f8e30e55264144c Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Mon, 16 Mar 2026 11:46:04 -0600 Subject: [PATCH 3/5] fix: use file:// URL for dynamic imports in smoke test (Windows compat) On Windows, raw paths like D:\... are interpreted as protocol "d:" by the ESM loader. Convert via pathToFileURL before dynamic import. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tests/extension-smoke.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tests/extension-smoke.test.ts b/src/tests/extension-smoke.test.ts index 1177a09ce..f8b9f4f37 100644 --- a/src/tests/extension-smoke.test.ts +++ b/src/tests/extension-smoke.test.ts @@ -14,7 +14,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const projectRoot = join(fileURLToPath(import.meta.url), "..", "..", ".."); const extensionsDir = join(projectRoot, "src", "resources", "extensions"); @@ -47,7 +47,7 @@ test("all bundled extensions can be imported without throwing", async () => { } try { - await import(entryPath); + await import(pathToFileURL(entryPath).href); } catch (err) { failures.push({ path: relPath, From e66c73daae9e9895779aca70befba833bf1fd5ae Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Mon, 16 Mar 2026 11:49:58 -0600 Subject: [PATCH 4/5] fix: add missing ProjectTotals fields in visualizer-views test fixture Co-Authored-By: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/tests/visualizer-views.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/resources/extensions/gsd/tests/visualizer-views.test.ts b/src/resources/extensions/gsd/tests/visualizer-views.test.ts index 580a21475..9db188112 100644 --- a/src/resources/extensions/gsd/tests/visualizer-views.test.ts +++ b/src/resources/extensions/gsd/tests/visualizer-views.test.ts @@ -249,6 +249,8 @@ console.log("\n=== renderMetricsView ==="); toolCalls: 15, assistantMessages: 10, userMessages: 5, + totalTruncationSections: 0, + continueHereFiredCount: 0, }, byPhase: [ { From 3d2f294f6a5008063ad3fad04a45c173f5aba542 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Mon, 16 Mar 2026 11:56:46 -0600 Subject: [PATCH 5/5] fix: google-search OAuth test mock and Windows path separator in smoke test - google-search test: mock getApiKeyForProvider to return JSON string matching real OAuth provider behavior (token+projectId), instead of using AuthStorage.inMemory which bypasses the OAuth getApiKey transform - smoke test: split on /[/\\]/ for Windows path separator compatibility Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tests/extension-smoke.test.ts | 2 +- src/tests/google-search-auth.repro.test.ts | 54 ++++++++++++---------- 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/src/tests/extension-smoke.test.ts b/src/tests/extension-smoke.test.ts index f8b9f4f37..795bb941f 100644 --- a/src/tests/extension-smoke.test.ts +++ b/src/tests/extension-smoke.test.ts @@ -39,7 +39,7 @@ test("all bundled extensions can be imported without throwing", async () => { for (const entryPath of entryPaths) { const relPath = entryPath.slice(extensionsDir.length + 1); - const extName = relPath.split("/")[0].replace(/\.ts$/, ""); + const extName = relPath.split(/[/\\]/)[0].replace(/\.ts$/, ""); if (SKIP_EXTENSIONS.has(extName)) { skipped++; diff --git a/src/tests/google-search-auth.repro.test.ts b/src/tests/google-search-auth.repro.test.ts index 2b3bab22a..69198845b 100644 --- a/src/tests/google-search-auth.repro.test.ts +++ b/src/tests/google-search-auth.repro.test.ts @@ -1,16 +1,13 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { AuthStorage, ModelRegistry } from "../../packages/pi-coding-agent/src/index.js"; import googleSearchExtension from "../resources/extensions/google-search/index.ts"; function createMockPI() { const handlers: any[] = []; - const notifications: any[] = []; let registeredTool: any = null; return { handlers, - notifications, registeredTool, on(event: string, handler: any) { handlers.push({ event, handler }); @@ -28,11 +25,23 @@ function createMockPI() { }; } +/** + * Build a mock modelRegistry whose getApiKeyForProvider returns the given + * JSON string (matching what the real OAuth provider's getApiKey produces). + */ +function mockModelRegistry(oauthJson?: string) { + return { + authStorage: { + hasAuth: async (_id: string) => !!oauthJson, + }, + getApiKeyForProvider: async (_provider: string) => oauthJson, + }; +} + test("fix: google-search uses OAuth if GEMINI_API_KEY is missing", async () => { const originalKey = process.env.GEMINI_API_KEY; delete process.env.GEMINI_API_KEY; - // Mock fetch const originalFetch = global.fetch; (global as any).fetch = async (url: string, options: any) => { assert.ok(url.includes("cloudcode-pa.googleapis.com"), "Should use Cloud Code Assist endpoint"); @@ -43,23 +52,29 @@ test("fix: google-search uses OAuth if GEMINI_API_KEY is missing", async () => { response: { candidates: [{ content: { parts: [{ text: "Mocked AI Answer" }] } }] } - }) + }), + text: async () => JSON.stringify({ + response: { + candidates: [{ content: { parts: [{ text: "Mocked AI Answer" }] } }] + } + }), }; }; try { const pi = createMockPI(); googleSearchExtension(pi as any); - const authStorage = AuthStorage.inMemory({ - "google-gemini-cli": { type: "oauth", access: "mock-token", projectId: "mock-project" } - }); - const modelRegistry = new ModelRegistry(authStorage); - const mockCtx = { ui: { notify() {} }, modelRegistry }; + + const oauthJson = JSON.stringify({ token: "mock-token", projectId: "mock-project" }); + const mockCtx = { + ui: { notify() {} }, + modelRegistry: mockModelRegistry(oauthJson), + }; await pi.fire("session_start", {}, mockCtx); const registeredTool = (pi as any).registeredTool; const result = await registeredTool.execute("call-1", { query: "test" }, new AbortController().signal, () => {}, mockCtx); - + assert.equal(result.isError, undefined); assert.ok(result.content[0].text.includes("Mocked AI Answer")); } finally { @@ -75,12 +90,11 @@ test("google-search warns if NO authentication is present", async () => { try { const pi = createMockPI(); googleSearchExtension(pi as any); - const authStorage = AuthStorage.inMemory({}); // No OAuth - const modelRegistry = new ModelRegistry(authStorage); + const notifications: any[] = []; const mockCtx = { ui: { notify(msg: string, level: string) { notifications.push({ msg, level }); } }, - modelRegistry + modelRegistry: mockModelRegistry(undefined), }; await pi.fire("session_start", {}, mockCtx); @@ -102,23 +116,15 @@ test("google-search uses GEMINI_API_KEY if present (precedence)", async () => { try { const pi = createMockPI(); googleSearchExtension(pi as any); - - // Even if OAuth is available, it should prefer the API Key - const authStorage = AuthStorage.inMemory({ - "google-gemini-cli": { type: "oauth", access: "should-not-be-used", projectId: "mock-project" } - }); - const modelRegistry = new ModelRegistry(authStorage); + const notifications: any[] = []; const mockCtx = { ui: { notify(msg: string, level: string) { notifications.push({ msg, level }); } }, - modelRegistry + modelRegistry: mockModelRegistry(JSON.stringify({ token: "should-not-be-used", projectId: "mock-project" })), }; await pi.fire("session_start", {}, mockCtx); assert.equal(notifications.length, 0, "Should NOT notify if API Key is present"); - - // We don't easily mock the @google/genai client here without more effort, - // but we've verified the logic branches. } finally { delete process.env.GEMINI_API_KEY; }