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: [ { 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 new file mode 100644 index 000000000..795bb941f --- /dev/null +++ b/src/tests/extension-smoke.test.ts @@ -0,0 +1,67 @@ +/** + * 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, pathToFileURL } 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", +]); + +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(pathToFileURL(entryPath).href); + } 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}`, + ); + } +}); 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; }