From 896bf263718323fab1a6244d8c18061918fdfe35 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 4 May 2026 16:59:40 -0700 Subject: [PATCH 1/4] Playground extension host: scanner, codegen, bundler, LM, fixtures Scanner detects JSX nodes, serializes props, maps hooks to components (including anonymous default exports). Codegen emits aggregator, entry, error-boundary, provider-chain templates with shared import-specifier utility. Recursive renderValue handles nested UnserializableRef in provider props. Rolldown bundler with Tailwind v4 compile and file watcher for hot-reload. vscode.lm factory with live/record/replay modes, model picker, runtime host with SSE, fixture store with path traversal protection, version validation, name collision avoidance. Fixture replay supports both TEXT_MESSAGE_CONTENT and TEXT_MESSAGE_CHUNK AG-UI event types. SSE error handling uses sentinel error name for RUN_ERROR events. Scanner caches file contents. View provider posts user-visible errors on missing provider or failed codegen, resets replay state on dispose, clears stale bundle on model switch. --- src/extension/__tests__/deactivate.test.ts | 53 + src/extension/activate.ts | 117 ++- .../hooks/__tests__/integration.test.ts | 30 +- src/extension/hooks/hook-registry.ts | 4 +- src/extension/hooks/hook-scanner.ts | 56 +- .../playground/__tests__/bundler.test.ts | 42 + .../playground/__tests__/e2e-bundle.test.ts | 52 + .../__tests__/e2e-vscode-lm.test.ts | 93 ++ .../playground/__tests__/file-watcher.test.ts | 118 +++ .../__tests__/find-copilotkit.test.ts | 59 ++ .../__tests__/fixture-replay.test.ts | 138 +++ .../__tests__/fixture-store.test.ts | 85 ++ .../__tests__/fixtures/aliased-provider.tsx | 9 + .../__tests__/fixtures/hooks-in-arrow.tsx | 8 + .../fixtures/hooks-in-components.tsx | 12 + .../fixtures/hooks-in-default-function.tsx | 6 + .../__tests__/fixtures/hooks-top-level.tsx | 10 + .../fixtures/inline-function-prop.tsx | 17 + .../__tests__/fixtures/multiple-providers.tsx | 17 + .../__tests__/fixtures/no-provider.tsx | 3 + .../__tests__/fixtures/provider-no-chain.tsx | 9 + .../fixtures/provider-with-chain.tsx | 26 + .../fixtures/provider-with-imported-chain.tsx | 18 + .../__tests__/fixtures/simple-provider.tsx | 9 + .../fixtures/v2-backcompat-provider.tsx | 9 + .../__tests__/fixtures/v2-provider.tsx | 9 + .../__tests__/fixtures/workspace/app.tsx | 15 + .../__tests__/fixtures/workspace/my-page.tsx | 6 + .../__tests__/fixtures/workspace/sidebar.tsx | 6 + .../__tests__/map-hooks-to-components.test.ts | 68 ++ .../__tests__/mode-detector.test.ts | 38 + .../playground/__tests__/model-picker.test.ts | 76 ++ .../playground/__tests__/runtime-host.test.ts | 80 ++ .../playground/__tests__/scanner.test.ts | 41 + .../__tests__/serialize-props.test.ts | 43 + .../__tests__/tailwind-compile.test.ts | 113 +++ .../__tests__/test-workspace-smoke.test.ts | 57 ++ .../__tests__/view-provider.test.ts | 509 ++++++++++ .../__tests__/vscode-lm-factory.test.ts | 266 +++++ .../__tests__/walk-ancestors.test.ts | 95 ++ src/extension/playground/ast-utils.ts | 53 + src/extension/playground/bridge-types.ts | 90 ++ src/extension/playground/bundler.ts | 100 ++ .../__tests__/aggregator-template.test.ts | 68 ++ .../codegen/__tests__/entry-codegen.test.ts | 101 ++ .../__tests__/error-boundary-source.test.ts | 20 + .../__tests__/provider-chain-template.test.ts | 217 +++++ .../playground/codegen/aggregator-template.ts | 103 ++ .../playground/codegen/entry-codegen.ts | 66 ++ .../codegen/error-boundary-source.ts | 88 ++ .../playground/codegen/import-specifier.ts | 23 + .../codegen/playground-chat-source.ts | 911 ++++++++++++++++++ .../codegen/provider-chain-template.ts | 316 ++++++ src/extension/playground/file-watcher.ts | 72 ++ src/extension/playground/find-copilotkit.ts | 140 +++ src/extension/playground/fixture-replay.ts | 142 +++ src/extension/playground/fixture-store.ts | 135 +++ .../playground/map-hooks-to-components.ts | 233 +++++ src/extension/playground/mode-detector.ts | 35 + src/extension/playground/model-picker.ts | 30 + src/extension/playground/runtime-host.ts | 205 ++++ src/extension/playground/scanner.ts | 151 +++ src/extension/playground/serialize-props.ts | 155 +++ src/extension/playground/tailwind-compile.ts | 347 +++++++ src/extension/playground/types.ts | 124 +++ src/extension/playground/view-provider.ts | 505 ++++++++++ src/extension/playground/vscode-lm-factory.ts | 356 +++++++ src/extension/playground/walk-ancestors.ts | 159 +++ 68 files changed, 7314 insertions(+), 53 deletions(-) create mode 100644 src/extension/__tests__/deactivate.test.ts create mode 100644 src/extension/playground/__tests__/bundler.test.ts create mode 100644 src/extension/playground/__tests__/e2e-bundle.test.ts create mode 100644 src/extension/playground/__tests__/e2e-vscode-lm.test.ts create mode 100644 src/extension/playground/__tests__/file-watcher.test.ts create mode 100644 src/extension/playground/__tests__/find-copilotkit.test.ts create mode 100644 src/extension/playground/__tests__/fixture-replay.test.ts create mode 100644 src/extension/playground/__tests__/fixture-store.test.ts create mode 100644 src/extension/playground/__tests__/fixtures/aliased-provider.tsx create mode 100644 src/extension/playground/__tests__/fixtures/hooks-in-arrow.tsx create mode 100644 src/extension/playground/__tests__/fixtures/hooks-in-components.tsx create mode 100644 src/extension/playground/__tests__/fixtures/hooks-in-default-function.tsx create mode 100644 src/extension/playground/__tests__/fixtures/hooks-top-level.tsx create mode 100644 src/extension/playground/__tests__/fixtures/inline-function-prop.tsx create mode 100644 src/extension/playground/__tests__/fixtures/multiple-providers.tsx create mode 100644 src/extension/playground/__tests__/fixtures/no-provider.tsx create mode 100644 src/extension/playground/__tests__/fixtures/provider-no-chain.tsx create mode 100644 src/extension/playground/__tests__/fixtures/provider-with-chain.tsx create mode 100644 src/extension/playground/__tests__/fixtures/provider-with-imported-chain.tsx create mode 100644 src/extension/playground/__tests__/fixtures/simple-provider.tsx create mode 100644 src/extension/playground/__tests__/fixtures/v2-backcompat-provider.tsx create mode 100644 src/extension/playground/__tests__/fixtures/v2-provider.tsx create mode 100644 src/extension/playground/__tests__/fixtures/workspace/app.tsx create mode 100644 src/extension/playground/__tests__/fixtures/workspace/my-page.tsx create mode 100644 src/extension/playground/__tests__/fixtures/workspace/sidebar.tsx create mode 100644 src/extension/playground/__tests__/map-hooks-to-components.test.ts create mode 100644 src/extension/playground/__tests__/mode-detector.test.ts create mode 100644 src/extension/playground/__tests__/model-picker.test.ts create mode 100644 src/extension/playground/__tests__/runtime-host.test.ts create mode 100644 src/extension/playground/__tests__/scanner.test.ts create mode 100644 src/extension/playground/__tests__/serialize-props.test.ts create mode 100644 src/extension/playground/__tests__/tailwind-compile.test.ts create mode 100644 src/extension/playground/__tests__/test-workspace-smoke.test.ts create mode 100644 src/extension/playground/__tests__/view-provider.test.ts create mode 100644 src/extension/playground/__tests__/vscode-lm-factory.test.ts create mode 100644 src/extension/playground/__tests__/walk-ancestors.test.ts create mode 100644 src/extension/playground/ast-utils.ts create mode 100644 src/extension/playground/bridge-types.ts create mode 100644 src/extension/playground/bundler.ts create mode 100644 src/extension/playground/codegen/__tests__/aggregator-template.test.ts create mode 100644 src/extension/playground/codegen/__tests__/entry-codegen.test.ts create mode 100644 src/extension/playground/codegen/__tests__/error-boundary-source.test.ts create mode 100644 src/extension/playground/codegen/__tests__/provider-chain-template.test.ts create mode 100644 src/extension/playground/codegen/aggregator-template.ts create mode 100644 src/extension/playground/codegen/entry-codegen.ts create mode 100644 src/extension/playground/codegen/error-boundary-source.ts create mode 100644 src/extension/playground/codegen/import-specifier.ts create mode 100644 src/extension/playground/codegen/playground-chat-source.ts create mode 100644 src/extension/playground/codegen/provider-chain-template.ts create mode 100644 src/extension/playground/file-watcher.ts create mode 100644 src/extension/playground/find-copilotkit.ts create mode 100644 src/extension/playground/fixture-replay.ts create mode 100644 src/extension/playground/fixture-store.ts create mode 100644 src/extension/playground/map-hooks-to-components.ts create mode 100644 src/extension/playground/mode-detector.ts create mode 100644 src/extension/playground/model-picker.ts create mode 100644 src/extension/playground/runtime-host.ts create mode 100644 src/extension/playground/scanner.ts create mode 100644 src/extension/playground/serialize-props.ts create mode 100644 src/extension/playground/tailwind-compile.ts create mode 100644 src/extension/playground/types.ts create mode 100644 src/extension/playground/view-provider.ts create mode 100644 src/extension/playground/vscode-lm-factory.ts create mode 100644 src/extension/playground/walk-ancestors.ts diff --git a/src/extension/__tests__/deactivate.test.ts b/src/extension/__tests__/deactivate.test.ts new file mode 100644 index 000000000..6d6e487e4 --- /dev/null +++ b/src/extension/__tests__/deactivate.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("vscode", () => ({ + Uri: { + joinPath: (_base: unknown, ...parts: string[]) => ({ + toString: () => parts.join("/"), + fsPath: parts.join("/"), + }), + file: (p: string) => ({ fsPath: p, scheme: "file" }), + }, + window: { + createOutputChannel: () => ({ appendLine: vi.fn(), dispose: vi.fn() }), + registerWebviewViewProvider: vi.fn(() => ({ dispose: vi.fn() })), + showErrorMessage: vi.fn(), + showWarningMessage: vi.fn(), + showInformationMessage: vi.fn(), + showQuickPick: vi.fn(), + showInputBox: vi.fn(), + activeTextEditor: null, + }, + workspace: { + workspaceFolders: [], + onDidChangeTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidOpenTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + textDocuments: [], + getConfiguration: vi.fn(() => ({ get: vi.fn() })), + openTextDocument: vi.fn(), + }, + languages: { + createDiagnosticCollection: vi.fn(() => ({ + delete: vi.fn(), + set: vi.fn(), + dispose: vi.fn(), + })), + }, + commands: { + registerCommand: vi.fn(() => ({ dispose: vi.fn() })), + }, + TextEditorRevealType: { InCenter: 2 }, + DiagnosticSeverity: { Warning: 1 }, + Diagnostic: vi.fn(), + Range: vi.fn(), + Position: vi.fn(), + Selection: vi.fn(), +})); + +import { deactivate } from "../activate"; + +describe("deactivate", () => { + it("is callable with no active session (no-op)", () => { + expect(() => deactivate()).not.toThrow(); + }); +}); diff --git a/src/extension/activate.ts b/src/extension/activate.ts index 5c3f52a41..811b6fd8a 100644 --- a/src/extension/activate.ts +++ b/src/extension/activate.ts @@ -15,6 +15,14 @@ import { InspectorPanel } from "./inspector-panel"; import { InspectorViewProvider } from "./inspector-view-provider"; import { DebugStream } from "./debug-stream"; import { activateHookExplorer } from "./hooks/activate-hook-explorer"; +import { + PlaygroundViewProvider, + createPlaygroundDeps, +} from "./playground/view-provider"; +import { scanPlayground } from "./playground/scanner"; +import { PlaygroundFileWatcher } from "./playground/file-watcher"; + +let activePlaygroundProvider: PlaygroundViewProvider | null = null; export function activate(context: vscode.ExtensionContext): void { const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; @@ -237,13 +245,120 @@ export function activate(context: vscode.ExtensionContext): void { context.subscriptions.push({ dispose: () => inspectorPanel.dispose() }); + // ----- Playground (Chat Tab) ----- + const playgroundOutputChannel = vscode.window.createOutputChannel( + "CopilotKit Playground", + ); + context.subscriptions.push(playgroundOutputChannel); + + const runPlaygroundScan = (): void => { + if (!workspaceRoot) { + playgroundOutputChannel.appendLine( + "[playground] skip scan — no workspace folder open", + ); + return; + } + try { + playgroundOutputChannel.appendLine( + `[playground] scanning ${workspaceRoot}`, + ); + const result = scanPlayground(workspaceRoot); + playgroundOutputChannel.appendLine( + `[playground] scan done: ${result.providers.length} provider(s), ` + + `${result.componentsWithHooks.length} component(s), ` + + `${result.hookSites.length} hook site(s), ` + + `${result.warnings.length} warning(s)`, + ); + playgroundProvider.setScanResult(result); + } catch (err) { + const detail = + err instanceof Error ? (err.stack ?? err.message) : String(err); + playgroundOutputChannel.appendLine(`[playground] scan failed: ${detail}`); + void vscode.window.showErrorMessage( + `CopilotKit playground scan failed: ${ + err instanceof Error ? err.message : String(err) + } — see "CopilotKit Playground" output for details.`, + ); + } + }; + + const playgroundProvider = new PlaygroundViewProvider( + context.extensionUri, + { + onRefresh: runPlaygroundScan, + onOpenSource: async (filePath, line) => { + try { + const doc = await vscode.workspace.openTextDocument( + vscode.Uri.file(filePath), + ); + const editor = await vscode.window.showTextDocument(doc); + if (line) { + const pos = new vscode.Position(Math.max(0, line - 1), 0); + editor.revealRange( + new vscode.Range(pos, pos), + vscode.TextEditorRevealType.InCenter, + ); + editor.selection = new vscode.Selection(pos, pos); + } + } catch (err) { + const detail = + err instanceof Error ? (err.stack ?? err.message) : String(err); + playgroundOutputChannel.appendLine( + `[playground] open-source ${filePath}:${line ?? "?"} failed: ${detail}`, + ); + void vscode.window.showErrorMessage( + `Could not open source file: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }, + }, + createPlaygroundDeps(workspaceRoot ?? null, (line) => + playgroundOutputChannel.appendLine(line), + ), + ); + activePlaygroundProvider = playgroundProvider; + context.subscriptions.push( + vscode.window.registerWebviewViewProvider( + PlaygroundViewProvider.viewType, + playgroundProvider, + { webviewOptions: { retainContextWhenHidden: true } }, + ), + ); + context.subscriptions.push( + vscode.commands.registerCommand( + "copilotkit.chat.refresh", + runPlaygroundScan, + ), + ); + + // Hot-reload: rescan + rebundle whenever the user touches a TS/TSX/CSS + // file in their workspace. Without this, edits to hooks, providers, or + // the Tailwind entry CSS only show up in the chat after a manual + // `CopilotKit: Refresh` (or full extension reload). + if (workspaceRoot) { + const playgroundFileWatcher = new PlaygroundFileWatcher(() => { + playgroundOutputChannel.appendLine( + "[playground] file change detected — rescanning", + ); + runPlaygroundScan(); + }); + context.subscriptions.push(playgroundFileWatcher); + } + + runPlaygroundScan(); + // ----- Hook Explorer ----- // All wiring (tree, panel, persistence, scan, 5 commands) lives in its own // module so this file stays a thin composition root. activateHookExplorer(context, workspaceRoot); } -export function deactivate(): void {} +export function deactivate(): void { + if (activePlaygroundProvider) { + void activePlaygroundProvider.stopSession(); + activePlaygroundProvider = null; + } +} /** * Validates a fixture document and sets diagnostics (yellow squiggly lines). diff --git a/src/extension/hooks/__tests__/integration.test.ts b/src/extension/hooks/__tests__/integration.test.ts index 50b52c3f8..97330d400 100644 --- a/src/extension/hooks/__tests__/integration.test.ts +++ b/src/extension/hooks/__tests__/integration.test.ts @@ -18,25 +18,26 @@ describe("hooks integration", () => { `${a.hook}:${a.name ?? ""}`.localeCompare(`${b.hook}:${b.name ?? ""}`), ); expect(hooks).toEqual([ - { hook: "useCoAgentStateRender", name: "forecast_agent" }, - { hook: "useComponent", name: "sunTimes" }, - { hook: "useCopilotAction", name: "addLocation" }, - { hook: "useCopilotAction", name: "removeLocation" }, - { hook: "useCopilotAction", name: "severeAlert" }, - { hook: "useCopilotAction", name: "showAirQuality" }, - { hook: "useCopilotAuthenticatedAction_c", name: "publishAlert" }, { hook: "useDefaultRenderTool", name: "defaultWeatherFallback" }, { hook: "useDefaultTool", name: null }, - { hook: "useFrontendTool", name: "precipitationGauge" }, + { hook: "useFrontendTool", name: "addLocation" }, + { hook: "useFrontendTool", name: "displayAirQuality" }, + { hook: "useFrontendTool", name: "displayCurrentWeather" }, + { hook: "useFrontendTool", name: "displayForecast" }, + { hook: "useFrontendTool", name: "displayHistoricalTemps" }, + { hook: "useFrontendTool", name: "displayPollenReport" }, + { hook: "useFrontendTool", name: "displayPrecipitation" }, + { hook: "useFrontendTool", name: "displayRadar" }, + { hook: "useFrontendTool", name: "displaySevereAlert" }, + { hook: "useFrontendTool", name: "displaySunTimes" }, + { hook: "useFrontendTool", name: "displayWeatherCompact" }, + { hook: "useFrontendTool", name: "publishAlert" }, + { hook: "useFrontendTool", name: "removeLocation" }, { hook: "useHumanInTheLoop", name: "confirmEvacuation" }, { hook: "useInterrupt", name: null }, - { hook: "useLangGraphInterrupt", name: null }, - { hook: "useLazyToolRenderer", name: "historicalTemperatures" }, + { hook: "useInterrupt", name: null }, { hook: "useRenderActivityMessage", name: null }, { hook: "useRenderCustomMessages", name: null }, - { hook: "useRenderTool", name: "getWeather" }, - { hook: "useRenderTool", name: "pollenReport" }, - { hook: "useRenderToolCall", name: "viewRadar" }, ]); }); @@ -44,6 +45,7 @@ describe("hooks integration", () => { for (const fx of [ "AdminIssueAlert.tsx", "ConfirmEvacuation.tsx", + "CurrentWeatherTool.tsx", "DefaultWeatherCatchAll.tsx", "DefaultWeatherRender.tsx", "ForecastAgent.tsx", @@ -62,7 +64,7 @@ describe("hooks integration", () => { "WeatherTool.tsx", ]) { const result = await bundleHookSite(path.join(fixturesDir, fx)); - expect(result.success, fx ? `${fx}: ${result.error}` : undefined).toBe( + expect(result.success, `${fx}: ${result.error}`).toBe( true, ); expect(result.code, fx).toBeTruthy(); diff --git a/src/extension/hooks/hook-registry.ts b/src/extension/hooks/hook-registry.ts index 5893c51ac..ebac53f4c 100644 --- a/src/extension/hooks/hook-registry.ts +++ b/src/extension/hooks/hook-registry.ts @@ -75,7 +75,7 @@ export const HOOK_REGISTRY = [ category: "render", identityField: "name", renderProps: "render-tool", - importSource: "@copilotkit/react-core/v2", + importSource: "@copilotkit/react-core", }, { name: "useRenderCustomMessages", @@ -224,7 +224,7 @@ export const HOOK_REGISTRY = [ category: "render", identityField: "name", renderProps: "action", - importSource: "@copilotkit/react-core/v2", + importSource: "@copilotkit/react-core", }, ] as const satisfies ReadonlyArray; diff --git a/src/extension/hooks/hook-scanner.ts b/src/extension/hooks/hook-scanner.ts index d1737aeea..6cbc6d82d 100644 --- a/src/extension/hooks/hook-scanner.ts +++ b/src/extension/hooks/hook-scanner.ts @@ -3,6 +3,7 @@ import * as path from "node:path"; import ignore, { type Ignore } from "ignore"; import { parseSync } from "oxc-parser"; import { getHookDef, isCopilotKitHook } from "./hook-registry"; +import { buildLineOffsets, offsetToLineColumn } from "../playground/ast-utils"; export interface HookCallSite { filePath: string; @@ -44,40 +45,6 @@ function readFile(filePath: string): string | null { } } -/** - * Pre-computes line-start offsets for O(log n) offset→line/column conversion. - */ -function buildLineOffsets(source: string): number[] { - const offsets = [0]; - for (let i = 0; i < source.length; i++) { - if (source.charCodeAt(i) === 10 /* \n */) { - offsets.push(i + 1); - } - } - return offsets; -} - -function offsetToLineColumn( - offset: number, - lineOffsets: number[], -): { line: number; column: number } { - // Binary search for the largest line-start offset ≤ offset. - let lo = 0; - let hi = lineOffsets.length - 1; - while (lo < hi) { - const mid = (lo + hi + 1) >>> 1; - if (lineOffsets[mid]! <= offset) { - lo = mid; - } else { - hi = mid - 1; - } - } - return { - line: lo + 1, - column: offset - lineOffsets[lo]!, - }; -} - /** * Parse a file's content and return the hook call-sites within. * @@ -250,6 +217,10 @@ export interface ScanWorkspaceResult { capped: boolean; /** Number of .ts/.tsx files considered before the cap tripped. */ filesScanned: number; + /** Every .ts/.tsx file the walk visited (pre-content-prefilter). */ + visitedFiles: string[]; + /** Cached file contents from the first read, keyed by absolute path. */ + fileContents: Map; } /** @@ -266,6 +237,8 @@ export interface ScanWorkspaceResult { */ export function scanWorkspace(workspaceDir: string): ScanWorkspaceResult { const results: HookCallSite[] = []; + const visited: string[] = []; + const fileContents = new Map(); let filesSeen = 0; let capped = false; @@ -309,10 +282,21 @@ export function scanWorkspace(workspaceDir: string): ScanWorkspaceResult { capped = true; return; } - results.push(...scanFile(full)); + visited.push(full); + const content = readFile(full); + if (content) { + fileContents.set(full, content); + results.push(...scanContent(full, content)); + } } }; walk(workspaceDir, []); - return { sites: results, capped, filesScanned: filesSeen }; + return { + sites: results, + capped, + filesScanned: filesSeen, + visitedFiles: visited, + fileContents, + }; } diff --git a/src/extension/playground/__tests__/bundler.test.ts b/src/extension/playground/__tests__/bundler.test.ts new file mode 100644 index 000000000..97d820425 --- /dev/null +++ b/src/extension/playground/__tests__/bundler.test.ts @@ -0,0 +1,42 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { bundlePlayground } from "../bundler"; + +let tempDir: string | null = null; + +afterEach(() => { + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + tempDir = null; +}); + +describe("bundlePlayground", () => { + it("bundles a minimal entry into an IIFE exposing __copilotkit_playground", async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cpk-bundler-test-")); + fs.writeFileSync( + path.join(tempDir, "entry.tsx"), + [ + 'import * as React from "react";', + "export function PlaygroundEntry() {", + " return React.createElement('div', null, 'ok');", + "}", + ].join("\n"), + "utf-8", + ); + + const result = await bundlePlayground(path.join(tempDir, "entry.tsx")); + expect(result.success).toBe(true); + expect(result.code).toMatch(/var __copilotkit_playground/); + // Externalized: no literal 'react' import in output. + expect(result.code).not.toMatch(/from\s+["']react["']/); + }); + + it("returns an error for a nonexistent entry", async () => { + const result = await bundlePlayground("/definitely/does/not/exist.tsx"); + expect(result.success).toBe(false); + expect(result.error).toBeTruthy(); + }); +}); diff --git a/src/extension/playground/__tests__/e2e-bundle.test.ts b/src/extension/playground/__tests__/e2e-bundle.test.ts new file mode 100644 index 000000000..37fc80db5 --- /dev/null +++ b/src/extension/playground/__tests__/e2e-bundle.test.ts @@ -0,0 +1,52 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { scanPlayground } from "../scanner"; +import { writePlaygroundSources } from "../codegen/entry-codegen"; +import { bundlePlayground } from "../bundler"; + +const workspace = path.join( + __dirname, + "..", + "..", + "..", + "..", + "test-workspace", + "playground", +); + +let outDir: string | null = null; + +afterEach(() => { + if (outDir && fs.existsSync(outDir)) { + fs.rmSync(outDir, { recursive: true, force: true }); + } + outDir = null; +}); + +describe("playground end-to-end bundle", () => { + it.skip("scans → codegens → bundles the test-workspace playground", async () => { + const scan = scanPlayground(workspace); + const sources = writePlaygroundSources(scan); + expect(sources).not.toBeNull(); + outDir = sources!.outDir; + + const bundle = await bundlePlayground(sources!.entryPath); + expect(bundle.success).toBe(true); + expect(bundle.code).toBeTruthy(); + expect(bundle.code!).toMatch(/var __copilotkit_playground/); + + // The IIFE must reference every user component's name (as an imported + // identifier). This is the cheapest way to assert the aggregator wired + // up all of them. + for (const c of scan.componentsWithHooks) { + if (c.exportName == null) continue; // skipped components + expect(bundle.code!).toContain(c.componentName); + } + + // The provider chain must reference both ancestor tag names. + for (const a of scan.ancestorChain ?? []) { + expect(bundle.code!).toContain(a.tagName); + } + }, 60_000); +}); diff --git a/src/extension/playground/__tests__/e2e-vscode-lm.test.ts b/src/extension/playground/__tests__/e2e-vscode-lm.test.ts new file mode 100644 index 000000000..beb5068f3 --- /dev/null +++ b/src/extension/playground/__tests__/e2e-vscode-lm.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("vscode", () => ({ + CancellationTokenSource: class { + token = { isCancellationRequested: false }; + cancel() {} + dispose() {} + }, + LanguageModelTextPart: class { + constructor(public value: string) {} + }, + LanguageModelToolCallPart: class { + constructor( + public callId: string, + public name: string, + public input: unknown, + ) {} + }, + LanguageModelToolResultPart: class { + constructor( + public callId: string, + public content: unknown[], + ) {} + }, + LanguageModelChatMessage: { + User: (text: string) => ({ role: "user", content: text }), + Assistant: (text: string) => ({ role: "assistant", content: text }), + }, +})); + +import { startRuntimeHost } from "../runtime-host"; +import type { LanguageModelChat } from "vscode"; + +describe("e2e: vscode.lm runtime host serves AG-UI SSE events", () => { + it("streams text from a fake vscode.lm model through the runtime", async () => { + const { LanguageModelTextPart } = await import("vscode"); + const fakeModel = { + id: "test-model", + family: "test", + name: "Test", + vendor: "test", + sendRequest: vi.fn(async () => ({ + stream: (async function* () { + yield new LanguageModelTextPart("Hello"); + yield new LanguageModelTextPart(", world"); + })(), + text: (async function* () {})(), + })), + } as unknown as LanguageModelChat; + + const handle = await startRuntimeHost({ + model: fakeModel, + mode: "live", + log: () => {}, + }); + + try { + // POST a minimal RunAgentInput to the SSE run endpoint. + // Route: POST /api/copilotkit/agent/:agentId/run (from fetch-router.ts matchSegments) + const body = JSON.stringify({ + threadId: "t1", + runId: "r1", + state: {}, + messages: [{ id: "m1", role: "user", content: "hi" }], + tools: [], + context: [], + forwardedProps: {}, + }); + const res = await fetch( + `${handle.url}/api/copilotkit/agent/default/run`, + { + method: "POST", + headers: { + "content-type": "application/json", + accept: "text/event-stream", + }, + body, + }, + ); + expect(res.status).toBe(200); + // Drain the SSE body. + const text = await res.text(); + // The BuiltIn agent's TanStack converter emits TEXT_MESSAGE_CONTENT events + // (wrapped in TEXT_MESSAGE_START / TEXT_MESSAGE_END lifecycle events). + expect(text).toContain("TEXT_MESSAGE_CONTENT"); + expect(text).toContain("Hello"); + expect(text).toContain("world"); + expect(fakeModel.sendRequest).toHaveBeenCalled(); + } finally { + await handle.stop(); + } + }, 30_000); +}); diff --git a/src/extension/playground/__tests__/file-watcher.test.ts b/src/extension/playground/__tests__/file-watcher.test.ts new file mode 100644 index 000000000..a0827aed7 --- /dev/null +++ b/src/extension/playground/__tests__/file-watcher.test.ts @@ -0,0 +1,118 @@ +/** @vitest-environment node */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// vscode is mocked at the workspace level (see other extension-host tests). +// We only need a minimal stub for FileSystemWatcher. +type Handler = (uri: { fsPath: string }) => void; +const handlers: { + change: Handler[]; + create: Handler[]; + delete: Handler[]; +} = { change: [], create: [], delete: [] }; + +vi.mock("vscode", () => ({ + workspace: { + createFileSystemWatcher: () => ({ + onDidChange: (h: Handler) => handlers.change.push(h), + onDidCreate: (h: Handler) => handlers.create.push(h), + onDidDelete: (h: Handler) => handlers.delete.push(h), + dispose: () => {}, + }), + }, +})); + +import { PlaygroundFileWatcher } from "../file-watcher"; + +beforeEach(() => { + handlers.change = []; + handlers.create = []; + handlers.delete = []; + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function fireChange(fsPath: string): void { + for (const h of handlers.change) h({ fsPath }); +} +function fireCreate(fsPath: string): void { + for (const h of handlers.create) h({ fsPath }); +} +function fireDelete(fsPath: string): void { + for (const h of handlers.delete) h({ fsPath }); +} + +describe("PlaygroundFileWatcher", () => { + it("debounces a burst of changes into a single callback", () => { + const onAnyChange = vi.fn(); + const watcher = new PlaygroundFileWatcher(onAnyChange, { + debounceMs: 100, + }); + + fireChange("/work/src/Foo.tsx"); + fireChange("/work/src/Bar.tsx"); + fireChange("/work/src/Baz.tsx"); + + expect(onAnyChange).not.toHaveBeenCalled(); + vi.advanceTimersByTime(100); + expect(onAnyChange).toHaveBeenCalledTimes(1); + + watcher.dispose(); + }); + + it("treats create + delete + change as equivalent triggers", () => { + const onAnyChange = vi.fn(); + const watcher = new PlaygroundFileWatcher(onAnyChange, { + debounceMs: 50, + }); + + fireCreate("/work/src/New.tsx"); + vi.advanceTimersByTime(50); + expect(onAnyChange).toHaveBeenCalledTimes(1); + + fireDelete("/work/src/Old.tsx"); + vi.advanceTimersByTime(50); + expect(onAnyChange).toHaveBeenCalledTimes(2); + + fireChange("/work/src/Edited.tsx"); + vi.advanceTimersByTime(50); + expect(onAnyChange).toHaveBeenCalledTimes(3); + + watcher.dispose(); + }); + + it("ignores changes inside node_modules / dist / .git / .next", () => { + const onAnyChange = vi.fn(); + const watcher = new PlaygroundFileWatcher(onAnyChange, { + debounceMs: 50, + }); + + fireChange("/work/node_modules/foo/dist/index.tsx"); + fireChange("/work/dist/build.tsx"); + fireChange("/work/.git/HEAD"); + fireChange("/work/.next/cache/x.tsx"); + + vi.advanceTimersByTime(50); + expect(onAnyChange).not.toHaveBeenCalled(); + + fireChange("/work/src/Real.tsx"); + vi.advanceTimersByTime(50); + expect(onAnyChange).toHaveBeenCalledTimes(1); + + watcher.dispose(); + }); + + it("dispose() clears any pending timer so no callback fires after disposal", () => { + const onAnyChange = vi.fn(); + const watcher = new PlaygroundFileWatcher(onAnyChange, { + debounceMs: 100, + }); + + fireChange("/work/src/Foo.tsx"); + watcher.dispose(); + vi.advanceTimersByTime(200); + expect(onAnyChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/extension/playground/__tests__/find-copilotkit.test.ts b/src/extension/playground/__tests__/find-copilotkit.test.ts new file mode 100644 index 000000000..d29cddec9 --- /dev/null +++ b/src/extension/playground/__tests__/find-copilotkit.test.ts @@ -0,0 +1,59 @@ +import * as path from "node:path"; +import * as fs from "node:fs"; +import { describe, expect, it } from "vitest"; +import { findCopilotKitNodes } from "../find-copilotkit"; + +const fx = (name: string) => path.join(__dirname, "fixtures", name); +const read = (name: string) => fs.readFileSync(fx(name), "utf-8"); + +describe("findCopilotKitNodes", () => { + it("finds a single and returns its location", () => { + const src = read("simple-provider.tsx"); + const nodes = findCopilotKitNodes(fx("simple-provider.tsx"), src); + expect(nodes).toHaveLength(1); + expect(nodes[0].filePath).toBe(fx("simple-provider.tsx")); + expect(nodes[0].loc.line).toBeGreaterThan(0); + }); + + it("finds multiple providers in the same file", () => { + const src = read("multiple-providers.tsx"); + const nodes = findCopilotKitNodes(fx("multiple-providers.tsx"), src); + expect(nodes).toHaveLength(2); + }); + + it("returns empty when there is no CopilotKit import", () => { + const src = read("no-provider.tsx"); + const nodes = findCopilotKitNodes(fx("no-provider.tsx"), src); + expect(nodes).toEqual([]); + }); + + it("follows aliased imports", () => { + const src = read("aliased-provider.tsx"); + const nodes = findCopilotKitNodes(fx("aliased-provider.tsx"), src); + expect(nodes).toHaveLength(1); + }); + + it("detects v2 CopilotKitProvider from @copilotkit/react-core/v2", () => { + const src = read("v2-provider.tsx"); + const nodes = findCopilotKitNodes(fx("v2-provider.tsx"), src); + expect(nodes).toHaveLength(1); + expect(nodes[0].importedName).toBe("CopilotKitProvider"); + expect(nodes[0].importSource).toBe("@copilotkit/react-core/v2"); + }); + + it("detects backward-compat CopilotKit from @copilotkit/react-core/v2", () => { + const src = read("v2-backcompat-provider.tsx"); + const nodes = findCopilotKitNodes(fx("v2-backcompat-provider.tsx"), src); + expect(nodes).toHaveLength(1); + expect(nodes[0].importedName).toBe("CopilotKit"); + expect(nodes[0].importSource).toBe("@copilotkit/react-core/v2"); + }); + + it("records v1 importSource for the simple-provider fixture", () => { + const src = read("simple-provider.tsx"); + const nodes = findCopilotKitNodes(fx("simple-provider.tsx"), src); + expect(nodes).toHaveLength(1); + expect(nodes[0].importedName).toBe("CopilotKit"); + expect(nodes[0].importSource).toBe("@copilotkit/react-core"); + }); +}); diff --git a/src/extension/playground/__tests__/fixture-replay.test.ts b/src/extension/playground/__tests__/fixture-replay.test.ts new file mode 100644 index 000000000..3a96e8486 --- /dev/null +++ b/src/extension/playground/__tests__/fixture-replay.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { buildReplayMessages } from "../fixture-replay"; +import type { SavedFixture } from "../fixture-store"; + +const baseMetadata: SavedFixture["metadata"] = { + name: "test", + createdAt: "2026-04-23T12:00:00Z", + modelId: "auto", + modelVendor: "copilot", + version: 2, +}; + +describe("buildReplayMessages", () => { + it("returns an empty array when the fixture has no recorded calls", () => { + expect(buildReplayMessages({ metadata: baseMetadata, calls: [] })).toEqual( + [], + ); + }); + + it("emits user messages from the last call's input plus the reconstructed final assistant turn", () => { + const fixture: SavedFixture = { + metadata: baseMetadata, + calls: [ + { + matchKey: "k1", + input: { + messages: [ + { id: "u1", role: "user", content: "weather in berlin" }, + ], + tools: [], + modelId: "auto", + }, + chunks: [ + { type: "TEXT_MESSAGE_CONTENT", delta: "Sure, " } as any, + { type: "TEXT_MESSAGE_CONTENT", delta: "here you go." } as any, + ], + }, + ], + }; + const out = buildReplayMessages(fixture); + expect(out).toHaveLength(2); + expect(out[0]).toMatchObject({ + role: "user", + content: "weather in berlin", + }); + expect(out[1]).toMatchObject({ + role: "assistant", + content: "Sure, here you go.", + }); + }); + + it("rebuilds tool calls from TOOL_CALL_START + TOOL_CALL_ARGS chunks", () => { + const fixture: SavedFixture = { + metadata: baseMetadata, + calls: [ + { + matchKey: "k1", + input: { + messages: [{ id: "u1", role: "user", content: "show weather" }], + tools: [], + modelId: "auto", + }, + chunks: [ + { + type: "TOOL_CALL_START", + toolCallId: "t1", + toolCallName: "displayCurrentWeather", + } as any, + { + type: "TOOL_CALL_ARGS", + toolCallId: "t1", + delta: '{"city":"', + } as any, + { + type: "TOOL_CALL_ARGS", + toolCallId: "t1", + delta: 'Berlin"}', + } as any, + { type: "TOOL_CALL_END", toolCallId: "t1" } as any, + ], + }, + ], + }; + const out = buildReplayMessages(fixture); + expect(out).toHaveLength(2); + expect(out[1]).toMatchObject({ + role: "assistant", + content: "", + toolCalls: [ + { + id: "t1", + type: "function", + function: { + name: "displayCurrentWeather", + arguments: '{"city":"Berlin"}', + }, + }, + ], + }); + }); + + it("emits TOOL_CALL_RESULT chunks as separate tool messages", () => { + const fixture: SavedFixture = { + metadata: baseMetadata, + calls: [ + { + matchKey: "k1", + input: { + messages: [{ id: "u1", role: "user", content: "search" }], + tools: [], + modelId: "auto", + }, + chunks: [ + { + type: "TOOL_CALL_START", + toolCallId: "t1", + toolCallName: "fetch_webpage", + } as any, + { type: "TOOL_CALL_END", toolCallId: "t1" } as any, + { + type: "TOOL_CALL_RESULT", + toolCallId: "t1", + content: "…", + } as any, + ], + }, + ], + }; + const out = buildReplayMessages(fixture); + const toolMsg = out.find((m) => m.role === "tool"); + expect(toolMsg).toBeDefined(); + expect(toolMsg).toMatchObject({ + role: "tool", + toolCallId: "t1", + content: "…", + }); + }); +}); diff --git a/src/extension/playground/__tests__/fixture-store.test.ts b/src/extension/playground/__tests__/fixture-store.test.ts new file mode 100644 index 000000000..44999c3e0 --- /dev/null +++ b/src/extension/playground/__tests__/fixture-store.test.ts @@ -0,0 +1,85 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { FixtureStore, type FixtureMetadata } from "../fixture-store"; +import type { RecordedCall } from "../vscode-lm-factory"; + +let workspaceRoot: string; + +beforeEach(() => { + workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "fixture-store-")); +}); + +afterEach(() => { + if (fs.existsSync(workspaceRoot)) { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + } +}); + +const meta: FixtureMetadata = { + name: "round-trip", + createdAt: "2026-04-28T12:00:00Z", + modelId: "claude-3-5-sonnet", + modelVendor: "github-copilot", + version: 2, +}; + +const sampleCall: RecordedCall = { + matchKey: "a".repeat(64), + input: { messages: [], tools: [], modelId: "claude-3-5-sonnet" }, + chunks: [{ type: "TEXT_MESSAGE_CONTENT", delta: "Hello" }], +}; + +describe("FixtureStore (v2)", () => { + it("lists empty when the fixtures dir does not exist", () => { + const store = new FixtureStore(workspaceRoot); + expect(store.list()).toEqual([]); + }); + + it("saves a v2 fixture and reads it back with calls[]", () => { + const store = new FixtureStore(workspaceRoot); + const file = store.save(meta, { calls: [sampleCall] }); + const fixture = store.read(file); + expect(fixture.metadata.modelId).toBe("claude-3-5-sonnet"); + expect(fixture.metadata.version).toBe(2); + expect(fixture.calls).toEqual([sampleCall]); + }); + + it("sanitizes unsafe filenames", () => { + const store = new FixtureStore(workspaceRoot); + store.save({ ...meta, name: "../../escape!" }, { calls: [] }); + const [entry] = store.list(); + expect(entry.filePath).not.toContain(".."); + expect(path.dirname(entry.filePath)).toBe( + path.join(workspaceRoot, ".copilotkit", "fixtures"), + ); + }); + + it("skips v1 (journal-shaped) fixtures with no version field", () => { + const dir = path.join(workspaceRoot, ".copilotkit", "fixtures"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, "old.json"), + JSON.stringify({ + metadata: { name: "old", createdAt: "2026-01-01T00:00:00Z" }, + recording: [], + }), + "utf-8", + ); + const warnings: string[] = []; + const store = new FixtureStore(workspaceRoot, { + onWarn: (m) => warnings.push(m), + }); + expect(store.list()).toEqual([]); + expect(warnings.some((w) => w.includes("old.json"))).toBe(true); + }); + + it("deletes a fixture", () => { + const store = new FixtureStore(workspaceRoot); + store.save(meta, { calls: [] }); + const [entry] = store.list(); + store.delete(entry.filePath); + expect(store.list()).toEqual([]); + }); +}); diff --git a/src/extension/playground/__tests__/fixtures/aliased-provider.tsx b/src/extension/playground/__tests__/fixtures/aliased-provider.tsx new file mode 100644 index 000000000..fcccef790 --- /dev/null +++ b/src/extension/playground/__tests__/fixtures/aliased-provider.tsx @@ -0,0 +1,9 @@ +import { CopilotKit as CK } from "@copilotkit/react-core"; + +export default function App() { + return ( + +
+ + ); +} diff --git a/src/extension/playground/__tests__/fixtures/hooks-in-arrow.tsx b/src/extension/playground/__tests__/fixtures/hooks-in-arrow.tsx new file mode 100644 index 000000000..71ef4a1fd --- /dev/null +++ b/src/extension/playground/__tests__/fixtures/hooks-in-arrow.tsx @@ -0,0 +1,8 @@ +import { useCopilotAction } from "@copilotkit/react-core"; + +export const Page = () => { + useCopilotAction({ name: "doThing", handler: () => {} }); + return
; +}; + +export default Page; diff --git a/src/extension/playground/__tests__/fixtures/hooks-in-components.tsx b/src/extension/playground/__tests__/fixtures/hooks-in-components.tsx new file mode 100644 index 000000000..56e237ab2 --- /dev/null +++ b/src/extension/playground/__tests__/fixtures/hooks-in-components.tsx @@ -0,0 +1,12 @@ +import { useCopilotAction, useCopilotReadable } from "@copilotkit/react-core"; + +export function MyPage() { + useCopilotAction({ name: "addTodo", handler: () => {} }); + useCopilotReadable({ description: "todos", value: [] }); + return
; +} + +export function Sidebar() { + useCopilotAction({ name: "removeTodo", handler: () => {} }); + return