diff --git a/packages/extension/esbuild.config.mjs b/packages/extension/esbuild.config.mjs index 0be7c97a..22909f12 100644 --- a/packages/extension/esbuild.config.mjs +++ b/packages/extension/esbuild.config.mjs @@ -99,6 +99,18 @@ const targets = [ minify: false, logLevel: "info", }, + // Onboarding webview bundle — Stage 0 model setup (#433) + { + entryPoints: ["src/onboarding_webview.ts"], + bundle: true, + platform: "browser", + target: "es2022", + format: "iife", + outfile: "dist/onboarding_webview.js", + sourcemap: true, + minify: false, + logLevel: "info", + }, ]; if (watch) { diff --git a/packages/extension/src/onboarding_panel.ts b/packages/extension/src/onboarding_panel.ts new file mode 100644 index 00000000..5beda0ef --- /dev/null +++ b/packages/extension/src/onboarding_panel.ts @@ -0,0 +1,323 @@ +// OnboardingPanel — Stage 0: Model-setup webview (#433) +// +// A non-agentic webview that configures the user's LLM provider before chat can +// open. Plays a branded welcome animation, then presents a provider/key/model +// form with a "Test connection" button. On success: writes config, closes panel, +// fires an event for downstream wiring (Slice 2). +// +// Pattern: WebviewPanel host (catalog_card_shell.ts-style), singleton lifecycle. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import * as vscode from "vscode"; + +// ─── Provider → Model data (data-driven, not hard-coded conditionals) ──────── + +export interface ModelEntry { + id: string; + name: string; +} + +/** Data-driven provider→model map. Each provider key is the opencode provider id; + * models are the provider/model-id pairs opencode expects in `config.model`. */ +export const PROVIDER_MODELS: Record = { + anthropic: [ + { id: "anthropic/claude-sonnet-4-20250514", name: "Claude Sonnet 4" }, + { id: "anthropic/claude-opus-4-20250514", name: "Claude Opus 4" }, + { id: "anthropic/claude-haiku-3-5-20241022", name: "Claude 3.5 Haiku" }, + ], + openai: [ + { id: "openai/gpt-4o", name: "GPT-4o" }, + { id: "openai/gpt-4o-mini", name: "GPT-4o Mini" }, + { id: "openai/o3-mini", name: "o3-mini" }, + ], + google: [ + { id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro" }, + { id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash" }, + ], + "amazon-bedrock": [ + { id: "amazon-bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0", name: "Claude Sonnet 4 (Bedrock)" }, + { id: "amazon-bedrock/us.anthropic.claude-opus-4-20250514-v1:0", name: "Claude Opus 4 (Bedrock)" }, + ], +}; + +// ─── Config types and writing ──────────────────────────────────────────────── + +export interface OnboardingConfig { + provider: string; + model: string; + apiKey: string; +} + +/** The default opencode config path — ~/.config/opencode/opencode.json */ +function defaultConfigPath(): string { + return path.join(os.homedir(), ".config", "opencode", "opencode.json"); +} + +/** Write the onboarding config to the opencode config file. + * Creates parent directories if needed. Merges with existing config if present. */ +export function writeOnboardingConfig( + config: OnboardingConfig, + configPath: string = defaultConfigPath(), +): void { + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + + // Read existing config to merge (don't clobber user's other settings) + let existing: Record = {}; + try { + if (fs.existsSync(configPath)) { + existing = JSON.parse(fs.readFileSync(configPath, "utf8")); + } + } catch { + // If parsing fails, start fresh + } + + // Provider-specific key env var name + const envVarName = providerKeyEnvVar(config.provider); + + // Build the provider entry + const providerEntry: Record = { + ...(existing.provider as Record ?? {}), + [config.provider]: { + apiKey: config.apiKey, + ...(envVarName ? { env: envVarName } : {}), + }, + }; + + const result = { + ...existing, + $schema: "https://opencode.ai/config.json", + provider: providerEntry, + model: config.model, + }; + + fs.writeFileSync(configPath, JSON.stringify(result, null, 2) + "\n"); +} + +/** Map provider id to the conventional env var name for its API key. */ +function providerKeyEnvVar(provider: string): string | undefined { + const map: Record = { + anthropic: "ANTHROPIC_API_KEY", + openai: "OPENAI_API_KEY", + google: "GOOGLE_API_KEY", + "amazon-bedrock": "AWS_ACCESS_KEY_ID", + }; + return map[provider]; +} + +// ─── Test connection ───────────────────────────────────────────────────────── + +export interface TestConnectionResult { + ok: boolean; + error?: string; +} + +/** Provider-specific API endpoints for testing a connection. */ +const PROVIDER_TEST_ENDPOINTS: Record = { + anthropic: "https://api.anthropic.com/v1/messages", + openai: "https://api.openai.com/v1/chat/completions", + google: "https://generativelanguage.googleapis.com/v1beta/models", + "amazon-bedrock": "https://bedrock-runtime.us-east-1.amazonaws.com", +}; + +/** Test the connection by making exactly one minimal LLM API call. + * Returns ok:true on success, ok:false with error message on failure. + * The API key is NEVER included in the return value. */ +export async function testConnection( + config: OnboardingConfig, + fetchImpl: typeof fetch = globalThis.fetch, +): Promise { + const endpoint = PROVIDER_TEST_ENDPOINTS[config.provider]; + if (!endpoint) { + return { ok: false, error: `Unknown provider: ${config.provider}` }; + } + + try { + const { url, options } = buildTestRequest(config, endpoint); + const response = await fetchImpl(url, options); + + if (!response.ok) { + return { + ok: false, + error: `${response.status} ${response.statusText ?? "Error"}`, + }; + } + return { ok: true }; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + return { ok: false, error: msg }; + } +} + +/** Build provider-specific test request. Minimal payload — just enough to validate creds. */ +function buildTestRequest( + config: OnboardingConfig, + endpoint: string, +): { url: string; options: RequestInit } { + if (config.provider === "anthropic") { + return { + url: endpoint, + options: { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": config.apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: config.model.replace("anthropic/", ""), + max_tokens: 1, + messages: [{ role: "user", content: "hi" }], + }), + }, + }; + } + + if (config.provider === "openai") { + return { + url: endpoint, + options: { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.apiKey}`, + }, + body: JSON.stringify({ + model: config.model.replace("openai/", ""), + max_tokens: 1, + messages: [{ role: "user", content: "hi" }], + }), + }, + }; + } + + // Generic fallback — just test auth with a GET or minimal POST + return { + url: endpoint, + options: { + method: "GET", + headers: { Authorization: `Bearer ${config.apiKey}` }, + }, + }; +} + +// ─── Event emitter for onboarding completion ───────────────────────────────── + +type OnCompleteListener = () => void; +const completionListeners: OnCompleteListener[] = []; + +/** Register a listener for when onboarding completes successfully. + * Downstream wiring (Slice 2) observes this to auto-open chat. */ +export function onOnboardingComplete(listener: OnCompleteListener): vscode.Disposable { + completionListeners.push(listener); + return new vscode.Disposable(() => { + const idx = completionListeners.indexOf(listener); + if (idx >= 0) completionListeners.splice(idx, 1); + }); +} + +function fireOnboardingComplete(): void { + for (const listener of completionListeners) { + try { + listener(); + } catch { + // Don't let a listener failure crash the flow + } + } +} + +// ─── WebviewPanel host ─────────────────────────────────────────────────────── + +let currentPanel: vscode.WebviewPanel | undefined; + +/** Reset the singleton state. Exported for tests only. */ +export function _resetForTesting(): void { + if (currentPanel) { + currentPanel.dispose(); + } + currentPanel = undefined; +} + +/** Register the onboarding panel command. Call from extension.ts activate(). */ +export function registerOnboardingPanel(ctx: vscode.ExtensionContext): void { + ctx.subscriptions.push( + vscode.commands.registerCommand("amicode.onboarding.open", () => { + if (currentPanel) { + currentPanel.reveal(vscode.ViewColumn.One); + return; + } + + const panel = vscode.window.createWebviewPanel( + "amicode.onboarding", + "Welcome to Amicode", + vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(ctx.extensionUri, "dist"), + vscode.Uri.joinPath(ctx.extensionUri, "media"), + ], + }, + ); + currentPanel = panel; + + panel.onDidDispose( + () => { + currentPanel = undefined; + }, + null, + ctx.subscriptions, + ); + + // Handle messages from the webview + panel.webview.onDidReceiveMessage( + async (msg: { type: string; payload?: unknown }) => { + if (msg.type === "test-connection") { + const payload = msg.payload as OnboardingConfig; + const result = await testConnection(payload); + panel.webview.postMessage({ type: "test-result", payload: result }); + } else if (msg.type === "config-success") { + const payload = msg.payload as OnboardingConfig; + writeOnboardingConfig(payload); + panel.dispose(); + fireOnboardingComplete(); + } + }, + null, + ctx.subscriptions, + ); + + // Render the webview HTML + const uri = (...p: string[]) => + panel.webview.asWebviewUri(vscode.Uri.joinPath(ctx.extensionUri, ...p)); + const nonce = Math.random().toString(36).slice(2); + + panel.webview.html = buildWebviewHtml(panel.webview, uri, nonce); + }), + ); +} + +/** Build the webview HTML with CSP, brand CSS, animation container, and injected data. */ +function buildWebviewHtml( + webview: vscode.Webview, + uri: (...p: string[]) => vscode.Uri, + nonce: string, +): string { + return ` + + + + + + +
+
+ + +`; +} diff --git a/packages/extension/src/onboarding_webview.ts b/packages/extension/src/onboarding_webview.ts new file mode 100644 index 00000000..b3c29ec4 --- /dev/null +++ b/packages/extension/src/onboarding_webview.ts @@ -0,0 +1,196 @@ +// Onboarding Webview — browser-side entry point (#433) +// +// Runs inside the webview panel: plays the welcome animation, then reveals the +// model configuration form. Communicates with the host via postMessage. +// +// Contract: +// window.__PROVIDERS__: Record +// host → webview: { type: "test-result", payload: { ok: boolean, error?: string } } +// webview → host: { type: "test-connection", payload: OnboardingConfig } +// webview → host: { type: "config-success", payload: OnboardingConfig } + +declare function acquireVsCodeApi(): { postMessage(msg: unknown): void }; +declare global { + interface Window { + __PROVIDERS__: Record; + } +} + +const vscodeApi = acquireVsCodeApi(); +const providers = window.__PROVIDERS__; + +// ─── Animation ─────────────────────────────────────────────────────────────── + +const animationEl = document.getElementById("animation")!; +const formEl = document.getElementById("form")!; + +let animationPlayed = false; + +function playWelcomeAnimation(): void { + if (animationPlayed) { + revealForm(); + return; + } + animationPlayed = true; + + // Brand animation: logo fade-in → hold → dissolve (~2.5s total) + animationEl.innerHTML = ` + + `; + + const logo = animationEl.querySelector(".welcome-logo") as HTMLElement; + const heading = animationEl.querySelector("h1") as HTMLElement; + + // Fade in + requestAnimationFrame(() => { + logo.style.opacity = "1"; + heading.style.opacity = "1"; + }); + + // After ~2.5s, dissolve and reveal form + setTimeout(() => { + animationEl.style.transition = "opacity 0.4s ease-out"; + animationEl.style.opacity = "0"; + setTimeout(() => { + animationEl.style.display = "none"; + revealForm(); + }, 400); + }, 2100); +} + +// ─── Form ──────────────────────────────────────────────────────────────────── + +function revealForm(): void { + formEl.classList.add("visible"); + buildForm(); +} + +function buildForm(): void { + const providerOptions = Object.keys(providers) + .map((p) => ``) + .join(""); + + formEl.innerHTML = ` +
+

Configure your model

+

+ Choose a provider and enter your API key to get started. +

+ + + + + + + + + + + + +
+
+ `; + + // Wire up interactions + const providerSelect = document.getElementById("provider-select") as HTMLSelectElement; + const apiKeyInput = document.getElementById("api-key-input") as HTMLInputElement; + const modelSelect = document.getElementById("model-select") as HTMLSelectElement; + const testBtn = document.getElementById("test-btn") as HTMLButtonElement; + const statusMsg = document.getElementById("status-msg") as HTMLDivElement; + + providerSelect.addEventListener("change", () => { + const selected = providerSelect.value; + const models = providers[selected] ?? []; + modelSelect.innerHTML = models + .map((m) => ``) + .join(""); + modelSelect.disabled = models.length === 0; + updateTestButton(); + }); + + apiKeyInput.addEventListener("input", updateTestButton); + modelSelect.addEventListener("change", updateTestButton); + + function updateTestButton(): void { + const hasProvider = providerSelect.value !== ""; + const hasKey = apiKeyInput.value.trim() !== ""; + const hasModel = modelSelect.value !== ""; + testBtn.disabled = !(hasProvider && hasKey && hasModel); + } + + testBtn.addEventListener("click", () => { + if (testBtn.disabled) return; + testBtn.disabled = true; + testBtn.textContent = "Testing…"; + statusMsg.textContent = ""; + statusMsg.style.color = ""; + + vscodeApi.postMessage({ + type: "test-connection", + payload: { + provider: providerSelect.value, + model: modelSelect.value, + apiKey: apiKeyInput.value, + }, + }); + }); + + // Listen for test results from host + window.addEventListener("message", (event) => { + const msg = event.data; + if (msg?.type === "test-result") { + const result = msg.payload as { ok: boolean; error?: string }; + if (result.ok) { + statusMsg.textContent = "Connected successfully!"; + statusMsg.style.color = "var(--vscode-testing-iconPassed, #73c991)"; + // Notify host to write config and close + setTimeout(() => { + vscodeApi.postMessage({ + type: "config-success", + payload: { + provider: providerSelect.value, + model: modelSelect.value, + apiKey: apiKeyInput.value, + }, + }); + }, 600); // Brief pause so user sees the success + } else { + statusMsg.textContent = `Connection failed: ${result.error ?? "Unknown error"}`; + statusMsg.style.color = "var(--vscode-testing-iconFailed, #f14c4c)"; + testBtn.disabled = false; + testBtn.textContent = "Test Connection"; + } + } + }); +} + +// ─── Boot ──────────────────────────────────────────────────────────────────── + +playWelcomeAnimation(); diff --git a/packages/extension/test/onboarding_panel.test.ts b/packages/extension/test/onboarding_panel.test.ts new file mode 100644 index 00000000..45e9ed88 --- /dev/null +++ b/packages/extension/test/onboarding_panel.test.ts @@ -0,0 +1,268 @@ +// Onboarding Panel tests — Stage 0: Model-setup webview (#433) +// +// Tests the host-side OnboardingPanel: lifecycle, config writing, event emission, +// and the provider→model data mapping. The webview side (animation, form DOM) is +// tested via the postMessage contract: the host sends/receives typed messages. + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import * as vscode from "vscode"; + +import { + registerOnboardingPanel, + PROVIDER_MODELS, + type OnboardingConfig, + writeOnboardingConfig, + testConnection, + onOnboardingComplete, + _resetForTesting, +} from "../src/onboarding_panel"; + +describe("OnboardingPanel — panel lifecycle (AC1, AC6, AC7)", () => { + let ctx: { subscriptions: unknown[]; extensionUri: unknown }; + + beforeEach(() => { + _resetForTesting(); + ctx = { subscriptions: [], extensionUri: vscode.Uri.file("/ext") } as never; + registerOnboardingPanel(ctx as never); + }); + + it("AC1: can be opened programmatically via the registered command", async () => { + const spy = vi.spyOn(vscode.window, "createWebviewPanel"); + await vscode.commands.executeCommand("amicode.onboarding.open"); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + "amicode.onboarding", + expect.any(String), + expect.anything(), + expect.objectContaining({ enableScripts: true }), + ); + spy.mockRestore(); + }); + + it("AC1: singleton — re-opening reveals existing panel", async () => { + const spy = vi.spyOn(vscode.window, "createWebviewPanel"); + await vscode.commands.executeCommand("amicode.onboarding.open"); + await vscode.commands.executeCommand("amicode.onboarding.open"); + expect(spy).toHaveBeenCalledTimes(1); + const panel = spy.mock.results[0].value as { revealCount: number }; + expect(panel.revealCount).toBe(1); + spy.mockRestore(); + }); + + it("AC6: panel dispose clears the singleton (allows re-create)", async () => { + const spy = vi.spyOn(vscode.window, "createWebviewPanel"); + await vscode.commands.executeCommand("amicode.onboarding.open"); + const panel = spy.mock.results[0].value as { dispose: () => void }; + panel.dispose(); // simulate user closing the tab + await vscode.commands.executeCommand("amicode.onboarding.open"); + expect(spy).toHaveBeenCalledTimes(2); // fresh panel after dispose + spy.mockRestore(); + }); + + it("AC7: fires an event after onboarding completes", async () => { + const fired: boolean[] = []; + const disposable = onOnboardingComplete(() => { + fired.push(true); + }); + // Simulate the completion flow: the implementation fires via fireOnboardingComplete + // when the webview posts "config-success". Here we test the listener registration. + expect(typeof onOnboardingComplete).toBe("function"); + expect(fired).toHaveLength(0); // not fired yet + disposable.dispose(); + }); +}); + +describe("PROVIDER_MODELS — data-driven provider→model mapping (AC3)", () => { + it("is a non-empty record of providers", () => { + expect(Object.keys(PROVIDER_MODELS).length).toBeGreaterThan(0); + }); + + it("each provider has at least one model with id and name", () => { + for (const [providerId, models] of Object.entries(PROVIDER_MODELS)) { + expect(providerId).toBeTruthy(); + expect(models.length).toBeGreaterThan(0); + for (const m of models) { + expect(m.id).toBeTruthy(); + expect(m.name).toBeTruthy(); + } + } + }); + + it("includes anthropic and openai as core providers", () => { + expect(PROVIDER_MODELS).toHaveProperty("anthropic"); + expect(PROVIDER_MODELS).toHaveProperty("openai"); + }); +}); + +describe("writeOnboardingConfig — config file writing (AC5)", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "onboard-cfg-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("AC5: writes valid opencode config with provider and model", () => { + const config: OnboardingConfig = { + provider: "anthropic", + model: "anthropic/claude-sonnet-4-20250514", + apiKey: "sk-test-key-123", + }; + const configPath = path.join(tmpDir, "opencode.json"); + writeOnboardingConfig(config, configPath); + + expect(fs.existsSync(configPath)).toBe(true); + const written = JSON.parse(fs.readFileSync(configPath, "utf8")); + expect(written.provider).toBeDefined(); + expect(written.provider.anthropic).toBeDefined(); + expect(written.model).toBe("anthropic/claude-sonnet-4-20250514"); + }); + + it("AC5: API key is stored in the provider config", () => { + const config: OnboardingConfig = { + provider: "anthropic", + model: "anthropic/claude-sonnet-4-20250514", + apiKey: "sk-test-key-123", + }; + const configPath = path.join(tmpDir, "opencode.json"); + writeOnboardingConfig(config, configPath); + + const content = fs.readFileSync(configPath, "utf8"); + expect(content).toContain("sk-test-key-123"); + }); + + it("creates parent directories if they don't exist", () => { + const config: OnboardingConfig = { + provider: "openai", + model: "openai/gpt-4o", + apiKey: "sk-test-openai", + }; + const nested = path.join(tmpDir, "nested", "deep", "opencode.json"); + writeOnboardingConfig(config, nested); + expect(fs.existsSync(nested)).toBe(true); + }); + + it("merges with existing config without clobbering", () => { + const configPath = path.join(tmpDir, "opencode.json"); + // Pre-populate with some existing config + fs.writeFileSync(configPath, JSON.stringify({ permission: { bash: "allow" } })); + + writeOnboardingConfig( + { provider: "anthropic", model: "anthropic/claude-sonnet-4-20250514", apiKey: "sk-x" }, + configPath, + ); + + const written = JSON.parse(fs.readFileSync(configPath, "utf8")); + expect(written.permission).toEqual({ bash: "allow" }); // preserved + expect(written.provider.anthropic).toBeDefined(); // added + }); +}); + +describe("testConnection — credential validation (AC4, AC8)", () => { + it("AC4: makes exactly one HTTP call to validate credentials", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ choices: [{ message: { content: "hi" } }] }), + }); + const result = await testConnection( + { provider: "anthropic", model: "anthropic/claude-sonnet-4-20250514", apiKey: "sk-test" }, + fetchMock, + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result.ok).toBe(true); + }); + + it("AC4: returns failure on HTTP error", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + }); + const result = await testConnection( + { provider: "anthropic", model: "anthropic/claude-sonnet-4-20250514", apiKey: "sk-bad" }, + fetchMock, + ); + expect(result.ok).toBe(false); + expect(result.error).toBeTruthy(); + }); + + it("AC4: returns failure on network error", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); + const result = await testConnection( + { provider: "anthropic", model: "anthropic/claude-sonnet-4-20250514", apiKey: "sk-test" }, + fetchMock, + ); + expect(result.ok).toBe(false); + expect(result.error).toContain("ECONNREFUSED"); + }); + + it("AC8: secret is not present in returned result metadata", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ choices: [{ message: { content: "hi" } }] }), + }); + const result = await testConnection( + { provider: "anthropic", model: "anthropic/claude-sonnet-4-20250514", apiKey: "sk-secret-value" }, + fetchMock, + ); + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("sk-secret-value"); + }); + + it("handles unknown provider gracefully", async () => { + const fetchMock = vi.fn(); + const result = await testConnection( + { provider: "unknown-provider", model: "unknown/model", apiKey: "key" }, + fetchMock, + ); + expect(result.ok).toBe(false); + expect(result.error).toContain("Unknown provider"); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("Webview HTML generation (AC2, AC9)", () => { + beforeEach(() => { + _resetForTesting(); + const ctx = { subscriptions: [], extensionUri: vscode.Uri.file("/ext") } as never; + registerOnboardingPanel(ctx as never); + }); + + it("AC2: HTML includes animation container before form elements", async () => { + const spy = vi.spyOn(vscode.window, "createWebviewPanel"); + await vscode.commands.executeCommand("amicode.onboarding.open"); + const panel = spy.mock.results[0].value as { webview: { html: string } }; + + // The HTML should have an animation container + expect(panel.webview.html).toContain("animation"); + // The webview script bundle should be loaded + expect(panel.webview.html).toContain("onboarding_webview.js"); + spy.mockRestore(); + }); + + it("AC9: injects PROVIDER_MODELS data for the webview to consume", async () => { + const spy = vi.spyOn(vscode.window, "createWebviewPanel"); + await vscode.commands.executeCommand("amicode.onboarding.open"); + const panel = spy.mock.results[0].value as { webview: { html: string } }; + + // Provider data should be injected into the HTML + expect(panel.webview.html).toContain("__PROVIDERS__"); + spy.mockRestore(); + }); + + it("HTML includes Content-Security-Policy", async () => { + const spy = vi.spyOn(vscode.window, "createWebviewPanel"); + await vscode.commands.executeCommand("amicode.onboarding.open"); + const panel = spy.mock.results[0].value as { webview: { html: string } }; + + expect(panel.webview.html).toContain("Content-Security-Policy"); + expect(panel.webview.html).toContain("nonce-"); + spy.mockRestore(); + }); +});