From ac7062cc1c4a0a0eb60dc594e5bb4652b67ca723 Mon Sep 17 00:00:00 2001 From: Matt Leaverton Date: Mon, 30 Mar 2026 12:08:20 -0500 Subject: [PATCH] test: rewrite skipped/placeholder tests with working implementations - agent-chat.spec.ts: implement permission banner test, use Redux state dispatch instead of reading live state for determinism - update-flow.test.ts: replace skip placeholders with real precheck script tests using child_process spawn - codex-session-flow.test.ts: replace live-Codex integration with hermetic test using fake executable and mocked config - dom.ts: guard clipboard mock against node environment (needed for @vitest-environment node tests that share the setup file) Co-Authored-By: Claude Opus 4.6 (1M context) --- test/e2e-browser/specs/agent-chat.spec.ts | 128 +++++-- test/e2e/update-flow.test.ts | 286 ++++++--------- .../server/codex-session-flow.test.ts | 327 ++++++++++++++---- test/setup/dom.ts | 10 +- 4 files changed, 483 insertions(+), 268 deletions(-) diff --git a/test/e2e-browser/specs/agent-chat.spec.ts b/test/e2e-browser/specs/agent-chat.spec.ts index 6e90e01f..fa1cc158 100644 --- a/test/e2e-browser/specs/agent-chat.spec.ts +++ b/test/e2e-browser/specs/agent-chat.spec.ts @@ -17,6 +17,14 @@ test.describe('Agent Chat', () => { .toBeVisible({ timeout: 10_000 }) } + async function getActiveLeaf(harness: any) { + const tabId = await harness.getActiveTabId() + expect(tabId).toBeTruthy() + const layout = await harness.getPaneLayout(tabId!) + expect(layout?.type).toBe('leaf') + return { tabId: tabId!, paneId: layout.id as string } + } + test('pane picker shows base pane types', async ({ freshellPage, page, terminal }) => { await terminal.waitForTerminal() await openPanePicker(page) @@ -37,37 +45,105 @@ test.describe('Agent Chat', () => { expect(shellVisible || wslVisible || cmdVisible || psVisible).toBe(true) }) - test('agent chat provider appears when CLI is available', async ({ freshellPage, page, harness, terminal }) => { + test('agent chat provider appears when the Claude CLI is available and enabled', async ({ freshellPage, page, terminal }) => { await terminal.waitForTerminal() - - // Check if any agent chat provider is available via Redux state - const state = await harness.getState() - const availableClis = state.connection?.availableClis ?? {} - const enabledProviders = state.settings?.settings?.codingCli?.enabledProviders ?? [] - - // Find a provider that is both available and enabled - const hasProvider = Object.keys(availableClis).some( - (cli) => availableClis[cli] && enabledProviders.includes(cli) - ) - - if (!hasProvider) { - // No CLI providers available in the isolated test env -- skip - test.skip() - return - } + await page.evaluate(() => { + const harness = window.__FRESHELL_TEST_HARNESS__ + harness?.dispatch({ + type: 'connection/setAvailableClis', + payload: { claude: true }, + }) + harness?.dispatch({ + type: 'settings/updateSettingsLocal', + payload: { + codingCli: { + enabledProviders: ['claude'], + }, + }, + }) + }) await openPanePicker(page) - - // The picker should show more than just Shell/Editor/Browser - const pickerOptions = page.locator('[data-testid="pane-picker-options"] button') - const count = await pickerOptions.count() - expect(count).toBeGreaterThan(3) + await expect(page.getByRole('button', { name: /^Freshclaude$/i })).toBeVisible() }) - test.skip('agent chat permission banners appear', async ({ freshellPage, page }) => { - // This test requires a live SDK session to trigger permission requests. - // In the isolated test environment, no SDK session is available. - // Skipping until a mock SDK bridge is implemented. + test('agent chat permission banners appear and allow sends a response', async ({ freshellPage, page, harness, terminal }) => { + await terminal.waitForTerminal() + const { tabId, paneId } = await getActiveLeaf(harness) + const sessionId = 'sdk-e2e-permission' + const cliSessionId = '33333333-3333-4333-8333-333333333333' + + await page.evaluate((currentPaneId: string) => { + window.__FRESHELL_TEST_HARNESS__?.setAgentChatNetworkEffectsSuppressed(currentPaneId, true) + }, paneId) + + await page.evaluate(({ currentTabId, currentPaneId, currentSessionId, currentCliSessionId }) => { + const harness = window.__FRESHELL_TEST_HARNESS__ + harness?.dispatch({ + type: 'agentChat/sessionCreated', + payload: { + requestId: 'req-e2e-permission', + sessionId: currentSessionId, + }, + }) + harness?.dispatch({ + type: 'agentChat/sessionInit', + payload: { + sessionId: currentSessionId, + cliSessionId: currentCliSessionId, + }, + }) + harness?.dispatch({ + type: 'agentChat/addPermissionRequest', + payload: { + sessionId: currentSessionId, + requestId: 'perm-e2e', + subtype: 'can_use_tool', + tool: { + name: 'Bash', + input: { command: 'echo hello-from-permission-banner' }, + }, + }, + }) + harness?.dispatch({ + type: 'panes/updatePaneContent', + payload: { + tabId: currentTabId, + paneId: currentPaneId, + content: { + kind: 'agent-chat', + provider: 'freshclaude', + createRequestId: 'req-e2e-permission', + sessionId: currentSessionId, + resumeSessionId: currentCliSessionId, + status: 'running', + }, + }, + }) + }, { + currentTabId: tabId, + currentPaneId: paneId, + currentSessionId: sessionId, + currentCliSessionId: cliSessionId, + }) + + const banner = page.getByRole('alert', { name: /permission request for bash/i }) + await expect(banner).toBeVisible() + await expect(banner).toContainText('Permission requested: Bash') + await expect(banner).toContainText('$ echo hello-from-permission-banner') + + await harness.clearSentWsMessages() + await banner.getByRole('button', { name: /allow tool use/i }).click() + + await expect.poll(async () => { + const sent = await harness.getSentWsMessages() + return sent.find((msg: any) => msg?.type === 'sdk.permission.respond') ?? null + }).toMatchObject({ + type: 'sdk.permission.respond', + sessionId, + requestId: 'perm-e2e', + behavior: 'allow', + }) }) test('picker creates shell pane when shell is selected', async ({ freshellPage, page, harness, terminal }) => { diff --git a/test/e2e/update-flow.test.ts b/test/e2e/update-flow.test.ts index 7fe8ae59..c00d6d27 100644 --- a/test/e2e/update-flow.test.ts +++ b/test/e2e/update-flow.test.ts @@ -1,187 +1,127 @@ -// test/e2e/update-flow.test.ts -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { spawn, type ChildProcess } from 'child_process' +// @vitest-environment node +import { describe, it, expect } from 'vitest' +import { spawn } from 'child_process' +import { createRequire } from 'module' +import net from 'net' import path from 'path' - -/** - * E2E Test Skeleton for Update Flow - * - * These tests are placeholders documenting what should be tested when - * proper E2E infrastructure is set up. They are skipped because they require: - * - * - msw or similar for GitHub API mocking - * - Process spawning and stdin/stdout control - * - Mocking child_process for git/npm commands - * - Potentially a test harness for interactive prompts - * - * The update flow works as follows: - * 1. Server starts and checks GitHub API for latest release tag - * 2. Compares remote version to local package.json version - * 3. If update available, prompts user with readline interface - * 4. If user accepts: runs git pull, npm ci, npm run build, then exits - * 5. If user declines: server continues normal startup - * 6. --skip-update-check flag or SKIP_UPDATE_CHECK env skips the check entirely - */ - -describe('update flow e2e', () => { - // Helper to spawn server process - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const spawnServer = (args: string[] = [], env: Record = {}): ChildProcess => { - const serverPath = path.resolve(__dirname, '../../dist/server/index.js') - return spawn('node', [serverPath, ...args], { - env: { ...process.env, ...env }, - stdio: ['pipe', 'pipe', 'pipe'], +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const REPO_ROOT = path.resolve(__dirname, '../..') +const PRECHECK_SCRIPT = path.resolve(REPO_ROOT, 'scripts/precheck.ts') +const require = createRequire(import.meta.url) +const TSX_CLI = require.resolve('tsx/cli') +const PROCESS_TIMEOUT_MS = 30_000 + +type PrecheckResult = { + code: number | null + signal: NodeJS.Signals | null + stdout: string + stderr: string +} + +async function getFreePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer() + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (typeof address !== 'object' || !address) { + server.close(() => reject(new Error('Failed to allocate a free port'))) + return + } + + const { port } = address + server.close((err) => { + if (err) { + reject(err) + return + } + resolve(port) + }) }) - } - - it.skip('shows update prompt when new version available (mocked)', async () => { - // This is a placeholder test demonstrating the flow - // Real e2e would need GitHub API mocking via msw or similar - - // TODO: Implementation steps: - // 1. Set up msw to mock GitHub releases API: - // - Mock GET https://api.github.com/repos/OWNER/REPO/releases/latest - // - Return { tag_name: 'v99.0.0' } to simulate newer version - // - // 2. Start server with test environment: - // - Set AUTH_TOKEN env var - // - Capture stdout/stderr streams - // - // 3. Assert update banner appears in stdout: - // - Look for "Update available" message - // - Look for version comparison (e.g., "v0.1.0 -> v99.0.0") - // - Look for prompt asking to update - // - // 4. Send 'n' to decline via stdin: - // - Write 'n\n' to child process stdin - // - // 5. Assert server continues to start: - // - Look for "Server listening" or similar startup message - // - Verify process is still running - // - Clean up by terminating process - - expect(true).toBe(true) // Placeholder assertion }) +} + +async function runPrecheck( + args: string[] = [], + env: NodeJS.ProcessEnv = {}, +): Promise { + const [serverPort, vitePort] = await Promise.all([getFreePort(), getFreePort()]) + + return await new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [TSX_CLI, PRECHECK_SCRIPT, ...args], + { + cwd: REPO_ROOT, + env: { + ...process.env, + PORT: String(serverPort), + VITE_PORT: String(vitePort), + npm_lifecycle_event: 'preserve', + ...env, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + + let stdout = '' + let stderr = '' + + child.stdout?.on('data', (chunk: Buffer | string) => { + stdout += chunk.toString() + }) + child.stderr?.on('data', (chunk: Buffer | string) => { + stderr += chunk.toString() + }) - it.skip('applies update when user accepts (mocked)', async () => { - // TODO: Implementation steps: - // 1. Mock GitHub API to return newer version: - // - Set up msw handler for releases/latest - // - Return { tag_name: 'v99.0.0' } - // - // 2. Mock git pull, npm ci, npm run build: - // - Could use a wrapper script that records calls - // - Or mock at the module level before spawning - // - Consider using PATH manipulation to inject mock binaries - // - // 3. Start server: - // - Spawn with test environment - // - Capture all output - // - // 4. Send 'y' (or empty/Enter) to accept: - // - Write 'y\n' or '\n' to stdin - // - Default behavior accepts update - // - // 5. Assert update commands were run: - // - Check for "Running git pull" message - // - Check for "Running npm ci" message - // - Check for "Running npm run build" message - // - // 6. Assert process exits with code 0: - // - Wait for process to exit - // - Verify exit code is 0 (success) - // - Verify "Update complete" message appeared - - expect(true).toBe(true) // Placeholder assertion - }) + const timeout = setTimeout(() => { + child.kill('SIGKILL') + reject(new Error(`precheck timed out after ${PROCESS_TIMEOUT_MS}ms`)) + }, PROCESS_TIMEOUT_MS) - it.skip('skips update check with --skip-update-check flag', async () => { - // TODO: Implementation steps: - // 1. Start server with --skip-update-check: - // - const proc = spawnServer(['--skip-update-check']) - // - // 2. Assert no GitHub API call was made: - // - Set up msw handler that records if called - // - Verify handler was never invoked - // - Or check that no network activity occurred - // - // 3. Assert server starts normally: - // - Look for "Server listening" message - // - Verify no "Update available" prompt appeared - // - Clean up by terminating process - - expect(true).toBe(true) // Placeholder assertion - }) + child.once('error', (error) => { + clearTimeout(timeout) + reject(error) + }) - it.skip('skips update check with SKIP_UPDATE_CHECK env var', async () => { - // TODO: Implementation steps: - // 1. Start server with SKIP_UPDATE_CHECK=true: - // - const proc = spawnServer([], { SKIP_UPDATE_CHECK: 'true' }) - // - Also test with SKIP_UPDATE_CHECK: '1' - // - // 2. Assert no GitHub API call was made: - // - Same verification as flag test - // - msw handler should not be invoked - // - // 3. Assert server starts normally: - // - Normal startup messages should appear - // - No update prompt should be shown - // - Server should be listening and healthy - - expect(true).toBe(true) // Placeholder assertion + child.once('close', (code, signal) => { + clearTimeout(timeout) + resolve({ code, signal, stdout, stderr }) + }) }) +} + +describe('update flow precheck', () => { + it('skips update checking when --skip-update-check is provided', async () => { + const result = await runPrecheck(['--skip-update-check']) - it.skip('handles GitHub API timeout gracefully', async () => { - // TODO: Implementation steps: - // 1. Mock GitHub API to delay beyond timeout: - // - Set up msw handler that delays response by 10+ seconds - // - Version checker has 5 second timeout - // - // 2. Start server and wait: - // - Server should not hang indefinitely - // - Should see timeout error in output - // - // 3. Assert server continues to start despite timeout: - // - Update check failure should not block startup - // - Server should proceed with normal operation - // - May log warning about failed update check - - expect(true).toBe(true) // Placeholder assertion + expect(result.signal).toBeNull() + expect(result.code).toBe(0) + expect(result.stdout).not.toContain('new Freshell') + expect(result.stdout).not.toContain('Update complete!') + expect(result.stderr).toBe('') }) - it.skip('handles GitHub API error gracefully', async () => { - // TODO: Implementation steps: - // 1. Mock GitHub API to return 500 error: - // - Set up msw handler returning server error - // - Or return 403 rate limit error - // - // 2. Start server: - // - Capture output for error messages - // - // 3. Assert server continues despite API error: - // - Should not crash or hang - // - Should log the error - // - Should proceed with normal startup - - expect(true).toBe(true) // Placeholder assertion + it('skips update checking when SKIP_UPDATE_CHECK=true', async () => { + const result = await runPrecheck([], { SKIP_UPDATE_CHECK: 'true' }) + + expect(result.signal).toBeNull() + expect(result.code).toBe(0) + expect(result.stdout).not.toContain('new Freshell') + expect(result.stdout).not.toContain('Update complete!') + expect(result.stderr).toBe('') }) - it.skip('handles update command failure gracefully', async () => { - // TODO: Implementation steps: - // 1. Mock GitHub API to return newer version - // - // 2. Mock git pull to fail: - // - Inject failing git binary via PATH - // - Or use a test repository with conflicts - // - // 3. Start server and accept update: - // - Send 'y' to stdin - // - // 4. Assert appropriate error handling: - // - Error message should be displayed - // - Process should exit with non-zero code - // - User should be informed of failure - - expect(true).toBe(true) // Placeholder assertion + it('skips update checking during the predev lifecycle while still succeeding the preflight', async () => { + const result = await runPrecheck([], { npm_lifecycle_event: 'predev' }) + + expect(result.signal).toBeNull() + expect(result.code).toBe(0) + expect(result.stdout).not.toContain('new Freshell') + expect(result.stdout).not.toContain('Update complete!') + expect(result.stderr).toBe('') }) }) diff --git a/test/integration/server/codex-session-flow.test.ts b/test/integration/server/codex-session-flow.test.ts index f56ac2d3..4fedb663 100644 --- a/test/integration/server/codex-session-flow.test.ts +++ b/test/integration/server/codex-session-flow.test.ts @@ -1,27 +1,181 @@ -// test/integration/server/codex-session-flow.test.ts -// -// NOTE: This is a true end-to-end integration test that requires: -// 1. The `codex` CLI to be installed and in PATH -// 2. A valid OpenAI API key configured for Codex CLI -// 3. Network access to OpenAI's API -// -// Set RUN_CODEX_INTEGRATION=true to run this test: -// RUN_CODEX_INTEGRATION=true npm run test:server -// -import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import fsp from 'fs/promises' import http from 'http' +import os from 'os' +import path from 'path' import express from 'express' import WebSocket from 'ws' import { WsHandler } from '../../../server/ws-handler' import { TerminalRegistry } from '../../../server/terminal-registry' import { CodingCliSessionManager } from '../../../server/coding-cli/session-manager' import { codexProvider } from '../../../server/coding-cli/providers/codex' +import { configStore } from '../../../server/config-store' +import { WS_PROTOCOL_VERSION } from '../../../shared/ws-protocol' + +vi.mock('../../../server/config-store', () => ({ + configStore: { + snapshot: vi.fn(), + }, +})) + +vi.mock('../../../server/logger', () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), + } + logger.child.mockReturnValue(logger) + return { logger } +}) process.env.AUTH_TOKEN = 'test-token' -const runCodexIntegration = process.env.RUN_CODEX_INTEGRATION === 'true' +const MESSAGE_TIMEOUT_MS = 5_000 + +async function writeFakeCodexExecutable(binaryPath: string) { + const script = `#!/usr/bin/env node +const fs = require('fs') + +const sessionId = 'fake-codex-session-1' +const argLogPath = process.env.FAKE_CODEX_ARG_LOG +if (argLogPath) { + fs.writeFileSync(argLogPath, JSON.stringify(process.argv.slice(2)), 'utf8') +} + +const events = [ + { + type: 'session_meta', + payload: { + id: sessionId, + cwd: process.cwd(), + model: 'gpt-5-codex', + }, + }, + { + type: 'event_msg', + session_id: sessionId, + payload: { + type: 'agent_message', + message: 'hello world', + }, + }, +] + +let index = 0 +const emitNext = () => { + if (index >= events.length) { + setTimeout(() => process.exit(0), 10) + return + } + process.stdout.write(JSON.stringify(events[index]) + '\\n') + index += 1 + setTimeout(emitNext, 10) +} + +emitNext() +` + + await fsp.writeFile(binaryPath, script, 'utf8') + await fsp.chmod(binaryPath, 0o755) +} + +function waitForMessage( + ws: WebSocket, + predicate: (msg: any) => boolean, + timeoutMs = MESSAGE_TIMEOUT_MS, +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup() + reject(new Error('Timed out waiting for WebSocket message')) + }, timeoutMs) + + const onMessage = (data: WebSocket.Data) => { + const message = JSON.parse(data.toString()) + if (!predicate(message)) return + cleanup() + resolve(message) + } + + const onError = (error: Error) => { + cleanup() + reject(error) + } + + const onClose = () => { + cleanup() + reject(new Error('WebSocket closed before expected message')) + } + + const cleanup = () => { + clearTimeout(timeout) + ws.off('message', onMessage) + ws.off('error', onError) + ws.off('close', onClose) + } + + ws.on('message', onMessage) + ws.on('error', onError) + ws.on('close', onClose) + }) +} + +async function createAuthenticatedWs(port: number): Promise { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + await new Promise((resolve, reject) => { + ws.once('open', () => resolve()) + ws.once('error', reject) + }) + + ws.send(JSON.stringify({ + type: 'hello', + token: process.env.AUTH_TOKEN || 'test-token', + protocolVersion: WS_PROTOCOL_VERSION, + })) + + await waitForMessage(ws, (msg) => msg.type === 'ready') + return ws +} + +async function closeWebSocket(ws: WebSocket): Promise { + await new Promise((resolve) => { + if (ws.readyState === WebSocket.CLOSED) { + resolve() + return + } + + const timeout = setTimeout(() => { + cleanup() + resolve() + }, 1_000) -describe.skipIf(!runCodexIntegration)('Codex Session Flow Integration', () => { + const cleanup = () => { + clearTimeout(timeout) + ws.off('close', onClose) + ws.off('error', onClose) + } + + const onClose = () => { + cleanup() + resolve() + } + + ws.on('close', onClose) + ws.on('error', onClose) + ws.close() + }) +} + +describe('Codex Session Flow Integration', () => { + let tempDir: string + let fakeCodexPath: string + let argLogPath: string + let previousCodexCmd: string | undefined + let previousFakeCodexArgLog: string | undefined let server: http.Server let port: number let wsHandler: WsHandler @@ -29,6 +183,16 @@ describe.skipIf(!runCodexIntegration)('Codex Session Flow Integration', () => { let cliManager: CodingCliSessionManager beforeAll(async () => { + tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-flow-')) + fakeCodexPath = path.join(tempDir, 'fake-codex') + argLogPath = path.join(tempDir, 'args.json') + await writeFakeCodexExecutable(fakeCodexPath) + + previousCodexCmd = process.env.CODEX_CMD + previousFakeCodexArgLog = process.env.FAKE_CODEX_ARG_LOG + process.env.CODEX_CMD = fakeCodexPath + process.env.FAKE_CODEX_ARG_LOG = argLogPath + const app = express() server = http.createServer(app) registry = new TerminalRegistry() @@ -37,73 +201,106 @@ describe.skipIf(!runCodexIntegration)('Codex Session Flow Integration', () => { await new Promise((resolve) => { server.listen(0, '127.0.0.1', () => { - port = (server.address() as any).port + port = (server.address() as { port: number }).port resolve() }) }) }) + beforeEach(async () => { + vi.mocked(configStore.snapshot).mockResolvedValue({ + settings: { + codingCli: { + enabledProviders: ['codex'], + providers: {}, + }, + }, + }) + await fsp.rm(argLogPath, { force: true }) + }) + afterAll(async () => { + if (previousCodexCmd === undefined) { + delete process.env.CODEX_CMD + } else { + process.env.CODEX_CMD = previousCodexCmd + } + if (previousFakeCodexArgLog === undefined) { + delete process.env.FAKE_CODEX_ARG_LOG + } else { + process.env.FAKE_CODEX_ARG_LOG = previousFakeCodexArgLog + } + cliManager.shutdown() registry.shutdown() wsHandler.close() await new Promise((resolve) => server.close(() => resolve())) + await fsp.rm(tempDir, { recursive: true, force: true }) }) - function createAuthenticatedWs(): Promise { - return new Promise((resolve, reject) => { - const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) - ws.on('open', () => { - ws.send(JSON.stringify({ type: 'hello', token: process.env.AUTH_TOKEN || 'test-token' })) - }) - ws.on('message', (data) => { - const msg = JSON.parse(data.toString()) - if (msg.type === 'ready') resolve(ws) - }) - ws.on('error', reject) - setTimeout(() => reject(new Error('Timeout')), 5000) - }) - } - - it('creates session and streams events', async () => { - const ws = await createAuthenticatedWs() - const events: any[] = [] - let sessionId: string | null = null - - const done = new Promise((resolve) => { - ws.on('message', (data) => { - const msg = JSON.parse(data.toString()) - - if (msg.type === 'codingcli.created') { - sessionId = msg.sessionId - } + it('creates a codex session and streams parsed provider events from a local codex executable', async () => { + const ws = await createAuthenticatedWs(port) + const observedMessages: any[] = [] + const onMessage = (data: WebSocket.Data) => { + observedMessages.push(JSON.parse(data.toString())) + } + ws.on('message', onMessage) - if (msg.type === 'codingcli.event') { - events.push(msg.event) - } + try { + ws.send(JSON.stringify({ + type: 'codingcli.create', + requestId: 'test-req-codex', + provider: 'codex', + prompt: 'say "hello world" and nothing else', + })) - if (msg.type === 'codingcli.exit') { - resolve() - } - }) - }) - - ws.send(JSON.stringify({ - type: 'codingcli.create', - requestId: 'test-req-codex', - provider: 'codex', - prompt: 'say "hello world" and nothing else', - })) + const created = await waitForMessage( + ws, + (msg) => msg.type === 'codingcli.created' && msg.requestId === 'test-req-codex', + ) + const exited = await waitForMessage( + ws, + (msg) => msg.type === 'codingcli.exit' && msg.sessionId === created.sessionId, + ) - await done + const eventMessages = observedMessages + .filter((msg) => msg.type === 'codingcli.event' && msg.sessionId === created.sessionId) + .map((msg) => msg.event) - expect(sessionId).toBeDefined() - expect(events.length).toBeGreaterThan(0) + expect(created.provider).toBe('codex') + expect(exited.exitCode).toBe(0) + expect(eventMessages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'session.start', + sessionId: 'fake-codex-session-1', + provider: 'codex', + session: expect.objectContaining({ + cwd: process.cwd(), + model: 'gpt-5-codex', + }), + }), + expect.objectContaining({ + type: 'message.assistant', + sessionId: 'fake-codex-session-1', + provider: 'codex', + message: { + role: 'assistant', + content: 'hello world', + }, + }), + ]), + ) - const hasInit = events.some((e) => e.type === 'session.init') - const hasMessage = events.some((e) => e.type === 'message.assistant') - expect(hasInit || hasMessage).toBe(true) - - ws.close() - }, 30000) + const recordedArgs = JSON.parse(await fsp.readFile(argLogPath, 'utf8')) + expect(recordedArgs).toEqual([ + 'exec', + '--json', + 'say "hello world" and nothing else', + ]) + } finally { + ws.off('message', onMessage) + await closeWebSocket(ws) + } + }) }) diff --git a/test/setup/dom.ts b/test/setup/dom.ts index 7549869b..9afbf653 100644 --- a/test/setup/dom.ts +++ b/test/setup/dom.ts @@ -142,7 +142,9 @@ const clipboardMock = { readText: vi.fn().mockResolvedValue(''), } -Object.defineProperty(globalThis.navigator, 'clipboard', { - value: clipboardMock, - configurable: true, -}) +if (typeof globalThis.navigator !== 'undefined') { + Object.defineProperty(globalThis.navigator, 'clipboard', { + value: clipboardMock, + configurable: true, + }) +}