diff --git a/apps/kimi-web/AGENTS.md b/apps/kimi-web/AGENTS.md index cc1396d992..8a6411ec49 100644 --- a/apps/kimi-web/AGENTS.md +++ b/apps/kimi-web/AGENTS.md @@ -39,7 +39,7 @@ All via `pnpm --filter @moonshot-ai/kimi-web …`: - `dev:stub` — offline stub daemon (`dev/stub-daemon.mjs`). - `build` — production build into `dist/`. - `typecheck` — `vue-tsc --noEmit`. -- `test` — `vitest run` (jsdom; setup in `test/setup.ts`). +- `test` — `vitest run` (pure logic tests only; no jsdom / component tests). - There is **no `lint` script** in this package; linting runs at the repo root via oxlint. ## Gotchas / hard rules diff --git a/apps/kimi-web/README.md b/apps/kimi-web/README.md index 3db909e524..bc4aca3f3b 100644 --- a/apps/kimi-web/README.md +++ b/apps/kimi-web/README.md @@ -17,7 +17,7 @@ pnpm -C apps/kimi-web run dev:stub # then run dev in another shell # checks pnpm -C apps/kimi-web run typecheck # vue-tsc --noEmit -pnpm -C apps/kimi-web run test # vitest +pnpm -C apps/kimi-web run test # vitest (pure logic only) pnpm -C apps/kimi-web run build # vite build ``` @@ -62,8 +62,6 @@ server (REST + WS) protocol (`event.*`) frames; the projector converts them to `AppEvent`s. - **i18n** (`src/i18n/`): vue-i18n, en/zh, per-namespace flat camelCase keys. Detect order: `localStorage('kimi-locale')` → `navigator.language` → `en`. -- **Tests**: Vitest + @vue/test-utils + jsdom, colocated under `__tests__/`. - --- ## Server contract — non-obvious notes diff --git a/apps/kimi-web/package.json b/apps/kimi-web/package.json index c74ea61eee..dbb9e36604 100644 --- a/apps/kimi-web/package.json +++ b/apps/kimi-web/package.json @@ -25,8 +25,6 @@ "devDependencies": { "@tailwindcss/vite": "^4.1.4", "@vitejs/plugin-vue": "^5.2.4", - "@vue/test-utils": "^2.4.6", - "jsdom": "^25.0.1", "tailwindcss": "^4.1.4", "typescript": "6.0.2", "vite": "^6.3.3", diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index de1c63040a..bb951ad8b8 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -40,7 +40,7 @@ const { t } = useI18n(); const debugEnabled = isTraceEnabled(); // Narrow viewports (≤640px) render the single-column mobile shell; desktop is -// unchanged. jsdom defaults to false (desktop) so component tests are unaffected. +// unchanged. Falls back to desktop when matchMedia is unavailable. const isMobile = useIsMobile(); // Mobile sheet visibility diff --git a/apps/kimi-web/src/composables/useIsMobile.ts b/apps/kimi-web/src/composables/useIsMobile.ts index 70f87b1cbb..fc514537a2 100644 --- a/apps/kimi-web/src/composables/useIsMobile.ts +++ b/apps/kimi-web/src/composables/useIsMobile.ts @@ -1,9 +1,8 @@ // apps/kimi-web/src/composables/useIsMobile.ts // Reactive "is the viewport narrow (phone-sized)?" flag. // -// Drives the App.vue desktop/mobile branch. SSR/jsdom-safe: when -// window.matchMedia is unavailable (e.g. the test environment), it defaults to -// FALSE (desktop) so existing component tests keep mounting the desktop layout. +// Drives the App.vue desktop/mobile branch. When window.matchMedia is +// unavailable, it defaults to FALSE (desktop). import { onUnmounted, ref, type Ref } from 'vue'; @@ -18,7 +17,7 @@ const MOBILE_QUERY = `(max-width: ${MOBILE_MAX_WIDTH}px)`; export function useIsMobile(): Ref { const isMobile = ref(false); - // jsdom/SSR guard: no matchMedia → stay desktop (false). + // SSR / no-matchMedia guard: stay desktop (false). if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { return isMobile; } diff --git a/apps/kimi-web/test/agent-group-turns.test.ts b/apps/kimi-web/test/agent-group-turns.test.ts deleted file mode 100644 index b42f26a53a..0000000000 --- a/apps/kimi-web/test/agent-group-turns.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { messagesToTurns } from '../src/composables/messagesToTurns'; -import { buildSwarmGroups } from '../src/composables/swarmGroups'; -import type { AppMessage, AppTask } from '../src/api/types'; - -const now = '2026-06-13T00:00:00.000Z'; - -describe('messagesToTurns agent blocks', () => { - it('renders one subagent task as an agent block', () => { - const messages: AppMessage[] = [ - { - id: 'msg_1', - sessionId: 'ses_1', - role: 'assistant', - promptId: 'pr_1', - createdAt: now, - content: [ - { type: 'text', text: 'starting review' }, - { type: 'toolUse', toolCallId: 'tc_agent', toolName: 'agent', input: { description: 'review' } }, - ], - }, - ]; - const tasks: AppTask[] = [ - { - id: 'agent_1', - sessionId: 'ses_1', - kind: 'subagent', - description: 'Review code', - status: 'running', - createdAt: now, - subagentPhase: 'working', - subagentType: 'coder', - parentToolCallId: 'tc_agent', - outputLines: ['Reading files', 'Running tests'], - }, - ]; - - const turns = messagesToTurns(messages, [], undefined, true, tasks); - expect(turns[0]?.blocks?.[1]).toEqual({ - kind: 'agent', - member: expect.objectContaining({ - id: 'agent_1', - name: 'Review code', - phase: 'working', - subagentType: 'coder', - outputLines: ['Reading files', 'Running tests'], - }), - }); - expect(turns[0]?.tools).toBeUndefined(); - }); - - it('does NOT render a swarm (subagents with a swarmIndex) inline — it is a SwarmCard', () => { - const messages: AppMessage[] = [ - { - id: 'msg_1', - sessionId: 'ses_1', - role: 'assistant', - promptId: 'pr_1', - createdAt: now, - content: [ - { type: 'toolUse', toolCallId: 'tc_swarm', toolName: 'agent_swarm', input: { description: 'review', count: 2 } }, - ], - }, - ]; - const tasks: AppTask[] = [ - { - id: 'agent_b', sessionId: 'ses_1', kind: 'subagent', description: 'Second', - status: 'running', createdAt: now, subagentPhase: 'queued', parentToolCallId: 'tc_swarm', swarmIndex: 2, - }, - { - id: 'agent_a', sessionId: 'ses_1', kind: 'subagent', description: 'First', - status: 'completed', createdAt: now, subagentPhase: 'completed', parentToolCallId: 'tc_swarm', swarmIndex: 1, - }, - ]; - - // The swarm is rendered as its own SwarmCard (buildSwarmGroups), so it must - // NOT also appear inline in the transcript — that was the "two blocks" bug. - const turns = messagesToTurns(messages, [], undefined, false, tasks); - const hasInlineAgent = (turns[0]?.blocks ?? []).some( - (b) => b.kind === 'agent' || b.kind === 'agentGroup', - ); - expect(hasInlineAgent).toBe(false); - // ...but it IS surfaced once, as a swarm group. - expect(buildSwarmGroups(tasks)).toHaveLength(1); - }); - - it('rebuilds a subagent AgentCard from the transcript when no live task exists (refresh)', () => { - // After a refresh, a foreground subagent has no background-task record, only - // the persisted Agent tool call + result. It must still render as an - // AgentCard (not degrade to a plain tool card), carrying the prompt + result. - const messages: AppMessage[] = [ - { - id: 'msg_1', - sessionId: 'ses_1', - role: 'assistant', - promptId: 'pr_1', - createdAt: now, - content: [ - { - type: 'toolUse', - toolCallId: 'tc_agent', - toolName: 'Agent', - input: { description: 'Audit auth', subagent_type: 'security', prompt: 'Look for auth bugs' }, - }, - ], - }, - { - id: 'msg_2', - sessionId: 'ses_1', - role: 'tool', - createdAt: now, - content: [ - { type: 'toolResult', toolCallId: 'tc_agent', output: 'Found 2 issues', isError: false }, - ], - }, - ]; - - // No tasks passed (the refresh case). - const turns = messagesToTurns(messages, [], undefined, false, []); - const block = turns[0]?.blocks?.[0]; - expect(block?.kind).toBe('agent'); - if (block?.kind !== 'agent') return; - expect(block.member).toEqual( - expect.objectContaining({ - name: 'Audit auth', - subagentType: 'security', - prompt: 'Look for auth bugs', - phase: 'completed', - summary: 'Found 2 issues', - }), - ); - // It must NOT also appear as a plain tool call. - expect(turns[0]?.tools).toBeUndefined(); - }); - - it('renders multiple NON-swarm subagents (no swarmIndex) as an inline agentGroup', () => { - const messages: AppMessage[] = [ - { - id: 'msg_1', - sessionId: 'ses_1', - role: 'assistant', - promptId: 'pr_1', - createdAt: now, - content: [ - { type: 'toolUse', toolCallId: 'tc_agent', toolName: 'agent', input: { description: 'review' } }, - ], - }, - ]; - const tasks: AppTask[] = [ - { - id: 'agent_a', sessionId: 'ses_1', kind: 'subagent', description: 'First', - status: 'completed', createdAt: '2026-06-13T00:00:00.000Z', subagentPhase: 'completed', parentToolCallId: 'tc_agent', - }, - { - id: 'agent_b', sessionId: 'ses_1', kind: 'subagent', description: 'Second', - status: 'running', createdAt: '2026-06-13T00:00:01.000Z', subagentPhase: 'queued', parentToolCallId: 'tc_agent', - }, - ]; - - const turns = messagesToTurns(messages, [], undefined, false, tasks); - const block = turns[0]?.blocks?.[0]; - expect(block?.kind).toBe('agentGroup'); - if (block?.kind !== 'agentGroup') return; - expect(block.members.map((member) => member.id)).toEqual(['agent_a', 'agent_b']); - // Not a swarm → no SwarmCard. - expect(buildSwarmGroups(tasks)).toHaveLength(0); - }); -}); diff --git a/apps/kimi-web/test/archive-last-session.test.ts b/apps/kimi-web/test/archive-last-session.test.ts deleted file mode 100644 index 582a01db88..0000000000 --- a/apps/kimi-web/test/archive-last-session.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -// apps/kimi-web/test/archive-last-session.test.ts -// -// Reproduces / verifies the bug where archiving the only session in a workspace -// does not behave correctly. - -import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { AppSession, AppWorkspace, KimiEventHandlers, KimiWebApi } from '../src/api/types'; - -const now = '2026-06-11T00:00:00.000Z'; - -function session(id: string, overrides?: Partial): AppSession { - return { - id, - title: id, - createdAt: now, - updatedAt: now, - status: 'idle', - archived: false, - cwd: '/repo', - model: 'kimi-test', - usage: { - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheCreationTokens: 0, - totalCostUsd: 0, - contextTokens: 0, - contextLimit: 128_000, - turnCount: 0, - }, - messageCount: 0, - lastSeq: 0, - ...overrides, - }; -} - -async function setup(opts: { - sessions?: AppSession[]; - workspaces?: AppWorkspace[]; -}) { - vi.resetModules(); - vi.stubGlobal('WebSocket', class WebSocket {}); - window.history.replaceState(null, '', '/'); - - let handlers: KimiEventHandlers | undefined; - const eventConn = { - subscribe: vi.fn(), - unsubscribe: vi.fn(), - bindNextPromptId: vi.fn(), - seedSnapshot: vi.fn(), - abort: vi.fn(), - close: vi.fn(), - }; - const listed = opts.sessions ?? []; - const workspaces = opts.workspaces ?? []; - const api = { - getHealth: vi.fn(async () => ({ status: 'ok', uptimeSec: 1 })), - getMeta: vi.fn(async () => ({ daemonVersion: 't', serverId: 's', startedAt: now, capabilities: {} })), - getAuth: vi.fn(async () => ({ ready: true, defaultModel: 'kimi-test', managedProvider: null })), - listModels: vi.fn(async () => []), - listWorkspaces: vi.fn(async () => workspaces), - getFsHome: vi.fn(async () => ({ home: '/home', recentRoots: [] })), - listSessions: vi.fn(async () => ({ items: listed, hasMore: false })), - getSession: vi.fn(async (id: string) => { - const found = listed.find((s) => s.id === id); - if (!found) throw new Error('SESSION_NOT_FOUND'); - return found; - }), - archiveSession: vi.fn(async () => ({ archived: true })), - getSessionSnapshot: vi.fn(async (id: string) => { - const found = listed.find((s) => s.id === id) ?? session(id); - return { - asOfSeq: 0, - epoch: 'ep_test', - session: found, - messages: [], - hasMoreMessages: false, - inFlightTurn: null, - pendingApprovals: [], - pendingQuestions: [], - }; - }), - listTasks: vi.fn(async () => []), - getGitStatus: vi.fn(async () => ({ branch: 'main', ahead: 0, behind: 0, entries: {}, additions: 0, deletions: 0 })), - getSessionStatus: vi.fn(async () => ({ - model: 'kimi-test', - thinkingLevel: 'high', - permission: 'manual', - planMode: false, - swarmMode: false, - contextTokens: 0, - maxContextTokens: 128_000, - contextUsage: 0, - })), - connectEvents: vi.fn((nextHandlers: KimiEventHandlers) => { - handlers = nextHandlers; - return eventConn; - }), - getFileUrl: vi.fn((fileId: string) => `/files/${fileId}`), - } as unknown as KimiWebApi; - - vi.doMock('../src/api', () => ({ getKimiWebApi: () => api })); - const { useKimiWebClient } = await import('../src/composables/useKimiWebClient'); - - return { - api, - client: useKimiWebClient(), - getHandlers: () => { - if (!handlers) throw new Error('connectEvents was not called'); - return handlers; - }, - }; -} - -afterEach(() => { - vi.unstubAllGlobals(); - vi.resetModules(); - vi.clearAllMocks(); - localStorage.clear(); - window.history.replaceState(null, '', '/'); -}); - -describe('archive last session in workspace', () => { - it('removes the only session and clears active session', async () => { - const { client } = await setup({ - sessions: [session('sess_1', { cwd: '/repo' })], - workspaces: [{ id: 'ws_repo', root: '/repo', name: 'repo', sessionCount: 1 }], - }); - await client.load(); - - expect(client.activeSessionId.value).toBe('sess_1'); - expect(client.sessions.value.map((s) => s.id)).toEqual(['sess_1']); - expect(client.workspacesView.value.map((w) => ({ id: w.id, sessionCount: w.sessionCount }))).toEqual([ - { id: 'ws_repo', sessionCount: 1 }, - ]); - - await client.archiveSession('sess_1'); - - expect(client.sessions.value).toEqual([]); - expect(client.activeSessionId.value).toBe(''); - expect(window.location.pathname).toBe('/'); - expect(client.workspacesView.value.map((w) => ({ id: w.id, sessionCount: w.sessionCount }))).toEqual([ - { id: 'ws_repo', sessionCount: 0 }, - ]); - }); - - it('removes the only session in one workspace when another workspace exists', async () => { - const { client } = await setup({ - sessions: [ - session('sess_a', { cwd: '/repo-a' }), - session('sess_b', { cwd: '/repo-b' }), - ], - workspaces: [ - { id: 'ws_a', root: '/repo-a', name: 'repo-a', sessionCount: 1 }, - { id: 'ws_b', root: '/repo-b', name: 'repo-b', sessionCount: 1 }, - ], - }); - await client.load(); - - expect(client.activeSessionId.value).toBe('sess_a'); - - await client.archiveSession('sess_a'); - - expect(client.sessions.value.map((s) => s.id)).toEqual(['sess_b']); - expect(client.activeSessionId.value).toBe('sess_b'); - }); -}); diff --git a/apps/kimi-web/test/chat-header.test.ts b/apps/kimi-web/test/chat-header.test.ts deleted file mode 100644 index 65ec20d7de..0000000000 --- a/apps/kimi-web/test/chat-header.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { createI18n } from 'vue-i18n'; -import { afterEach, describe, expect, it } from 'vitest'; - -import ChatHeader from '../src/components/ChatHeader.vue'; -import enHeader from '../src/i18n/locales/en/header'; - -const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { en: { header: enHeader } }, - missingWarn: false, - fallbackWarn: false, -}); - -describe('ChatHeader', () => { - afterEach(() => { - document.body.innerHTML = ''; - }); - - it('emits openChanges when the git status area is clicked', async () => { - const wrapper = mount(ChatHeader, { - props: { - isGitRepo: true, - branch: 'main', - ahead: 0, - behind: 0, - changesCount: 3, - gitDiffStats: { totalAdditions: 10, totalDeletions: 2 }, - }, - global: { plugins: [i18n] }, - }); - - await wrapper.find('.ch-git').trigger('click'); - - expect(wrapper.emitted('openChanges')).toHaveLength(1); - }); - - it('does not render the git button for a non-git workspace', () => { - const wrapper = mount(ChatHeader, { - props: { isGitRepo: false }, - global: { plugins: [i18n] }, - }); - - expect(wrapper.find('.ch-git').exists()).toBe(false); - }); - - it('renders the full branch name and exposes it via title', () => { - const branch = 'feat/web-session-lazy-loading/very-long-branch-name-for-header-display'; - - const wrapper = mount(ChatHeader, { - props: { - isGitRepo: true, - branch, - }, - global: { plugins: [i18n] }, - }); - - const branchEl = wrapper.find('.ch-branch'); - - expect(branchEl.text()).toBe(branch); - expect(branchEl.attributes('title')).toBe(branch); - }); - - it('renders the detached label with title when branch is empty', () => { - const wrapper = mount(ChatHeader, { - props: { isGitRepo: true }, - global: { plugins: [i18n] }, - }); - - const branchEl = wrapper.find('.ch-branch'); - - expect(branchEl.text()).toBe('detached'); - expect(branchEl.attributes('title')).toBe('detached'); - }); -}); diff --git a/apps/kimi-web/test/chatpane-copy.test.ts b/apps/kimi-web/test/chatpane-copy.test.ts deleted file mode 100644 index 3b3b634436..0000000000 --- a/apps/kimi-web/test/chatpane-copy.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { mount, flushPromises } from '@vue/test-utils'; -import { createI18n } from 'vue-i18n'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import ChatPane from '../src/components/ChatPane.vue'; -import type { ChatTurn } from '../src/types'; - -const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { - en: { - conversation: { - cancel: 'Cancel', - compactedPlain: 'Context compacted', - compactedAuto: 'Context auto-compacted', - compactedTokens: ' ({before} -> {after})', - confirm: 'Confirm', - loading: 'Loading', - undo: 'Undo', - undoConfirm: 'Undo last message?', - viewSummary: 'View summary', - yesterday: 'Yesterday', - }, - filePreview: { copy: 'Copy' }, - }, - }, - missingWarn: false, - fallbackWarn: false, -}); - -function mountPane(turns: ChatTurn[]) { - return mount(ChatPane, { - props: { turns }, - global: { - plugins: [i18n], - stubs: { - Markdown: { props: ['text'], template: '
{{ text }}
' }, - ThinkingBlock: true, - ToolCall: true, - ActivityNotice: true, - AgentCard: true, - AgentGroup: true, - }, - }, - }); -} - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe('ChatPane copy', () => { - it('copies only assistant final text from the per-message copy button', async () => { - const writeText = vi.fn().mockResolvedValue(undefined); - Object.defineProperty(navigator, 'clipboard', { - value: { writeText }, - configurable: true, - }); - const turns: ChatTurn[] = [ - { - id: 'a1', - role: 'assistant', - no: 1, - text: 'Final answer', - blocks: [ - { kind: 'thinking', thinking: 'private reasoning' }, - { - kind: 'tool', - tool: { - id: 'tool_1', - name: 'bash', - arg: 'pnpm test', - status: 'ok', - output: ['tool output'], - }, - }, - { kind: 'text', text: 'Final answer' }, - ], - }, - ]; - const wrapper = mountPane(turns); - - await wrapper.find('.cpbtn').trigger('click'); - await flushPromises(); - - expect(writeText).toHaveBeenCalledWith('Final answer'); - }); -}); diff --git a/apps/kimi-web/test/chatpane-lazy-load.test.ts b/apps/kimi-web/test/chatpane-lazy-load.test.ts deleted file mode 100644 index 5b742e85d1..0000000000 --- a/apps/kimi-web/test/chatpane-lazy-load.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { createI18n } from 'vue-i18n'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { nextTick } from 'vue'; - -import ChatPane from '../src/components/ChatPane.vue'; -import type { ChatTurn } from '../src/types'; - -const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { en: {} }, - missingWarn: false, - fallbackWarn: false, -}); - -const turns: ChatTurn[] = [{ id: 'a1', role: 'assistant', no: 1, text: 'hello' }]; - -let intersectionCallback: IntersectionObserverCallback | null = null; -let realIntersectionObserver: typeof globalThis.IntersectionObserver | undefined; - -class MockIntersectionObserver { - constructor(cb: IntersectionObserverCallback) { - intersectionCallback = cb; - } - observe(): void { - intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver); - } - unobserve(): void {} - disconnect(): void {} -} - -function mountChatPane(extraProps: Record) { - return mount(ChatPane, { - props: { - turns, - hasMoreMessages: true, - isFollowing: false, - ...extraProps, - }, - global: { - plugins: [i18n], - stubs: { - Markdown: true, - ThinkingBlock: true, - ToolCall: true, - ActivityNotice: true, - AgentCard: true, - AgentGroup: true, - MoonSpinner: true, - }, - }, - }); -} - -beforeEach(() => { - intersectionCallback = null; - realIntersectionObserver = globalThis.IntersectionObserver; - (globalThis as unknown as { IntersectionObserver: unknown }).IntersectionObserver = MockIntersectionObserver; -}); - -afterEach(() => { - if (realIntersectionObserver) { - (globalThis as unknown as { IntersectionObserver: unknown }).IntersectionObserver = realIntersectionObserver; - } else { - delete (globalThis as unknown as { IntersectionObserver?: unknown }).IntersectionObserver; - } -}); - -describe('ChatPane lazy-load sentinel', () => { - it('does not auto-retry while the previous older-message load failed', async () => { - const wrapper = mountChatPane({ loadingMoreError: true }); - await nextTick(); - - expect(wrapper.emitted('loadOlderMessages')).toBeUndefined(); - - await wrapper.setProps({ loadingMoreError: false }); - await nextTick(); - - expect(wrapper.emitted('loadOlderMessages')).toHaveLength(1); - }); -}); diff --git a/apps/kimi-web/test/chatpane-undo-animation.test.ts b/apps/kimi-web/test/chatpane-undo-animation.test.ts deleted file mode 100644 index 51c36f0340..0000000000 --- a/apps/kimi-web/test/chatpane-undo-animation.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { createI18n } from 'vue-i18n'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { nextTick } from 'vue'; - -import ChatPane from '../src/components/ChatPane.vue'; -import type { ChatTurn } from '../src/types'; - -const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { - en: { - conversation: { - undo: 'Undo', - undoConfirm: 'Undo last message?', - confirm: 'Confirm', - cancel: 'Cancel', - loading: 'Loading', - }, - filePreview: { copy: 'Copy' }, - }, - }, - missingWarn: false, - fallbackWarn: false, -}); - -const turns: ChatTurn[] = [{ id: 'u1', role: 'user', no: 1, text: 'hello' }]; - -afterEach(() => { - vi.useRealTimers(); -}); - -describe('ChatPane undo animation', () => { - it('waits for the exit animation before emitting editMessage', async () => { - vi.useFakeTimers(); - const wrapper = mount(ChatPane, { - props: { turns, mobile: true }, - global: { - plugins: [i18n], - stubs: { - Markdown: true, - ThinkingBlock: true, - ToolCall: true, - ActivityNotice: true, - AgentCard: true, - AgentGroup: true, - }, - }, - }); - - await wrapper.find('.u-edit').trigger('click'); - await wrapper.find('.u-edit-confirm-btn.confirm').trigger('click'); - - expect(wrapper.emitted('editMessage')).toBeUndefined(); - expect(wrapper.find('.u-bub').classes()).toContain('undoing'); - - vi.advanceTimersByTime(240); - await nextTick(); - - expect(wrapper.emitted('editMessage')?.[0]).toEqual(['hello']); - }); -}); diff --git a/apps/kimi-web/test/compaction.test.ts b/apps/kimi-web/test/compaction.test.ts deleted file mode 100644 index 4702a8eff1..0000000000 --- a/apps/kimi-web/test/compaction.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -// apps/kimi-web/test/compaction.test.ts -// -// Compaction events stream through the REAL pipeline — projector → reducer — -// and surface as per-session compaction status ("compacting…" notice while -// running) plus a persistent divider marker message on completion. The -// scrollback is never reloaded/replaced. - -import { describe, expect, it } from 'vitest'; -import { createAgentProjector } from '../src/api/daemon/agentEventProjector'; -import { createInitialState, reduceAppEvent, type KimiClientState } from '../src/api/daemon/eventReducer'; -import { COMPACTION_MARKER_METADATA_KEY, type AppEvent } from '../src/api/types'; - -const SESSION = 'sess_1'; - -function play(events: [string, unknown][]): { state: KimiClientState; appEvents: AppEvent[] } { - const projector = createAgentProjector(); - let state = createInitialState(); - // The session transcript is loaded (the marker is only appended then). - state = { ...state, messagesBySession: { [SESSION]: [] } }; - const appEvents: AppEvent[] = []; - let seq = 0; - for (const [type, payload] of events) { - for (const appEvent of projector.project(type, payload, SESSION)) { - appEvents.push(appEvent); - state = reduceAppEvent(state, appEvent, { sessionId: SESSION, seq: ++seq }); - } - } - return { state, appEvents }; -} - -describe('compaction pipeline', () => { - it('compaction.started marks the session as compacting', () => { - const { state } = play([ - ['compaction.started', { trigger: 'manual', instruction: 'keep recent work' }], - ]); - expect(state.compactionBySession[SESSION]).toEqual({ - status: 'running', - trigger: 'manual', - }); - }); - - it('compaction.completed clears the running status and appends a divider marker', () => { - const { state, appEvents } = play([ - ['compaction.started', { trigger: 'auto' }], - ['compaction.completed', { result: { summary: 's', compactedCount: 12, tokensBefore: 90000, tokensAfter: 12000 } }], - ]); - - // Running status is gone — completion is the marker, not transient status. - expect(state.compactionBySession[SESSION]).toBeUndefined(); - - const msgs = state.messagesBySession[SESSION] ?? []; - const marker = msgs[msgs.length - 1]; - expect(marker?.metadata?.['origin']).toEqual({ kind: 'compaction_summary' }); - expect(marker?.metadata?.[COMPACTION_MARKER_METADATA_KEY]).toEqual({ - trigger: 'auto', - tokensBefore: 90000, - tokensAfter: 12000, - }); - expect(marker?.content).toEqual([{ type: 'text', text: 's' }]); - - // The historyCompacted signal still fires (seq bookkeeping); the client - // wrapper must NOT route compaction reasons to a snapshot reload. - expect(appEvents.some((e) => e.type === 'historyCompacted')).toBe(true); - }); - - it('compaction.cancelled clears the compacting state', () => { - const { state } = play([ - ['compaction.started', { trigger: 'manual' }], - ['compaction.cancelled', {}], - ]); - expect(state.compactionBySession[SESSION]).toBeUndefined(); - }); - - it('a completed event without a prior started still appends a marker', () => { - const { state } = play([ - ['compaction.completed', { result: { summary: 's', compactedCount: 3, tokensBefore: 50000, tokensAfter: 8000 } }], - ]); - expect(state.compactionBySession[SESSION]).toBeUndefined(); - const msgs = state.messagesBySession[SESSION] ?? []; - expect(msgs[msgs.length - 1]?.metadata?.[COMPACTION_MARKER_METADATA_KEY]).toMatchObject({ - trigger: 'auto', - tokensBefore: 50000, - tokensAfter: 8000, - }); - }); -}); diff --git a/apps/kimi-web/test/composer.test.ts b/apps/kimi-web/test/composer.test.ts deleted file mode 100644 index 1daae458fa..0000000000 --- a/apps/kimi-web/test/composer.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { flushPromises, mount } from '@vue/test-utils'; -import { createI18n } from 'vue-i18n'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import Composer from '../src/components/Composer.vue'; -import type { AppModel } from '../src/api/types'; - -function mountComposer(props: Record = {}) { - const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { - en: { - composer: { - editQueued: 'Edit queued', - interrupt: 'Interrupt', - interruptTitle: 'Interrupt', - placeholder: 'Message Kimi', - queueLabel: 'Queue', - previewAttachment: 'Preview {name}', - remove: 'Remove', - removeNamed: 'Remove {name}', - send: 'Send', - steerNow: 'Steer now', - steerTitle: 'Steer now', - }, - commands: { - goal: { desc: 'Start a goal' }, - swarm: { desc: 'Run with swarm' }, - btw: { desc: 'Ask side chat' }, - compact: { desc: 'Compact context' }, - }, - status: { - modelTooltip: 'Switch model', - starredModels: 'Starred', - moreModels: 'More models…', - thinkingLabel: 'thinking', - }, - }, - }, - missingWarn: false, - fallbackWarn: false, - }); - - return mount(Composer, { - props, - global: { - plugins: [i18n], - }, - }); -} - -function waitForCompositionEndTimer(): Promise { - return new Promise((resolve) => setTimeout(resolve, 0)); -} - -afterEach(() => { - document.body.innerHTML = ''; - try { localStorage.clear(); } catch { /* ignore */ } - vi.restoreAllMocks(); -}); - -describe('Composer IME input', () => { - it('does not submit when Enter confirms active composition', async () => { - const wrapper = mountComposer(); - const textarea = wrapper.get('textarea'); - - await textarea.setValue('ni'); - await textarea.trigger('compositionstart'); - await textarea.trigger('keydown', { key: 'Enter', isComposing: true }); - - expect(wrapper.emitted('submit')).toBeUndefined(); - }); - - it('does not submit the Enter that immediately follows compositionend', async () => { - const wrapper = mountComposer(); - const textarea = wrapper.get('textarea'); - - await textarea.setValue('你好'); - await textarea.trigger('compositionstart'); - await textarea.trigger('compositionend'); - await textarea.trigger('keydown', { key: 'Enter', isComposing: false }); - - expect(wrapper.emitted('submit')).toBeUndefined(); - - await waitForCompositionEndTimer(); - await textarea.trigger('keydown', { key: 'Enter', isComposing: false }); - - expect(wrapper.emitted('submit')).toEqual([[{ text: '你好', attachments: [] }]]); - }); -}); - -describe('Composer history recall', () => { - it('walks sent messages with ArrowUp/ArrowDown and restores the draft', async () => { - const wrapper = mountComposer(); - const textarea = wrapper.get('textarea'); - const el = textarea.element as HTMLTextAreaElement; - - await textarea.setValue('first'); - await textarea.trigger('keydown', { key: 'Enter' }); - await textarea.setValue('second'); - await textarea.trigger('keydown', { key: 'Enter' }); - expect(wrapper.emitted('submit')).toHaveLength(2); - expect(el.value).toBe(''); - - // ArrowUp recalls the most recent, then the older one. - await textarea.trigger('keydown', { key: 'ArrowUp' }); - expect(el.value).toBe('second'); - await textarea.trigger('keydown', { key: 'ArrowUp' }); - expect(el.value).toBe('first'); - - // ArrowDown walks forward, then restores the (empty) live draft. - await textarea.trigger('keydown', { key: 'ArrowDown' }); - expect(el.value).toBe('second'); - await textarea.trigger('keydown', { key: 'ArrowDown' }); - expect(el.value).toBe(''); - }); - - it('keeps walking past a multi-line entry (caret lands off the first line)', async () => { - const wrapper = mountComposer(); - const textarea = wrapper.get('textarea'); - const el = textarea.element as HTMLTextAreaElement; - - // Three sends; the middle one is multi-line. After recalling it the caret - // sits on its LAST line, so the old "ArrowUp only on the first line" gate - // trapped it there and you could never reach the oldest entry. - await textarea.setValue('oldest'); - await textarea.trigger('keydown', { key: 'Enter' }); - await textarea.setValue('multi\nline'); - await textarea.trigger('keydown', { key: 'Enter' }); - await textarea.setValue('newest'); - await textarea.trigger('keydown', { key: 'Enter' }); - - await textarea.trigger('keydown', { key: 'ArrowUp' }); - expect(el.value).toBe('newest'); - await textarea.trigger('keydown', { key: 'ArrowUp' }); - expect(el.value).toBe('multi\nline'); - // The fix: still recalls the oldest even though the caret is on the last - // line of the multi-line entry. - await textarea.trigger('keydown', { key: 'ArrowUp' }); - expect(el.value).toBe('oldest'); - }); -}); - -describe('Composer draft persistence', () => { - it('saves the unsent draft per session and restores it on switch', async () => { - const wrapper = mountComposer({ sessionId: 'sess_A' }); - const textarea = wrapper.get('textarea'); - const el = textarea.element as HTMLTextAreaElement; - - await textarea.setValue('draft for A'); - expect(localStorage.getItem('kimi-web.draft.sess_A')).toBe('draft for A'); - - // Switch to another session → box clears (B has no draft), A is preserved. - await wrapper.setProps({ sessionId: 'sess_B' }); - expect(el.value).toBe(''); - await textarea.setValue('draft for B'); - - // Back to A → its draft comes back. - await wrapper.setProps({ sessionId: 'sess_A' }); - expect(el.value).toBe('draft for A'); - // B's draft is still stored too. - expect(localStorage.getItem('kimi-web.draft.sess_B')).toBe('draft for B'); - }); - - it('restores a saved draft on mount and clears it after sending', async () => { - localStorage.setItem('kimi-web.draft.sess_X', 'unfinished'); - const wrapper = mountComposer({ sessionId: 'sess_X' }); - const textarea = wrapper.get('textarea'); - expect((textarea.element as HTMLTextAreaElement).value).toBe('unfinished'); - - await textarea.trigger('keydown', { key: 'Enter' }); - expect(wrapper.emitted('submit')).toHaveLength(1); - // Draft cleared once sent. - expect(localStorage.getItem('kimi-web.draft.sess_X')).toBe(null); - }); - - it('stays empty when a new session is created right after sending from the empty state', async () => { - const wrapper = mountComposer({ sessionId: undefined }); - const textarea = wrapper.get('textarea'); - const el = textarea.element as HTMLTextAreaElement; - - await textarea.setValue('hello'); - await textarea.trigger('keydown', { key: 'Enter' }); - - expect(wrapper.emitted('submit')).toHaveLength(1); - expect(el.value).toBe(''); - - // Parent creates a new session and passes its id down to the composer. - await wrapper.setProps({ sessionId: 'sess_new' }); - await flushPromises(); - - expect(el.value).toBe(''); - expect(localStorage.getItem('kimi-web.draft.sess_new')).toBe(null); - }); -}); - -describe('Composer height', () => { - it('does not write an autosized textarea height as text grows', async () => { - const wrapper = mountComposer(); - const textarea = wrapper.get('textarea'); - const el = textarea.element as HTMLTextAreaElement; - el.style.height = '180px'; - - await textarea.setValue('one line\nsecond line\nthird line'); - - expect(el.style.height).toBe(''); - }); -}); - -describe('Composer attachment preview', () => { - it('opens a pasted image preview from the attachment thumbnail', async () => { - const originalCreateObjectURL = URL.createObjectURL; - const originalRevokeObjectURL = URL.revokeObjectURL; - Object.defineProperty(URL, 'createObjectURL', { - value: vi.fn(() => 'blob:preview'), - configurable: true, - }); - Object.defineProperty(URL, 'revokeObjectURL', { - value: vi.fn(), - configurable: true, - }); - const wrapper = mountComposer({ - uploadImage: vi.fn(async () => ({ fileId: 'file_1', name: 'shot.png', mediaType: 'image/png' })), - }); - const file = new File(['png'], 'shot.png', { type: 'image/png' }); - const paste = new Event('paste', { bubbles: true, cancelable: true }); - Object.defineProperty(paste, 'clipboardData', { - value: { items: [], files: [file] }, - }); - - document.dispatchEvent(paste); - await flushPromises(); - - await wrapper.find('.att-preview').trigger('click'); - - expect(wrapper.find('.att-lightbox').exists()).toBe(true); - expect(wrapper.find('.att-lightbox-media').attributes('src')).toBe('blob:preview'); - - Object.defineProperty(URL, 'createObjectURL', { - value: originalCreateObjectURL, - configurable: true, - }); - Object.defineProperty(URL, 'revokeObjectURL', { - value: originalRevokeObjectURL, - configurable: true, - }); - }); -}); - -describe('Composer slash command input', () => { - it('emits /goal with the typed objective instead of sending it as chat', async () => { - const wrapper = mountComposer(); - const textarea = wrapper.get('textarea'); - - await textarea.setValue('/goal swarm review the changed files'); - await textarea.trigger('keydown', { key: 'Enter' }); - - expect(wrapper.emitted('command')).toEqual([['/goal swarm review the changed files']]); - expect(wrapper.emitted('submit')).toBeUndefined(); - }); - - it('emits /swarm with the typed task instead of sending it as chat', async () => { - const wrapper = mountComposer(); - const textarea = wrapper.get('textarea'); - - await textarea.setValue('/swarm inspect flaky tests'); - await textarea.trigger('keydown', { key: 'Enter' }); - - expect(wrapper.emitted('command')).toEqual([['/swarm inspect flaky tests']]); - expect(wrapper.emitted('submit')).toBeUndefined(); - }); - - it('keeps input-capable slash commands in the composer when selected from the menu', async () => { - const wrapper = mountComposer(); - const textarea = wrapper.get('textarea'); - - await textarea.setValue('/go'); - await textarea.trigger('keydown', { key: 'Enter' }); - - expect((textarea.element as HTMLTextAreaElement).value).toBe('/goal '); - expect(wrapper.emitted('command')).toBeUndefined(); - }); - - it('keeps selected session skills in the composer so arguments can be added', async () => { - const wrapper = mountComposer({ - skills: [{ name: 'my-skill', description: 'Do a thing', source: 'project' }], - }); - const textarea = wrapper.get('textarea'); - - await textarea.setValue('/my'); - await textarea.trigger('keydown', { key: 'Enter' }); - - expect((textarea.element as HTMLTextAreaElement).value).toBe('/my-skill '); - expect(wrapper.emitted('command')).toBeUndefined(); - }); -}); - -describe('Composer model dropdown', () => { - const models: AppModel[] = [ - { id: 'kimi/k2', provider: 'kimi', model: 'k2', displayName: 'Kimi K2', maxContextSize: 128000 }, - { id: 'openai/gpt-5', provider: 'openai', model: 'gpt-5', displayName: 'GPT-5', maxContextSize: 256000 }, - { id: 'openai/gpt-4o', provider: 'openai', model: 'gpt-4o', displayName: 'GPT-4o', maxContextSize: 128000 }, - ]; - - it('shows starred models from other providers in the quick-switch dropdown', async () => { - const wrapper = mountComposer({ - status: { model: 'Kimi K2', modelId: 'kimi/k2', ctxUsed: 0, ctxMax: 128000, permission: 'manual' }, - models, - starredIds: ['openai/gpt-5'], - }); - - await wrapper.find('.model-pill').trigger('click'); - - const rows = wrapper.findAll('.md-row'); - expect(rows.length).toBeGreaterThan(0); - expect(wrapper.text()).toContain('Starred'); - expect(wrapper.text()).toContain('GPT-5'); - expect(wrapper.text()).toContain('openai'); - }); - - it('emits selectModel when a starred model is chosen', async () => { - const wrapper = mountComposer({ - status: { model: 'Kimi K2', modelId: 'kimi/k2', ctxUsed: 0, ctxMax: 128000, permission: 'manual' }, - models, - starredIds: ['openai/gpt-5'], - }); - - await wrapper.find('.model-pill').trigger('click'); - const starredRow = wrapper.findAll('.md-row').find((row) => row.text().includes('GPT-5')); - expect(starredRow).toBeDefined(); - await starredRow!.trigger('click'); - - expect(wrapper.emitted('selectModel')).toEqual([['openai/gpt-5']]); - }); -}); - -describe('Composer context indicator', () => { - const status = { model: 'Kimi K2', modelId: 'kimi/k2', ctxUsed: 0, ctxMax: 128000, permission: 'manual' }; - - it('shows the ctx-group by default when status is available', () => { - const wrapper = mountComposer({ status }); - - expect(wrapper.find('.ctx-group').exists()).toBe(true); - }); - - it('hides the ctx-group when hideContext is true', () => { - const wrapper = mountComposer({ status, hideContext: true }); - - expect(wrapper.find('.ctx-group').exists()).toBe(false); - }); - - it('still shows the model pill when ctx-group is hidden', () => { - const wrapper = mountComposer({ status, hideContext: true }); - - expect(wrapper.find('.model-pill').exists()).toBe(true); - }); -}); diff --git a/apps/kimi-web/test/conversation-dock-cards.test.ts b/apps/kimi-web/test/conversation-dock-cards.test.ts deleted file mode 100644 index cdcd3855dd..0000000000 --- a/apps/kimi-web/test/conversation-dock-cards.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { createI18n } from 'vue-i18n'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { nextTick } from 'vue'; - -import ConversationPane from '../src/components/ConversationPane.vue'; -import type { SwarmGroup } from '../src/composables/swarmGroups'; -import type { ConversationStatus, QueuedPromptView, TaskItem, TodoView, UIQuestion } from '../src/types'; - -const status: ConversationStatus = { - model: 'kimi-test', - modelId: 'kimi-test', - ctxUsed: 0, - ctxMax: 0, - permission: 'manual', - branch: 'main', - cwd: '/repo', - isGitRepo: true, -}; - -const turns = [{ id: 't1', role: 'user' as const, no: 1, text: 'hi' }]; - -function question(id: string, text: string): UIQuestion { - return { - questionId: id, - sessionId: 'sess_1', - questions: [ - { - id: `${id}_item`, - question: text, - options: [{ id: 'opt_1', label: 'Option 1' }], - }, - ], - }; -} - -function mountPane(extraProps: Record) { - const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { en: {} }, - missingWarn: false, - fallbackWarn: false, - }); - return mount(ConversationPane, { - attachTo: document.body, - props: { - mobile: true, - turns, - tasks: [], - status, - ...extraProps, - }, - global: { - plugins: [i18n], - stubs: { - ChatHeader: true, - ChatPane: true, - Composer: true, - GoalStrip: true, - TasksPane: true, - TodoCard: true, - QueuePane: true, - Terminal: true, - SwarmCard: true, - }, - }, - }); -} - -afterEach(() => { - document.body.innerHTML = ''; - vi.unstubAllGlobals(); -}); - -describe('ConversationPane docked composer', () => { - it('renders the docked composer inside the chat layout', async () => { - const wrapper = mountPane({}); - - expect(wrapper.find('composer-stub').exists()).toBe(true); - expect(wrapper.find('.chat-layout > .chat-dock').exists()).toBe(true); - expect(wrapper.find('.chat-scroll > .chat-dock').exists()).toBe(false); - }); - - it('passes the chat scroller gutter to the dock for composer alignment', async () => { - const resizeCallbacks: ResizeObserverCallback[] = []; - class MockResizeObserver { - constructor(callback: ResizeObserverCallback) { - resizeCallbacks.push(callback); - } - - observe(): void {} - unobserve(): void {} - disconnect(): void {} - } - vi.stubGlobal('ResizeObserver', MockResizeObserver); - - const wrapper = mountPane({}); - await nextTick(); - await nextTick(); - - const pane = wrapper.find('.chat-scroll').element as HTMLElement; - Object.defineProperty(pane, 'offsetWidth', { - configurable: true, - get: () => 800, - }); - Object.defineProperty(pane, 'clientWidth', { - configurable: true, - get: () => 785, - }); - - for (const callback of resizeCallbacks) { - callback([], {} as ResizeObserver); - } - await nextTick(); - - const dock = wrapper.find('.chat-dock').element as HTMLElement; - expect(dock.style.getPropertyValue('--panes-scrollbar-width')).toBe('15px'); - }); - - it('remounts the question card when the pending question changes', async () => { - const wrapper = mountPane({ questions: [question('q1', 'First?')] }); - - await wrapper.find('.qmin').trigger('click'); - expect(wrapper.find('.qbody').exists()).toBe(false); - - await wrapper.setProps({ questions: [question('q2', 'Second?')] }); - await nextTick(); - - expect(wrapper.find('.qbody').exists()).toBe(true); - expect(wrapper.text()).toContain('Second?'); - }); - - it('remounts the approval card when the pending approval changes', async () => { - const wrapper = mountPane({ - approvals: [{ approvalId: 'a1', block: { kind: 'generic', summary: 'first action' } }], - }); - - await wrapper.find('.amin').trigger('click'); - expect(wrapper.find('.body-generic').exists()).toBe(false); - - await wrapper.setProps({ - approvals: [{ approvalId: 'a2', block: { kind: 'generic', summary: 'second action' } }], - }); - await nextTick(); - - expect(wrapper.find('.body-generic').exists()).toBe(true); - expect(wrapper.text()).toContain('second action'); - }); -}); - -describe('ConversationPane dock work panel', () => { - it('opens bash, subagent, todos, and queue from the dock chips', async () => { - const tasks: TaskItem[] = [ - { - id: 'task_1', - name: 'Build web', - kind: 'bash', - state: 'run', - timing: 'Running', - }, - { - id: 'task_2', - name: 'Review code', - kind: 'subagent', - state: 'run', - timing: 'Running', - }, - ]; - const todos: TodoView[] = [{ title: 'Check mobile dock', status: 'in_progress' }]; - const queued: QueuedPromptView[] = [{ text: 'Queued thought', attachmentCount: 0 }]; - const wrapper = mountPane({ tasks, todos, queued }); - - expect(wrapper.find('.dock-work-panel').exists()).toBe(false); - - const chips = wrapper.findAll('.dock-work-chip'); - expect(chips).toHaveLength(4); - for (const chip of chips) { - expect(chip.find('svg').exists()).toBe(true); - expect(chip.find('.dw-count').exists()).toBe(true); - } - expect(chips[2]!.find('.dw-count').text()).toBe('(0/1)'); - expect(chips[3]!.find('.dw-count').text()).toBe('(1)'); - - await chips[0]!.trigger('click'); - expect(wrapper.find('.dock-work-panel').exists()).toBe(true); - const bashPane = wrapper.findComponent({ name: 'TasksPane' }); - expect(bashPane.exists()).toBe(true); - expect(bashPane.props('tasks')).toHaveLength(1); - expect(bashPane.props('tasks')[0].id).toBe('task_1'); - - await chips[1]!.trigger('click'); - const subagentPane = wrapper.findAllComponents({ name: 'TasksPane' }).at(-1); - expect(subagentPane).toBeTruthy(); - expect(subagentPane!.props('tasks')).toHaveLength(1); - expect(subagentPane!.props('tasks')[0].id).toBe('task_2'); - - await chips[2]!.trigger('click'); - expect(wrapper.find('todo-card-stub').exists()).toBe(true); - - await chips[3]!.trigger('click'); - expect(wrapper.find('queue-pane-stub').exists()).toBe(true); - expect(wrapper.findComponent({ name: 'QueuePane' }).props('queued')).toHaveLength(1); - }); - - it('closes the dock work panel when the user clicks outside it', async () => { - const tasks: TaskItem[] = [ - { - id: 'task_1', - name: 'Review code', - kind: 'subagent', - state: 'run', - timing: 'Running', - }, - ]; - const wrapper = mountPane({ tasks }); - - await wrapper.find('.dock-work-chip').trigger('click'); - expect(wrapper.find('.dock-work-panel').exists()).toBe(true); - expect(wrapper.find('.dock-work-close').exists()).toBe(false); - - document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); - await nextTick(); - - expect(wrapper.find('.dock-work-panel').exists()).toBe(false); - }); -}); - -function swarmGroup(members: { phase: SwarmGroup['members'][number]['phase']; id?: string }[]): SwarmGroup { - const ms = members.map((m, i) => ({ - id: m.id ?? `agent_${i + 1}`, - name: `Agent ${i + 1}`, - phase: m.phase, - swarmIndex: i + 1, - })); - const counts: SwarmGroup['counts'] = { queued: 0, working: 0, suspended: 0, completed: 0, failed: 0 }; - for (const m of ms) counts[m.phase]++; - return { id: 'swarm_1', members: ms, counts }; -} - -describe('ConversationPane swarm stack', () => { - it('shows the swarm stack while at least one member is active', () => { - const wrapper = mountPane({ - swarms: [swarmGroup([{ phase: 'working' }, { phase: 'completed' }])], - }); - expect(wrapper.find('.swarm-stack').exists()).toBe(true); - }); - - it('hides the swarm stack once all members are completed or failed', () => { - const wrapper = mountPane({ - swarms: [swarmGroup([{ phase: 'completed' }, { phase: 'failed' }])], - }); - expect(wrapper.find('.swarm-stack').exists()).toBe(false); - }); -}); diff --git a/apps/kimi-web/test/conversation-pane-empty-send-integration.test.ts b/apps/kimi-web/test/conversation-pane-empty-send-integration.test.ts deleted file mode 100644 index b4fcd1bf26..0000000000 --- a/apps/kimi-web/test/conversation-pane-empty-send-integration.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -// apps/kimi-web/test/conversation-pane-empty-send-integration.test.ts -// -// Integration test that drives the real useKimiWebClient + ConversationPane -// through the empty-session -> send -> new session flow. We want to verify that -// the composer text is cleared and does not reappear in the docked composer. - -import { mount } from '@vue/test-utils'; -import { createI18n } from 'vue-i18n'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { nextTick } from 'vue'; -import type { AppSession, KimiEventHandlers, KimiWebApi } from '../src/api/types'; -import ConversationPane from '../src/components/ConversationPane.vue'; -import { defineComponent, h, type VNode } from 'vue'; - -const now = '2026-06-11T00:00:00.000Z'; - -function makeSession(id: string, overrides?: Partial): AppSession { - return { - id, - title: id, - createdAt: now, - updatedAt: now, - status: 'idle', - cwd: '/repo', - model: 'kimi-test', - usage: { - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheCreationTokens: 0, - totalCostUsd: 0, - contextTokens: 0, - contextLimit: 128_000, - turnCount: 0, - }, - messageCount: 0, - lastSeq: 0, - ...overrides, - }; -} - -async function setup() { - vi.resetModules(); - vi.stubGlobal('WebSocket', class WebSocket {}); - - let handlers: KimiEventHandlers | undefined; - const eventConn = { - subscribe: vi.fn(), - unsubscribe: vi.fn(), - bindNextPromptId: vi.fn(), - seedSnapshot: vi.fn(), - abort: vi.fn(), - close: vi.fn(), - }; - - const created = makeSession('sess_new'); - const api = { - createSession: vi.fn(async () => created), - submitPrompt: vi.fn(async () => ({ promptId: 'pr_1', userMessageId: 'msg_real' })), - addWorkspace: vi.fn(async () => ({ id: 'ws_repo', root: '/repo', name: 'repo', isGitRepo: false, sessionCount: 0 })), - deleteWorkspace: vi.fn(async () => ({ deleted: true })), - listWorkspaces: vi.fn(async () => []), - browseFs: vi.fn(async (path?: string) => ({ path: path ?? '/home/user', parent: null, entries: [] })), - getFsHome: vi.fn(async () => ({ home: '/home/user', recentRoots: [] })), - listSessions: vi.fn(async () => ({ items: [], hasMore: false })), - getHealth: vi.fn(async () => ({ ok: true })), - getMeta: vi.fn(async () => ({ daemonVersion: '0.0.1' })), - getSessionStatus: vi.fn(async () => ({ - model: 'kimi-test', - thinkingLevel: 'high', - permission: 'manual', - planMode: false, - swarmMode: false, - contextTokens: 0, - maxContextTokens: 128_000, - contextUsage: 0, - })), - getSessionSnapshot: vi.fn(async () => ({ - asOfSeq: 0, - epoch: 'ep_test', - session: created, - messages: [], - hasMoreMessages: false, - inFlightTurn: null, - pendingApprovals: [], - pendingQuestions: [], - })), - listTasks: vi.fn(async () => []), - getGitStatus: vi.fn(async () => ({ branch: 'main', ahead: 0, behind: 0, entries: {}, additions: 0, deletions: 0 })), - connectEvents: vi.fn((nextHandlers: KimiEventHandlers) => { - handlers = nextHandlers; - return eventConn; - }), - getFileUrl: vi.fn((fileId: string) => `/files/${fileId}`), - } as unknown as KimiWebApi; - - vi.doMock('../src/api', () => ({ getKimiWebApi: () => api })); - const { useKimiWebClient } = await import('../src/composables/useKimiWebClient'); - - return { - api, - client: useKimiWebClient(), - eventConn, - getHandlers: () => { - if (!handlers) throw new Error('connectEvents was not called'); - return handlers; - }, - }; -} - -let resizeCallbacks: ResizeObserverCallback[] = []; -class MockResizeObserver { - constructor(cb: ResizeObserverCallback) { - resizeCallbacks.push(cb); - } - observe(): void {} - unobserve(): void {} - disconnect(): void {} -} - -afterEach(() => { - document.body.innerHTML = ''; - try { localStorage.clear(); } catch { /* ignore */ } - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - resizeCallbacks = []; -}); - -describe('ConversationPane empty-session send integration', () => { - it('clears the composer through the real client flow', async () => { - vi.stubGlobal('ResizeObserver', MockResizeObserver); - const { client } = await setup(); - await client.addWorkspaceByPath('/repo'); - client.openWorkspaceDraft('ws_repo'); - - const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { en: {} }, - missingWarn: false, - fallbackWarn: false, - }); - - async function handleSubmit(payload: { text: string; attachments: { fileId: string; kind: 'image' | 'video' }[] }): Promise { - const wsId = client.activeWorkspaceId.value; - if (!client.activeSessionId.value && wsId) { - await client.startSessionAndSendPrompt(wsId, payload.text, payload.attachments); - } - } - - const TestWrapper = defineComponent({ - setup() { - return () => - h(ConversationPane, { - mobile: true, - turns: client.turns.value, - sessionId: client.activeSessionId.value, - tasks: client.tasks.value, - status: client.status.value, - sessionLoading: client.sessionLoading.value, - running: client.activity.value !== 'idle', - queued: client.queued.value, - sending: client.isSending.value, - models: client.models.value, - skills: client.skills.value, - workspaces: client.workspacesView.value, - activeWorkspaceId: client.activeWorkspaceId.value, - workspaceName: client.visibleWorkspace.value?.name, - workspaceRoot: client.visibleWorkspace.value?.root ?? client.status.value.cwd, - fileReloadKey: client.activeSessionId.value, - onSubmit: handleSubmit, - } as Record); - }, - }); - - const wrapper = mount(TestWrapper, { - attachTo: document.body, - global: { - plugins: [i18n], - stubs: { - ChatHeader: true, - ChatPane: true, - GoalStrip: true, - TasksPane: true, - TodoCard: true, - Terminal: true, - SwarmCard: true, - }, - }, - }); - - await nextTick(); - - const textarea = wrapper.find('textarea.ph'); - expect(textarea.exists()).toBe(true); - - // Type in the empty-session composer. - await textarea.setValue('hello integration'); - expect((textarea.element as HTMLTextAreaElement).value).toBe('hello integration'); - - // Submit with Enter. - await textarea.trigger('keydown', { key: 'Enter' }); - - // Composer should be empty immediately. - expect((wrapper.find('textarea.ph').element as HTMLTextAreaElement).value).toBe(''); - expect(localStorage.getItem('kimi-web.draft.__new__')).toBe(null); - - // Let the client flow finish. - await vi.waitFor(() => expect(client.activeSessionId.value).toBe('sess_new')); - await vi.waitFor(() => expect(client.sessionLoading.value).toBe(false)); - - // No matter which composer is mounted now, its textarea must be empty. - const final = wrapper.find('textarea.ph'); - expect(final.exists()).toBe(true); - expect((final.element as HTMLTextAreaElement).value).toBe(''); - expect(localStorage.getItem('kimi-web.draft.sess_new')).toBe(null); - }); -}); diff --git a/apps/kimi-web/test/conversation-pane-empty-send.test.ts b/apps/kimi-web/test/conversation-pane-empty-send.test.ts deleted file mode 100644 index f560a12603..0000000000 --- a/apps/kimi-web/test/conversation-pane-empty-send.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { createI18n } from 'vue-i18n'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { nextTick } from 'vue'; - -import ConversationPane from '../src/components/ConversationPane.vue'; -import type { ChatTurn, ConversationStatus } from '../src/types'; - -const status: ConversationStatus = { - model: 'kimi-test', - modelId: 'kimi-test', - ctxUsed: 0, - ctxMax: 0, - permission: 'manual', - branch: 'main', - cwd: '/repo', - isGitRepo: true, -}; - -let resizeCallbacks: ResizeObserverCallback[] = []; -class MockResizeObserver { - constructor(cb: ResizeObserverCallback) { - resizeCallbacks.push(cb); - } - observe(): void {} - unobserve(): void {} - disconnect(): void {} -} - -function mountPane(extraProps: Record = {}) { - resizeCallbacks = []; - vi.stubGlobal('ResizeObserver', MockResizeObserver); - - const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { en: {} }, - missingWarn: false, - fallbackWarn: false, - }); - - return mount(ConversationPane, { - attachTo: document.body, - props: { - mobile: true, - turns: [], - tasks: [], - status, - fileReloadKey: 'no-session', - sessionLoading: false, - running: false, - ...extraProps, - }, - global: { - plugins: [i18n], - stubs: { - ChatHeader: true, - ChatPane: true, - GoalStrip: true, - TasksPane: true, - TodoCard: true, - Terminal: true, - SwarmCard: true, - }, - }, - }); -} - -afterEach(() => { - document.body.innerHTML = ''; - try { localStorage.clear(); } catch { /* ignore */ } - vi.unstubAllGlobals(); - vi.restoreAllMocks(); -}); - -describe('ConversationPane empty-session send', () => { - it('offers an add-workspace action when no workspace exists', async () => { - const wrapper = mountPane({ workspaces: [], activeWorkspaceId: null }); - await nextTick(); - - const addWorkspace = wrapper.find('.empty-add-workspace'); - expect(addWorkspace.exists()).toBe(true); - - await addWorkspace.trigger('click'); - - expect(wrapper.emitted('addWorkspace')).toHaveLength(1); - }); - - it('clears the empty composer and keeps the new-session draft empty after send', async () => { - const wrapper = mountPane({ sessionId: '' }); - await nextTick(); - - const textarea = wrapper.find('textarea.ph'); - expect(textarea.exists()).toBe(true); - const el = textarea.element as HTMLTextAreaElement; - - // Type in the empty-session composer. - await textarea.setValue('hello world'); - expect(el.value).toBe('hello world'); - expect(localStorage.getItem('kimi-web.draft.__new__')).toBe('hello world'); - - // Simulate the parent handling submit: no active session -> create session and send. - // The composer clears itself synchronously before emitting submit. - await textarea.trigger('keydown', { key: 'Enter' }); - - // Composer should be empty immediately after submit. - expect(el.value).toBe(''); - expect(localStorage.getItem('kimi-web.draft.__new__')).toBe(null); - - // Parent now creates/selects a new session and switches to loading. - await wrapper.setProps({ sessionId: 'sess_new', sessionLoading: true, fileReloadKey: 'sess_new' }); - await nextTick(); - - // Loading state: the dock composer is shown; its value must be empty. - const dockDuringLoading = wrapper.find('textarea.ph'); - expect(dockDuringLoading.exists()).toBe(true); - expect((dockDuringLoading.element as HTMLTextAreaElement).value).toBe(''); - - // Snapshot returns: still no turns, loading cleared. - await wrapper.setProps({ sessionLoading: false }); - await nextTick(); - - // Empty composer remounts for the new session before the optimistic message lands. - const remounted = wrapper.find('textarea.ph'); - expect(remounted.exists()).toBe(true); - expect((remounted.element as HTMLTextAreaElement).value).toBe(''); - expect(localStorage.getItem('kimi-web.draft.sess_new')).toBe(null); - - // Optimistic user message lands. - const turn: ChatTurn = { - id: 'msg_1', - role: 'user', - text: 'hello world', - blocks: [{ kind: 'text', text: 'hello world' }], - }; - await wrapper.setProps({ turns: [turn] }); - await nextTick(); - - // Chat dock composer mounts; its draft for the new session must also be empty. - const dockTextarea = wrapper.find('textarea.ph'); - expect(dockTextarea.exists()).toBe(true); - expect((dockTextarea.element as HTMLTextAreaElement).value).toBe(''); - expect(localStorage.getItem('kimi-web.draft.sess_new')).toBe(null); - }); -}); diff --git a/apps/kimi-web/test/conversation-pane-follow.test.ts b/apps/kimi-web/test/conversation-pane-follow.test.ts deleted file mode 100644 index 07ada3c51d..0000000000 --- a/apps/kimi-web/test/conversation-pane-follow.test.ts +++ /dev/null @@ -1,490 +0,0 @@ -import { flushPromises, mount } from '@vue/test-utils'; -import { createI18n } from 'vue-i18n'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { nextTick, type Component } from 'vue'; - -import ConversationPane from '../src/components/ConversationPane.vue'; -import ChatDock from '../src/components/ChatDock.vue'; -import type { ChatTurn, ConversationStatus, UIQuestion } from '../src/types'; - -// These tests verify USER-OBSERVABLE follow/scroll behaviour through the real -// ConversationPane (+ real ChatDock so the composer / question / approval / pill -// all render). The only test doubles are the heavy leaf renderers and a -// controllable ResizeObserver — jsdom ships no ResizeObserver, and the dock / -// content-column resize path can only be exercised by firing its callback. - -const status: ConversationStatus = { - model: 'kimi-test', - modelId: 'kimi-test', - ctxUsed: 0, - ctxMax: 0, - permission: 'manual', - branch: 'main', - cwd: '/repo', - isGitRepo: true, -}; - -let resizeCallbacks: ResizeObserverCallback[] = []; -class MockResizeObserver { - constructor(cb: ResizeObserverCallback) { - resizeCallbacks.push(cb); - } - observe(): void {} - unobserve(): void {} - disconnect(): void {} -} -/** Simulate a layout resize (dock grew, image loaded, window resized, …). */ -function fireResize(): void { - for (const cb of resizeCallbacks) cb([], {} as ResizeObserver); -} - -function mountMobilePane( - extraProps: Record, - options: { chatPaneStub?: Component | boolean } = {}, -) { - const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { en: {} }, - missingWarn: false, - fallbackWarn: false, - }); - return mount(ConversationPane, { - attachTo: document.body, - props: { - mobile: true, - turns: [], - tasks: [], - status, - fileReloadKey: 'sess_1', - sessionLoading: false, - running: false, - ...extraProps, - }, - global: { - plugins: [i18n], - // ChatDock is rendered for real (composer/question/approval/pill paths); - // only the heavy leaf renderers are stubbed. - stubs: { - ChatHeader: true, - ChatPane: options.chatPaneStub ?? true, - Composer: true, - GoalStrip: true, - TasksPane: true, - TodoCard: true, - Terminal: true, - SwarmCard: true, - }, - }, - }); -} - -/** Mock the scroll geometry of a scroller. scrollHeight/clientHeight are read - from `geo` live (so a test can grow scrollHeight across frames); scrollTop is - a real writable value the component sets. */ -function mockPaneGeometry( - el: HTMLElement, - geo: { scrollHeight: number; clientHeight: number; scrollTop: number }, -): void { - Object.defineProperty(el, 'scrollHeight', { configurable: true, get: () => geo.scrollHeight }); - Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => geo.clientHeight }); - Object.defineProperty(el, 'scrollTop', { configurable: true, writable: true, value: geo.scrollTop }); -} - -function turn(no: number, text: string, extra: Partial = {}): ChatTurn { - return { id: `t${no}`, role: no % 2 ? 'user' : 'assistant', no, text, ...extra }; -} - -function question(id: string): UIQuestion { - return { - questionId: id, - sessionId: 'sess_1', - questions: [{ id: `${id}_q`, question: 'Pick one?', options: [{ id: 'o1', label: 'One' }] }], - }; -} - -let realResizeObserver: typeof globalThis.ResizeObserver | undefined; - -beforeEach(() => { - resizeCallbacks = []; - realResizeObserver = globalThis.ResizeObserver; - (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = MockResizeObserver; - vi.useFakeTimers(); - vi.spyOn(performance, 'now').mockReturnValue(100_000); -}); - -afterEach(() => { - document.body.innerHTML = ''; - localStorage.clear(); - if (realResizeObserver) { - (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = realResizeObserver; - } else { - // jsdom ships no ResizeObserver — remove the mock instead of leaving it behind. - delete (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver; - } - vi.restoreAllMocks(); - vi.useRealTimers(); -}); - -/** Mount, let the initial stable-follow loop settle, then return the (geometry- - mocked) scroller pre-positioned at the bottom and "following". */ -async function settledPane( - geo: { scrollHeight: number; clientHeight: number }, - props: Record = {}, - options: { chatPaneStub?: Component | boolean } = {}, -) { - const wrapper = mountMobilePane({ turns: [turn(1, 'hi')], ...props }, options); - await nextTick(); - vi.advanceTimersByTime(200); // initial scheduleStableFollow loop completes - await nextTick(); - - const pane = wrapper.find('.chat-scroll').element as HTMLElement; - const g = { ...geo, scrollTop: geo.scrollHeight - geo.clientHeight }; - mockPaneGeometry(pane, g); - // A scroll event at the bottom syncs the baseline; following stays on. - pane.dispatchEvent(new Event('scroll')); - await nextTick(); - return { wrapper, pane, geo: g }; -} - -/** Push new turns and fully settle: the scrollKey watcher's own `await nextTick` - plus the follow-up re-render both need to flush before the pill / scroll - position reflect the change. */ -async function pushTurns(wrapper: ReturnType, turns: ChatTurn[]) { - await wrapper.setProps({ turns }); - await nextTick(); - await nextTick(); - vi.advanceTimersByTime(40); - await nextTick(); -} - -/** Simulate the user scrolling the pane up out of the bottom zone. */ -function scrollUpTo(pane: HTMLElement, top: number): void { - pane.scrollTop = top; - pane.dispatchEvent(new Event('scroll')); -} - -const LoadOlderChatPane = { - template: '', -}; - -describe('ConversationPane follow — user scrolls up (req 2)', () => { - it('stops auto-follow and shows the pill instead of yanking the view back', async () => { - const { wrapper, pane, geo } = await settledPane({ scrollHeight: 2000, clientHeight: 500 }); - - // User scrolls up to read history, then new streaming content arrives. - scrollUpTo(pane, 300); - await nextTick(); - await pushTurns(wrapper, [turn(1, 'hi'), turn(2, 'streaming…')]); - - // The view is NOT pulled back to the bottom; the pill appears instead. - expect(pane.scrollTop).toBe(300); - expect(wrapper.find('.newmsg-pill').exists()).toBe(true); - - // Returning to the bottom zone re-arms the follow; new content pins again. - pane.scrollTop = geo.scrollHeight - geo.clientHeight; - pane.dispatchEvent(new Event('scroll')); - await nextTick(); - pane.scrollTop = 100; // pretend a later reflow left us short - await pushTurns(wrapper, [turn(1, 'hi'), turn(2, 'streaming… more')]); - - expect(pane.scrollTop).toBe(2000); - expect(wrapper.find('.newmsg-pill').exists()).toBe(false); - }); -}); - -describe('ConversationPane follow — history prepend', () => { - async function loadOlderAndSettle( - wrapper: ReturnType, - turns: ChatTurn[], - loadOlderMessages: ReturnType Promise>>, - afterLoad?: () => void, - ) { - loadOlderMessages.mockImplementation(async () => { - await wrapper.setProps({ turns }); - afterLoad?.(); - }); - - await wrapper.find('.load-older').trigger('click'); - await flushPromises(); - await nextTick(); - vi.advanceTimersByTime(40); - await nextTick(); - } - - it('keeps the new-message pill when bottom content arrives during a prepend', async () => { - const loadOlderMessages = vi.fn<(sessionId: string) => Promise>(); - const { wrapper, pane } = await settledPane( - { scrollHeight: 2000, clientHeight: 500 }, - { - sessionId: 'sess_1', - hasMoreMessages: true, - loadOlderMessages, - }, - { chatPaneStub: LoadOlderChatPane }, - ); - - scrollUpTo(pane, 300); - await nextTick(); - - await loadOlderAndSettle( - wrapper, - [turn(0, 'older'), turn(1, 'hi'), turn(2, 'new bottom')], - loadOlderMessages, - ); - - expect(pane.scrollTop).toBe(300); - expect(wrapper.find('.newmsg-pill').exists()).toBe(true); - }); - - it('does not show the new-message pill for a prepend-only update', async () => { - const loadOlderMessages = vi.fn<(sessionId: string) => Promise>(); - const { wrapper, pane } = await settledPane( - { scrollHeight: 2000, clientHeight: 500 }, - { - sessionId: 'sess_1', - hasMoreMessages: true, - loadOlderMessages, - }, - { chatPaneStub: LoadOlderChatPane }, - ); - - scrollUpTo(pane, 300); - await nextTick(); - - await loadOlderAndSettle(wrapper, [turn(0, 'older'), turn(1, 'hi')], loadOlderMessages); - - expect(pane.scrollTop).toBe(300); - expect(wrapper.find('.newmsg-pill').exists()).toBe(false); - }); - - it('does not show the new-message pill when a same-length prepend changes the first turn id', async () => { - const loadOlderMessages = vi.fn<(sessionId: string) => Promise>(); - const { wrapper, pane } = await settledPane( - { scrollHeight: 2000, clientHeight: 500 }, - { - sessionId: 'sess_1', - hasMoreMessages: true, - loadOlderMessages, - }, - { chatPaneStub: LoadOlderChatPane }, - ); - - await wrapper.setProps({ turns: [turn(1, 'first'), turn(2, 'last')] }); - await nextTick(); - scrollUpTo(pane, 300); - await nextTick(); - - await loadOlderAndSettle(wrapper, [turn(0, 'merged first'), turn(2, 'last')], loadOlderMessages); - - expect(pane.scrollTop).toBe(300); - expect(wrapper.find('.newmsg-pill').exists()).toBe(false); - }); - - it('falls back to scroll-height delta when the old anchor turn id disappears', async () => { - const loadOlderMessages = vi.fn<(sessionId: string) => Promise>(); - const { wrapper, pane } = await settledPane( - { scrollHeight: 2000, clientHeight: 500 }, - { - sessionId: 'sess_1', - hasMoreMessages: true, - loadOlderMessages, - }, - { chatPaneStub: LoadOlderChatPane }, - ); - - scrollUpTo(pane, 300); - await nextTick(); - - await loadOlderAndSettle( - wrapper, - [turn(0, 'older'), turn(1, 'hi')], - loadOlderMessages, - () => { - mockPaneGeometry(pane, { scrollHeight: 2600, clientHeight: 500, scrollTop: 300 }); - }, - ); - - expect(pane.scrollTop).toBe(900); - }); -}); - -describe('ConversationPane follow — user intent jumps to bottom (req 1)', () => { - it('sending a message returns to the bottom and resumes following', async () => { - const { wrapper, pane } = await settledPane({ scrollHeight: 2000, clientHeight: 500 }); - - // Scroll up + let new content raise the pill. - scrollUpTo(pane, 200); - await nextTick(); - await pushTurns(wrapper, [turn(1, 'hi'), turn(2, 'reply')]); - expect(pane.scrollTop).toBe(200); - expect(wrapper.find('.newmsg-pill').exists()).toBe(true); - - // User sends a message. - pane.scrollTop = 200; - wrapper.findComponent(ChatDock).vm.$emit('submit', { text: 'next', attachments: [] }); - await nextTick(); - vi.advanceTimersByTime(60); - await nextTick(); - - expect(pane.scrollTop).toBe(2000); - expect(wrapper.find('.newmsg-pill').exists()).toBe(false); - expect(wrapper.emitted('submit')).toBeTruthy(); - - // Following resumed: subsequent streaming keeps it pinned. - pane.scrollTop = 100; - await pushTurns(wrapper, [turn(1, 'hi'), turn(2, 'reply'), turn(3, 'more')]); - expect(pane.scrollTop).toBe(2000); - }); - - it('answering a question returns to the bottom', async () => { - const { wrapper, pane } = await settledPane( - { scrollHeight: 2000, clientHeight: 500 }, - { questions: [question('q1')] }, - ); - - pane.scrollTop = 150; - pane.dispatchEvent(new Event('scroll')); - await nextTick(); - - wrapper.findComponent(ChatDock).vm.$emit('answer', 'q1', { kind: 'option', optionId: 'o1' }); - await nextTick(); - vi.advanceTimersByTime(60); - await nextTick(); - - expect(pane.scrollTop).toBe(2000); - }); - - it('clicking the new-messages pill scrolls smoothly to the bottom and resumes following (req 7)', async () => { - const { wrapper, pane } = await settledPane({ scrollHeight: 2000, clientHeight: 500 }); - const scrollToSpy = vi.fn(); - (pane as unknown as { scrollTo: typeof scrollToSpy }).scrollTo = scrollToSpy; - - // Scroll up so the pill can appear, then bring in new content. - scrollUpTo(pane, 200); - await nextTick(); - await pushTurns(wrapper, [turn(1, 'hi'), turn(2, 'reply')]); - expect(wrapper.find('.newmsg-pill').exists()).toBe(true); - - await wrapper.find('.newmsg-pill').trigger('click'); - await nextTick(); - - // Pill jump is the ONE place that uses smooth scrolling. - expect(scrollToSpy).toHaveBeenCalledWith(expect.objectContaining({ behavior: 'smooth' })); - expect(wrapper.find('.newmsg-pill').exists()).toBe(false); - - // A delayed scroll event from the smooth animation must NOT be mistaken for a - // user up-scroll (same performance.now → inside the 100ms guard window). - pane.scrollTop = 1200; // mid-animation position, below the bottom - pane.dispatchEvent(new Event('scroll')); - await nextTick(); - - // Following stayed on: new content pins synchronously (no smooth scroll). - scrollToSpy.mockClear(); - pane.scrollTop = 100; - await pushTurns(wrapper, [turn(1, 'hi'), turn(2, 'reply'), turn(3, 'more')]); - expect(pane.scrollTop).toBe(2000); - expect(scrollToSpy).not.toHaveBeenCalled(); - }); -}); - -describe('ConversationPane follow — content changes keep the view pinned (req 3)', () => { - it('follows new turns, text/thinking/tool streaming, and approvals while following', async () => { - const { wrapper, pane } = await settledPane({ scrollHeight: 2000, clientHeight: 500 }); - - async function expectPinnedAfter(props: Record) { - pane.scrollTop = 100; // a reflow left the view short of the bottom - await wrapper.setProps(props); - await nextTick(); - vi.advanceTimersByTime(40); - await nextTick(); - expect(pane.scrollTop).toBe(2000); - } - - await expectPinnedAfter({ turns: [turn(1, 'hi'), turn(2, 'a')] }); // new turn - await expectPinnedAfter({ turns: [turn(1, 'hi'), turn(2, 'a longer streamed body')] }); // text stream - await expectPinnedAfter({ turns: [turn(1, 'hi'), turn(2, 'a longer streamed body', { thinking: 'pondering deeply' })] }); // thinking - await expectPinnedAfter({ - turns: [turn(1, 'hi'), turn(2, 'a longer streamed body', { - thinking: 'pondering deeply', - tools: [{ id: 'k1', name: 'bash', arg: 'ls', status: 'ok', output: ['one', 'two'] }], - })], - }); // tool args + output - await expectPinnedAfter({ approvals: [{ approvalId: 'ap1', block: { kind: 'generic', summary: 'run it' } }] }); // approval - }); - - it('re-pins after a turn finishes running (final markdown / highlight reflow)', async () => { - const { wrapper, pane } = await settledPane({ scrollHeight: 2000, clientHeight: 500 }, { running: true }); - - pane.scrollTop = 100; // final reflow left it short - await wrapper.setProps({ running: false }); - await nextTick(); - vi.advanceTimersByTime(80); - await nextTick(); - - expect(pane.scrollTop).toBe(2000); - }); -}); - -describe('ConversationPane follow — layout changes re-pin (req 4)', () => { - it('re-pins when the bottom dock grows (question replaces the composer)', async () => { - const { wrapper, pane } = await settledPane({ scrollHeight: 2000, clientHeight: 500 }); - - // A question replaces the composer → the dock grows and shrinks the - // viewport with no scroll/content event; only a ResizeObserver sees it. - await wrapper.setProps({ questions: [question('q1')] }); - await nextTick(); - pane.scrollTop = 100; // dock growth left the latest content hidden behind it - fireResize(); - await nextTick(); - vi.advanceTimersByTime(40); - await nextTick(); - - expect(pane.scrollTop).toBe(2000); - }); - - it('does not yank the view on resize when the user has scrolled up', async () => { - const { wrapper, pane } = await settledPane({ scrollHeight: 2000, clientHeight: 500 }); - - pane.scrollTop = 300; - pane.dispatchEvent(new Event('scroll')); // following off - await nextTick(); - - fireResize(); // a resize must not pull a reading user back down - await nextTick(); - vi.advanceTimersByTime(40); - await nextTick(); - - expect(pane.scrollTop).toBe(300); - }); -}); - -describe('ConversationPane follow — re-pin across frames until stable (req 6)', () => { - it('keeps re-pinning as the tail height grows over several frames after a send', async () => { - const wrapper = mountMobilePane({ turns: [turn(1, 'hi')] }); - await nextTick(); - vi.advanceTimersByTime(200); - await nextTick(); - - const pane = wrapper.find('.chat-scroll').element as HTMLElement; - const geo = { scrollHeight: 2000, clientHeight: 500, scrollTop: 100 }; - mockPaneGeometry(pane, geo); - - wrapper.findComponent(ChatDock).vm.$emit('submit', { text: 'go', attachments: [] }); - await nextTick(); - - // The tail keeps growing across the next few frames (markdown, images, code - // highlight). A single scroll would leave the view short; the stable-follow - // loop must keep pinning until the height settles. - vi.advanceTimersByTime(16); - geo.scrollHeight = 2600; - vi.advanceTimersByTime(16); - geo.scrollHeight = 3200; - vi.advanceTimersByTime(16); - await nextTick(); - vi.advanceTimersByTime(120); // height now stable → loop converges - await nextTick(); - - expect(pane.scrollTop).toBe(3200); - }); -}); diff --git a/apps/kimi-web/test/conversation-pane-header.test.ts b/apps/kimi-web/test/conversation-pane-header.test.ts deleted file mode 100644 index 4bcd4860b1..0000000000 --- a/apps/kimi-web/test/conversation-pane-header.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { createI18n } from 'vue-i18n'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { nextTick } from 'vue'; - -import ConversationPane from '../src/components/ConversationPane.vue'; -import type { ConversationStatus } from '../src/types'; - -const status: ConversationStatus = { - model: 'kimi-test', - modelId: 'kimi-test', - ctxUsed: 0, - ctxMax: 0, - permission: 'manual', - branch: 'main', - cwd: '/repo', - isGitRepo: true, -}; - -const turns = [{ id: 't1', role: 'user' as const, no: 1, text: 'hi' }]; - -function mountPane() { - const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { en: {} }, - missingWarn: false, - fallbackWarn: false, - }); - return mount(ConversationPane, { - attachTo: document.body, - props: { - mobile: false, - turns, - tasks: [], - status, - gitInfo: { branch: 'main', ahead: 0, behind: 0 }, - changes: [{ path: 'a.ts', status: 'modified' }], - gitDiffStats: { totalAdditions: 5, totalDeletions: 1 }, - fileReloadKey: 'sess_1', - sessionLoading: false, - running: false, - }, - global: { - plugins: [i18n], - stubs: { - ChatPane: true, - Composer: true, - ChatDock: true, - SwarmCard: true, - }, - }, - }); -} - -describe('ConversationPane header', () => { - afterEach(() => { - document.body.innerHTML = ''; - vi.restoreAllMocks(); - }); - - it('forwards openChanges from ChatHeader', async () => { - const wrapper = mountPane(); - await nextTick(); - - await wrapper.find('.ch-git').trigger('click'); - - expect(wrapper.emitted('openChanges')).toHaveLength(1); - }); -}); diff --git a/apps/kimi-web/test/conversation-pane-open-agent.test.ts b/apps/kimi-web/test/conversation-pane-open-agent.test.ts deleted file mode 100644 index c4a3b68d34..0000000000 --- a/apps/kimi-web/test/conversation-pane-open-agent.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { createI18n } from 'vue-i18n'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { defineComponent, nextTick } from 'vue'; - -import ConversationPane from '../src/components/ConversationPane.vue'; -import type { ConversationStatus } from '../src/types'; - -const status: ConversationStatus = { - model: 'kimi-test', - modelId: 'kimi-test', - ctxUsed: 0, - ctxMax: 0, - permission: 'manual', - branch: 'main', - cwd: '/repo', - isGitRepo: true, -}; - -const ChatPaneStub = defineComponent({ - name: 'ChatPaneStub', - emits: ['open-agent'], - template: ``, -}); - -function mountPane() { - const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { en: {} }, - missingWarn: false, - fallbackWarn: false, - }); - return mount(ConversationPane, { - attachTo: document.body, - props: { - mobile: false, - turns: [{ id: 't1', role: 'assistant' as const, no: 1, text: 'hi' }], - tasks: [], - status, - sessionLoading: false, - running: false, - }, - global: { - plugins: [i18n], - stubs: { - ChatPane: ChatPaneStub, - Composer: true, - ChatDock: true, - SwarmCard: true, - }, - }, - }); -} - -describe('ConversationPane open-agent forwarding', () => { - afterEach(() => { - document.body.innerHTML = ''; - vi.restoreAllMocks(); - }); - - it('forwards ChatPane open-agent emits to the parent', async () => { - const wrapper = mountPane(); - await nextTick(); - - await wrapper.find('[data-testid="stub-open-agent"]').trigger('click'); - await nextTick(); - - expect(wrapper.emitted('openAgent')).toEqual([ - [{ turnId: 't1', blockIndex: 2, memberId: 'agent_1' }], - ]); - }); -}); diff --git a/apps/kimi-web/test/conversation-pane-scroll.test.ts b/apps/kimi-web/test/conversation-pane-scroll.test.ts deleted file mode 100644 index 4132542bbc..0000000000 --- a/apps/kimi-web/test/conversation-pane-scroll.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { createI18n } from 'vue-i18n'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { nextTick } from 'vue'; - -import ConversationPane from '../src/components/ConversationPane.vue'; -import type { ConversationStatus } from '../src/types'; - -const status: ConversationStatus = { - model: 'kimi-test', - modelId: 'kimi-test', - ctxUsed: 0, - ctxMax: 0, - permission: 'manual', - branch: 'main', - cwd: '/repo', - isGitRepo: true, -}; - -function mountPane(extraProps: Record) { - const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { en: {} }, - missingWarn: false, - fallbackWarn: false, - }); - return mount(ConversationPane, { - attachTo: document.body, - props: { - mobile: true, - turns: [], - tasks: [], - status, - fileReloadKey: 'sess_1', - sessionLoading: false, - running: false, - ...extraProps, - }, - global: { - plugins: [i18n], - stubs: { - ChatHeader: true, - ChatPane: true, - Composer: true, - GoalStrip: true, - TasksPane: true, - TodoCard: true, - Terminal: true, - SwarmCard: true, - }, - }, - }); -} - -function mockPaneGeometry( - el: HTMLElement, - geometry: { scrollHeight: number; clientHeight: number; scrollTop: number }, -): void { - Object.defineProperty(el, 'scrollHeight', { - configurable: true, - get: () => geometry.scrollHeight, - }); - Object.defineProperty(el, 'clientHeight', { - configurable: true, - get: () => geometry.clientHeight, - }); - Object.defineProperty(el, 'scrollTop', { - configurable: true, - writable: true, - value: geometry.scrollTop, - }); -} - -afterEach(() => { - document.body.innerHTML = ''; - localStorage.clear(); - vi.restoreAllMocks(); - vi.useRealTimers(); -}); - -function mountDesktopPane(extraProps: Record) { - const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { en: {} }, - missingWarn: false, - fallbackWarn: false, - }); - return mount(ConversationPane, { - attachTo: document.body, - props: { - mobile: false, - turns: [], - tasks: [], - status, - fileReloadKey: 'sess_1', - sessionLoading: false, - running: false, - ...extraProps, - }, - global: { - plugins: [i18n], - stubs: { - ChatHeader: true, - ChatPane: true, - Composer: true, - GoalStrip: true, - ChatDock: true, - SwarmCard: true, - }, - }, - }); -} - -describe('ConversationPane session switch scroll', () => { - it('scrolls to the bottom when switching to a shorter session', async () => { - vi.useFakeTimers(); - vi.spyOn(performance, 'now').mockReturnValue(100_000); - - const longTurns = Array.from({ length: 20 }, (_, i) => ({ - id: `t${i}`, - role: 'user' as const, - no: i + 1, - text: `message ${i + 1}`, - })); - - const wrapper = mountPane({ - turns: longTurns, - fileReloadKey: 'sess-long', - }); - await nextTick(); - - const panesEl = wrapper.find('.chat-scroll').element as HTMLElement; - mockPaneGeometry(panesEl, { scrollHeight: 2000, clientHeight: 500, scrollTop: 1500 }); - - // Simulate the user having scrolled the long session to the bottom. - panesEl.dispatchEvent(new Event('scroll')); - await nextTick(); - - // Switch to a much shorter session. The fileReloadKey watcher resets the - // scroll baseline synchronously; dispatch the transient clamping scroll - // event right after setProps resolves but before the async watcher ticks - // (scrollKey / scheduleStableFollow) run and overwrite lastScrollTop. - await wrapper.setProps({ - fileReloadKey: 'sess-short', - turns: [{ id: 't1', role: 'user' as const, no: 1, text: 'hi' }], - }); - - // Transient geometry: scrollHeight still large, scrollTop clamped to 0. - mockPaneGeometry(panesEl, { scrollHeight: 2000, clientHeight: 500, scrollTop: 0 }); - panesEl.dispatchEvent(new Event('scroll')); - - // Now let the async watcher ticks run. - await nextTick(); - - // New session finally settles to short geometry. - mockPaneGeometry(panesEl, { scrollHeight: 300, clientHeight: 500, scrollTop: 0 }); - await nextTick(); - - // Let scheduleStableFollow run its rAF ticks. - vi.advanceTimersByTime(200); - await nextTick(); - - expect(panesEl.scrollTop).toBe(300); - }); -}); diff --git a/apps/kimi-web/test/dangling-tool-spinner.test.ts b/apps/kimi-web/test/dangling-tool-spinner.test.ts deleted file mode 100644 index a78d9592a7..0000000000 --- a/apps/kimi-web/test/dangling-tool-spinner.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { messagesToTurns } from '../src/composables/messagesToTurns'; -import type { AppMessage } from '../src/api/types'; - -const now = '2026-06-11T00:00:00.000Z'; - -// An assistant turn whose final tool call never received a matching toolResult -// (a result frame dropped on a reconnect / ordering race). The tool is the last -// thing in the conversation, so it lands in the FINAL group. -function messagesWithDanglingTool(): AppMessage[] { - return [ - { - id: 'a1', - sessionId: 's1', - role: 'assistant', - promptId: 'pr_1', - createdAt: now, - content: [ - { type: 'text', text: 'reading the file' }, - { type: 'toolUse', toolCallId: 'tc_1', toolName: 'read_file', input: { path: 'a.ts' } }, - ], - }, - ]; -} - -describe('dangling tool spinner', () => { - it('keeps the final tool spinning while the session is active', () => { - const turns = messagesToTurns(messagesWithDanglingTool(), [], undefined, true); - const tool = turns.at(-1)!.tools![0]!; - expect(tool.status).toBe('running'); - }); - - it('settles the final tool once the session is idle', () => { - const turns = messagesToTurns(messagesWithDanglingTool(), [], undefined, false); - const tool = turns.at(-1)!.tools![0]!; - expect(tool.status).toBe('ok'); - }); - - it('still resolves a tool that did get its result, regardless of activity', () => { - const msgs: AppMessage[] = [ - ...messagesWithDanglingTool(), - { - id: 't1', - sessionId: 's1', - role: 'tool', - promptId: 'pr_1', - createdAt: now, - content: [{ type: 'toolResult', toolCallId: 'tc_1', output: 'done', isError: false }], - }, - ]; - const turns = messagesToTurns(msgs, [], undefined, true); - expect(turns.at(-1)!.tools![0]!.status).toBe('ok'); - }); -}); diff --git a/apps/kimi-web/test/debug-trace.test.ts b/apps/kimi-web/test/debug-trace.test.ts deleted file mode 100644 index 8da1dfe168..0000000000 --- a/apps/kimi-web/test/debug-trace.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -// apps/kimi-web/test/debug-trace.test.ts -// -// KAP debug trace: the side-channel recording of REST calls and WS frames. -// Drives the REAL DaemonHttpClient (stubbed fetch) and DaemonEventSocket -// (stubbed WebSocket) and asserts what a user would see in the debug panel: -// request/response/error entries, redacted secrets, truncated payloads, -// bounded buffer, JSONL export. - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { DaemonHttpClient } from '../src/api/daemon/http'; -import { DaemonEventSocket, type DaemonEventSocketHandlers } from '../src/api/daemon/ws'; -import { - clearTrace, - installClientErrorCapture, - sanitizeForTrace, - traceEntries, - traceToJsonl, - traceWsIn, -} from '../src/debug/trace'; - -function okEnvelope(data: unknown): Response { - return new Response( - JSON.stringify({ code: 0, msg: 'ok', data, request_id: 'req_env_1' }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); -} - -function errEnvelope(code: number, msg: string): Response { - return new Response( - JSON.stringify({ code, msg, data: null, request_id: 'req_env_2' }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); -} - -beforeEach(() => { - // Opt the trace in the way a user would (the localStorage switch). - localStorage.setItem('kimi-web.debug', '1'); - clearTrace(); -}); - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe('client-side error capture', () => { - it('folds console.error into the trace so the export includes app errors', () => { - const original = console.error; - installClientErrorCapture(); - try { - console.error('render failed', new Error('boom')); - } finally { - console.error = original; // undo the install-once wrap for other tests - } - const entry = traceEntries().find((e) => e.kind === 'client:error'); - expect(entry).toBeDefined(); - expect(entry!.source).toBe('client'); - expect(entry!.label).toContain('render failed'); - // The exported JSONL carries the client entry alongside network traffic. - expect(traceToJsonl().includes('"client:error"')).toBe(true); - }); -}); - -describe('REST tracing via DaemonHttpClient', () => { - it('records request + response with envelope code, status, duration and requestId', async () => { - vi.stubGlobal('fetch', vi.fn(async () => okEnvelope({ id: 'ses_1' }))); - const http = new DaemonHttpClient('http://example.test:58627'); - - await http.post('/sessions', { metadata: { cwd: '/repo' } }); - - const entries = traceEntries(); - const request = entries.find((e) => e.kind === 'rest:request'); - const response = entries.find((e) => e.kind === 'rest:response'); - expect(request).toBeDefined(); - expect(request!.method).toBe('POST'); - expect(request!.path).toBe('/sessions'); - expect(request!.requestId).toMatch(/./); - expect(response).toBeDefined(); - expect(response!.status).toBe(200); - expect(response!.code).toBe(0); - expect(typeof response!.durationMs).toBe('number'); - expect(response!.requestId).toBe(request!.requestId); - const detail = response!.detail as { envelope: { request_id: string } }; - expect(detail.envelope.request_id).toBe('req_env_1'); - }); - - it('sends client identity headers when configured', async () => { - const fetchMock = vi.fn(async () => okEnvelope({ id: 'ses_1' })); - vi.stubGlobal('fetch', fetchMock); - const http = new DaemonHttpClient('http://example.test:58627', { - clientId: 'web_test_client', - clientName: 'kimi-code-web', - clientVersion: '0.1.1', - clientUiMode: 'web', - }); - - await http.post('/sessions', { metadata: { cwd: '/repo' } }); - - const init = fetchMock.mock.calls[0]![1] as RequestInit; - const headers = init.headers as Record; - expect(headers['X-Kimi-Client-Id']).toBe('web_test_client'); - expect(headers['X-Kimi-Client-Name']).toBe('kimi-code-web'); - expect(headers['X-Kimi-Client-Version']).toBe('0.1.1'); - expect(headers['X-Kimi-Client-Ui-Mode']).toBe('web'); - }); - - it('redacts sensitive request fields (api_key / authorization)', async () => { - vi.stubGlobal('fetch', vi.fn(async () => okEnvelope({}))); - const http = new DaemonHttpClient('http://example.test:58627'); - - await http.post('/providers', { api_key: 'YOUR_API_KEY', authorization: 'Bearer x' }); - - const request = traceEntries().find((e) => e.kind === 'rest:request'); - const body = (request!.detail as { body: Record }).body; - expect(body['api_key']).toBe('[redacted]'); - expect(body['authorization']).toBe('[redacted]'); - }); - - it('records a daemon API error (non-zero envelope code) as rest:error', async () => { - vi.stubGlobal('fetch', vi.fn(async () => errEnvelope(40401, 'session does not exist'))); - const http = new DaemonHttpClient('http://example.test:58627'); - - await expect(http.get('/sessions/ses_x')).rejects.toThrow(); - - const entry = traceEntries().find((e) => e.kind === 'rest:error'); - expect(entry).toBeDefined(); - expect(entry!.code).toBe(40401); - expect(entry!.label).toContain('session does not exist'); - }); - - it('records a network failure with its phase', async () => { - vi.stubGlobal('fetch', vi.fn(async () => Promise.reject(new TypeError('Failed to fetch')))); - const http = new DaemonHttpClient('http://example.test:58627'); - - await expect(http.get('/healthz')).rejects.toThrow(); - - const entry = traceEntries().find((e) => e.kind === 'rest:error'); - expect(entry).toBeDefined(); - expect((entry!.detail as { phase: string }).phase).toBe('fetch'); - }); - - it('records a JSON parse failure with HTTP status', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response('busy', { status: 502 }))); - const http = new DaemonHttpClient('http://example.test:58627'); - - await expect(http.get('/healthz')).rejects.toThrow(); - - const entry = traceEntries().find((e) => e.kind === 'rest:error'); - expect(entry).toBeDefined(); - expect(entry!.status).toBe(502); - expect((entry!.detail as { phase: string }).phase).toBe('parse'); - }); -}); - -describe('WS tracing via DaemonEventSocket', () => { - class FakeWebSocket { - static OPEN = 1; - static last: FakeWebSocket | null = null; - onopen: (() => void) | null = null; - onmessage: ((ev: { data: string }) => void) | null = null; - onerror: (() => void) | null = null; - onclose: ((ev?: { code: number; reason: string; wasClean: boolean }) => void) | null = null; - readyState = 1; - sent: string[] = []; - constructor(public url: string) { - FakeWebSocket.last = this; - } - send(data: string): void { - this.sent.push(data); - } - close(): void {} - } - - const handlers: DaemonEventSocketHandlers = { - onWireEvent: () => {}, - onRawAgentEvent: () => {}, - onResync: () => {}, - onConnectionState: () => {}, - onError: () => {}, - }; - - it('records lifecycle, handshake frames and event frames with session/seq/offset', () => { - vi.stubGlobal('WebSocket', FakeWebSocket); - const socket = new DaemonEventSocket('ws://example.test/ws', 'client_1', handlers); - socket.subscribe('ses_1', { seq: 0 }); - socket.connect(); - const fake = FakeWebSocket.last!; - fake.onopen?.(); - fake.onmessage?.({ data: JSON.stringify({ type: 'server_hello', payload: {} }) }); - fake.onmessage?.({ - data: JSON.stringify({ - type: 'message.delta', - session_id: 'ses_1', - seq: 7, - offset: 3, - timestamp: '2026-06-12T00:00:00Z', - payload: { delta: 'hi' }, - }), - }); - fake.onclose?.({ code: 1006, reason: 'gone', wasClean: false }); - socket.close(); - - const entries = traceEntries(); - const kinds = entries.map((e) => `${e.kind}:${e.eventType ?? ''}`); - expect(kinds).toContain('ws:lifecycle:connect'); - expect(kinds).toContain('ws:lifecycle:open'); - expect(kinds).toContain('ws:in:server_hello'); - expect(kinds).toContain('ws:out:client_hello'); - expect(kinds).toContain('ws:lifecycle:close'); - expect(kinds).toContain('ws:lifecycle:reconnect-scheduled'); - - const event = entries.find((e) => e.eventType === 'message.delta'); - expect(event).toBeDefined(); - expect(event!.sessionId).toBe('ses_1'); - expect(event!.seq).toBe(7); - expect(event!.offset).toBe(3); - - const hello = entries.find((e) => e.kind === 'ws:out' && e.eventType === 'client_hello'); - const helloDetail = hello!.detail as { payload: { subscriptions: string[] } }; - expect(helloDetail.payload.subscriptions).toContain('ses_1'); - }); -}); - -describe('sanitization + buffer bounds + export', () => { - it('truncates long strings and elides base64-like blobs', () => { - const long = 'lorem ipsum '.repeat(200); // 2400 chars, with spaces (not base64-like) - const b64 = 'A'.repeat(300); - const out = sanitizeForTrace({ text: long, image: b64 }) as Record; - expect(out['text']!.length).toBeLessThan(600); - expect(out['text']).toContain('[+1900 chars]'); - expect(out['image']).toContain('base64-like'); - }); - - it('keeps at most 1000 entries (ring buffer)', () => { - for (let i = 0; i < 1100; i++) { - traceWsIn({ type: 'ping', payload: { nonce: i } }); - } - expect(traceEntries().length).toBe(1000); - // Oldest entries dropped — the first kept nonce is 100. - const first = traceEntries()[0]!.detail as { nonce: number }; - expect(first.nonce).toBe(100); - }); - - it('exports JSONL that parses back into entries', () => { - traceWsIn({ type: 'ping', payload: { nonce: 1 } }); - const jsonl = traceToJsonl(); - const lines = jsonl.split('\n'); - expect(lines.length).toBe(traceEntries().length); - const parsed = JSON.parse(lines[0]!) as { kind: string }; - expect(parsed.kind).toBe('ws:in'); - }); -}); diff --git a/apps/kimi-web/test/diff-view.test.ts b/apps/kimi-web/test/diff-view.test.ts deleted file mode 100644 index 11e6bcd8af..0000000000 --- a/apps/kimi-web/test/diff-view.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { createI18n } from 'vue-i18n'; -import { describe, expect, it } from 'vitest'; -import { nextTick } from 'vue'; -import DiffView from '../src/components/DiffView.vue'; - -const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { - en: { - diff: { - title: 'Changes', - branch: 'branch', - aheadTitle: 'ahead', - behindTitle: 'behind', - changeCount: '{count} changes', - empty: 'No git changes', - clean: 'Working tree clean', - back: 'Back', - loading: 'Loading…', - noDiff: 'No diff', - list: 'List', - tree: 'Tree', - close: 'Close', - }, - }, - }, - missingWarn: false, - fallbackWarn: false, -}); - -function mountDiff(props: Record = {}) { - return mount(DiffView, { - props: { - changes: [], - gitInfo: { branch: 'main', ahead: 0, behind: 0 }, - ...props, - }, - global: { plugins: [i18n] }, - }); -} - -describe('DiffView', () => { - it('renders a header with title, change count, and close button', async () => { - const wrapper = mountDiff({ - changes: [ - { path: 'src/a.ts', status: 'modified' }, - { path: 'src/b.ts', status: 'added' }, - ], - }); - await nextTick(); - - expect(wrapper.find('.dv-panel-head').exists()).toBe(true); - expect(wrapper.find('.dv-title').text()).toBe('Changes'); - expect(wrapper.find('.dv-change-count').text()).toBe('2 changes'); - - await wrapper.find('.dv-close').trigger('click'); - expect(wrapper.emitted('close')).toHaveLength(1); - }); - - it('renders a flat list of changed files and emits open on click', async () => { - const wrapper = mountDiff({ - changes: [{ path: 'src/a.ts', status: 'modified' }], - }); - await nextTick(); - - const rows = wrapper.findAll('.ch-row'); - expect(rows).toHaveLength(1); - expect(rows[0]!.find('.fpath').text()).toContain('src/a.ts'); - - await rows[0]!.trigger('click'); - expect(wrapper.emitted('open')).toEqual([['src/a.ts']]); - }); - - it('switches to tree view and renders folders and files', async () => { - const wrapper = mountDiff({ - changes: [ - { path: 'src/a.ts', status: 'modified' }, - { path: 'src/b.ts', status: 'added' }, - { path: 'test/c.test.ts', status: 'deleted' }, - ], - }); - await nextTick(); - - await wrapper.findAll('.dv-toggle-btn')[1]!.trigger('click'); - await nextTick(); - - const folders = wrapper.findAll('.tree-folder'); - const files = wrapper.findAll('.tree-file'); - expect(folders.length).toBeGreaterThanOrEqual(2); - expect(files.length).toBe(3); - - // Clicking a file emits open with its full path. - await files[0]!.trigger('click'); - expect(wrapper.emitted('open')?.[0]).toEqual([expect.stringContaining('.ts')]); - }); - - it('toggles folder expansion to show/hide children', async () => { - const wrapper = mountDiff({ - changes: [ - { path: 'src/nested/a.ts', status: 'modified' }, - ], - }); - await nextTick(); - - await wrapper.findAll('.dv-toggle-btn')[1]!.trigger('click'); - await nextTick(); - - const folders = wrapper.findAll('.tree-folder'); - expect(folders.length).toBeGreaterThan(0); - - const initialFiles = wrapper.findAll('.tree-file').length; - await folders[0]!.trigger('click'); - await nextTick(); - - expect(wrapper.findAll('.tree-file').length).toBeLessThan(initialFiles); - }); -}); diff --git a/apps/kimi-web/test/file-preview.test.ts b/apps/kimi-web/test/file-preview.test.ts deleted file mode 100644 index 384c43413c..0000000000 --- a/apps/kimi-web/test/file-preview.test.ts +++ /dev/null @@ -1,329 +0,0 @@ -// apps/kimi-web/test/file-preview.test.ts -// -// File preview scroll behaviour: opening a file at a specific line should land -// on that line without an unexpected upward jump when the component is reused -// (e.g. switching from one file preview to another in the split-pane preview). - -import { mount } from '@vue/test-utils'; -import { createI18n } from 'vue-i18n'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { nextTick } from 'vue'; - -import FilePreview, { type FileData } from '../src/components/FilePreview.vue'; -import enFilePreview from '../src/i18n/locales/en/filePreview'; - -const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { en: { filePreview: enFilePreview } }, - missingWarn: false, - fallbackWarn: false, -}); - -function makeFile(path: string, content: string, lineCount: number): FileData { - return { - path, - content, - encoding: 'utf-8', - mime: 'text/plain', - isBinary: false, - size: content.length, - lineCount, - }; -} - -function mockScrollGeometry( - bodyEl: HTMLElement, - lineEl: HTMLElement, - options: { - bodyClientHeight: number; - lineOffsetTop: number; - lineHeight: number; - currentScrollTop?: number; - }, -): void { - const { bodyClientHeight, lineOffsetTop, lineHeight, currentScrollTop = 0 } = options; - Object.defineProperty(bodyEl, 'clientHeight', { - configurable: true, - get: () => bodyClientHeight, - }); - Object.defineProperty(bodyEl, 'scrollTop', { - configurable: true, - writable: true, - value: currentScrollTop, - }); - Object.defineProperty(lineEl, 'offsetTop', { - configurable: true, - get: () => lineOffsetTop, - }); - vi.spyOn(bodyEl, 'getBoundingClientRect').mockReturnValue({ - top: 0, - left: 0, - right: 0, - bottom: bodyClientHeight, - width: 0, - height: bodyClientHeight, - x: 0, - y: 0, - toJSON: () => '', - }); - vi.spyOn(lineEl, 'getBoundingClientRect').mockReturnValue({ - top: lineOffsetTop - currentScrollTop, - left: 0, - right: 0, - bottom: lineOffsetTop - currentScrollTop + lineHeight, - width: 0, - height: lineHeight, - x: 0, - y: lineOffsetTop - currentScrollTop, - toJSON: () => '', - }); -} - -describe('FilePreview scroll-to-line', () => { - beforeEach(() => { - document.body.innerHTML = ''; - }); - afterEach(() => { - document.body.innerHTML = ''; - vi.restoreAllMocks(); - }); - - it('centers the requested line when first opening a file', async () => { - const content = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join('\n'); - const wrapper = mount(FilePreview, { - props: { file: makeFile('a.txt', content, 20), loading: false, line: 5 }, - global: { plugins: [i18n] }, - attachTo: document.body, - }); - await nextTick(); - - const bodyEl = wrapper.find('.fp-body').element as HTMLElement; - const lineEl = bodyEl.querySelector('[data-line="5"]') as HTMLElement; - expect(lineEl).not.toBeNull(); - - mockScrollGeometry(bodyEl, lineEl, { bodyClientHeight: 100, lineOffsetTop: 80, lineHeight: 20 }); - - // Trigger the watcher again now that mocked geometry is in place. - await wrapper.setProps({ file: makeFile('a.txt', content, 20), line: 5 }); - await nextTick(); - - // Centered: line top (80) - body/2 (50) + line/2 (10) = 40 - expect(bodyEl.scrollTop).toBe(40); - }); - - it('resets scroll when switching to a different file so the new target line does not jump up from a stale position', async () => { - const contentA = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join('\n'); - const contentB = Array.from({ length: 20 }, (_, i) => `other ${i + 1}`).join('\n'); - - const wrapper = mount(FilePreview, { - props: { file: makeFile('a.txt', contentA, 20), loading: false, line: 10 }, - global: { plugins: [i18n] }, - attachTo: document.body, - }); - await nextTick(); - - const bodyEl = wrapper.find('.fp-body').element as HTMLElement; - const lineElA = bodyEl.querySelector('[data-line="10"]') as HTMLElement; - mockScrollGeometry(bodyEl, lineElA, { - bodyClientHeight: 100, - lineOffsetTop: 180, - lineHeight: 20, - }); - await wrapper.setProps({ file: makeFile('a.txt', contentA, 20), line: 10 }); - await nextTick(); - expect(bodyEl.scrollTop).toBe(140); // 180 - 50 + 10 - - // Simulate the user (or a prior file) having scrolled mid-content. - bodyEl.scrollTop = 500; - - // Switch to a different file at an early line. - await wrapper.setProps({ file: makeFile('b.txt', contentB, 20), line: 2 }); - await nextTick(); - - const lineElB = bodyEl.querySelector('[data-line="2"]') as HTMLElement; - mockScrollGeometry(bodyEl, lineElB, { - bodyClientHeight: 100, - lineOffsetTop: 20, - lineHeight: 20, - currentScrollTop: 0, - }); - await nextTick(); - - // Reset + centered: 20 - 50 + 10 = -20, clamped to 0 by the browser. - expect(bodyEl.scrollTop).toBe(0); - }); -}); - -describe('FilePreview markdown', () => { - beforeEach(() => { - Object.defineProperty(window, 'matchMedia', { - writable: true, - value: vi.fn().mockImplementation((query: string) => ({ - matches: false, - media: query, - onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - })), - }); - }); - - afterEach(() => { - document.body.innerHTML = ''; - vi.restoreAllMocks(); - }); - - function markdownFile(path: string, content: string): FileData { - return { - path, - content, - encoding: 'utf-8', - mime: 'text/markdown', - isBinary: false, - size: content.length, - lineCount: content.split('\n').length, - }; - } - - it('renders Markdown as rich preview by default', async () => { - const wrapper = mount(FilePreview, { - props: { file: markdownFile('README.md', '# Hello\n\nWorld'), loading: false }, - global: { plugins: [i18n] }, - attachTo: document.body, - }); - await nextTick(); - - expect(wrapper.find('.fp-markdown').exists()).toBe(true); - expect(wrapper.find('.fp-markdown').text()).toContain('Hello'); - expect(wrapper.find('.fp-code').exists()).toBe(false); - }); - - it('toggles to source view and back', async () => { - const wrapper = mount(FilePreview, { - props: { file: markdownFile('README.md', '# Hello'), loading: false }, - global: { plugins: [i18n] }, - attachTo: document.body, - }); - await nextTick(); - - const buttons = wrapper.findAll('.fp-seg-btn'); - expect(buttons.map((b) => b.text())).toEqual(['Preview', 'Source']); - - await buttons[1]!.trigger('click'); - await nextTick(); - - expect(wrapper.find('.fp-code').exists()).toBe(true); - expect(wrapper.find('.fp-code').text()).toContain('# Hello'); - - await buttons[0]!.trigger('click'); - await nextTick(); - - expect(wrapper.find('.fp-markdown').exists()).toBe(true); - }); - - it('recognises .mdx files as markdown', async () => { - const wrapper = mount(FilePreview, { - props: { file: { ...markdownFile('page.mdx', '# MDX'), mime: 'text/plain' }, loading: false }, - global: { plugins: [i18n] }, - attachTo: document.body, - }); - await nextTick(); - - expect(wrapper.find('.fp-seg-btn').exists()).toBe(true); - }); - - it('resolves a Markdown relative link against the current file directory', async () => { - const openFile = vi.fn(); - const wrapper = mount(FilePreview, { - props: { - file: markdownFile('docs/guide/page.md', 'See [other](../other.md).'), - loading: false, - openFile, - }, - global: { plugins: [i18n] }, - attachTo: document.body, - }); - await nextTick(); - await nextTick(); - - const link = wrapper.find('.fp-markdown a[href="../other.md"]'); - expect(link.exists()).toBe(true); - await link.trigger('click'); - await nextTick(); - - expect(openFile).toHaveBeenCalledWith({ path: 'docs/other.md' }); - }); - - it('resolves a Markdown relative image against the current file directory', async () => { - const resolveImage = vi.fn(async (src: string) => `resolved:${src}`); - const wrapper = mount(FilePreview, { - props: { - file: markdownFile('docs/guide/page.md', '![img](../img.png)'), - loading: false, - }, - global: { - plugins: [i18n], - provide: { resolveImage }, - }, - attachTo: document.body, - }); - await nextTick(); - await nextTick(); - - expect(resolveImage).toHaveBeenCalledWith('docs/img.png'); - }); - - it('strips fragment and query from Markdown file links before opening', async () => { - const openFile = vi.fn(); - const wrapper = mount(FilePreview, { - props: { - file: markdownFile('docs/page.md', 'See [a](guide.md#section) and [b](other.md?x=1).'), - loading: false, - openFile, - }, - global: { plugins: [i18n] }, - attachTo: document.body, - }); - await nextTick(); - await nextTick(); - - const linkA = wrapper.find('.fp-markdown a[href="guide.md#section"]'); - const linkB = wrapper.find('.fp-markdown a[href="other.md?x=1"]'); - expect(linkA.exists()).toBe(true); - expect(linkB.exists()).toBe(true); - - await linkA.trigger('click'); - await nextTick(); - await linkB.trigger('click'); - await nextTick(); - - expect(openFile).toHaveBeenNthCalledWith(1, { path: 'docs/guide.md' }); - expect(openFile).toHaveBeenNthCalledWith(2, { path: 'docs/other.md' }); - }); - - it('does not intercept pure anchor links', async () => { - const openFile = vi.fn(); - const wrapper = mount(FilePreview, { - props: { - file: markdownFile('docs/page.md', 'Jump to [section](#section).'), - loading: false, - openFile, - }, - global: { plugins: [i18n] }, - attachTo: document.body, - }); - await nextTick(); - await nextTick(); - - const link = wrapper.find('.fp-markdown a[href="#section"]'); - expect(link.exists()).toBe(true); - await link.trigger('click'); - await nextTick(); - - expect(openFile).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/kimi-web/test/filePathLinks.test.ts b/apps/kimi-web/test/filePathLinks.test.ts deleted file mode 100644 index abc678405a..0000000000 --- a/apps/kimi-web/test/filePathLinks.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { collectFilePathAliases, findFilePathLinks, parseFilePathLinkCandidate } from '../src/lib/filePathLinks'; - -describe('file path links', () => { - it('parses relative paths with line numbers', () => { - expect(parseFilePathLinkCandidate('apps/kimi-web/src/App.vue:23')).toEqual({ - path: 'apps/kimi-web/src/App.vue', - line: 23, - }); - expect(parseFilePathLinkCandidate('src/foo.ts#L9')).toEqual({ - path: 'src/foo.ts', - line: 9, - }); - }); - - it('parses common root filenames', () => { - expect(parseFilePathLinkCandidate('package.json')).toEqual({ path: 'package.json' }); - expect(parseFilePathLinkCandidate('AGENTS.md')).toEqual({ path: 'AGENTS.md' }); - }); - - it('ignores bare asset filenames that are not reliable workspace paths', () => { - expect(parseFilePathLinkCandidate('before.png')).toBeNull(); - expect(parseFilePathLinkCandidate('e2e-success.png')).toBeNull(); - expect(findFilePathLinks('Other images: before.png, e2e-success.png.')).toEqual([]); - }); - - it('uses same-message absolute path aliases for displayed asset filenames', () => { - const aliases = collectFilePathAliases(''); - expect(findFilePathLinks('Displayed before.png.', { aliases })).toEqual([ - { - path: '/Users/moonshot/Downloads/before.png', - line: undefined, - start: 10, - end: 20, - text: 'before.png', - }, - ]); - }); - - it('ignores URLs and non-path words', () => { - expect(parseFilePathLinkCandidate('https://example.com/a.ts')).toBeNull(); - expect(parseFilePathLinkCandidate('hello')).toBeNull(); - }); - - it('ignores branch-like slash names without file extensions', () => { - expect(parseFilePathLinkCandidate('feat/web')).toBeNull(); - expect(findFilePathLinks('commit db8d21cd on feat/web.')).toEqual([]); - }); - - it('finds multiple links in message text', () => { - expect(findFilePathLinks('See apps/kimi-web/src/App.vue:11 and package.json.')).toEqual([ - { - path: 'apps/kimi-web/src/App.vue', - line: 11, - start: 4, - end: 32, - text: 'apps/kimi-web/src/App.vue:11', - }, - { - path: 'package.json', - line: undefined, - start: 37, - end: 49, - text: 'package.json', - }, - ]); - }); -}); diff --git a/apps/kimi-web/test/formatMessageTime.test.ts b/apps/kimi-web/test/formatMessageTime.test.ts deleted file mode 100644 index e2621cabd4..0000000000 --- a/apps/kimi-web/test/formatMessageTime.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { formatMessageTime } from '../src/lib/formatMessageTime'; - -// Build an ISO string for a given local date/time so tests are not sensitive -// to the runner's time zone offset. -function localIso(year: number, month: number, day: number, hour = 0, minute = 0): string { - return new Date(year, month - 1, day, hour, minute).toISOString(); -} - -describe('formatMessageTime', () => { - it('returns time only for today', () => { - vi.setSystemTime(new Date(2026, 5, 15, 14, 32)); - const iso = localIso(2026, 6, 15, 9, 0); - expect(formatMessageTime(iso)).toBe('09:00'); - }); - - it('returns yesterday label with time', () => { - vi.setSystemTime(new Date(2026, 5, 15, 14, 32)); - const iso = localIso(2026, 6, 14, 9, 0); - expect(formatMessageTime(iso)).toBe('昨天 09:00'); - }); - - it('returns month-day time for earlier this year', () => { - vi.setSystemTime(new Date(2026, 5, 15, 14, 32)); - const iso = localIso(2026, 5, 1, 9, 0); - expect(formatMessageTime(iso)).toBe('05-01 09:00'); - }); - - it('returns full date time for previous year', () => { - vi.setSystemTime(new Date(2026, 5, 15, 14, 32)); - const iso = localIso(2025, 12, 31, 9, 0); - expect(formatMessageTime(iso)).toBe('2025-12-31 09:00'); - }); - - it('uses custom yesterday label', () => { - vi.setSystemTime(new Date(2026, 5, 15, 14, 32)); - const iso = localIso(2026, 6, 14, 9, 0); - expect(formatMessageTime(iso, 'Yesterday')).toBe('Yesterday 09:00'); - }); - - it('falls back to raw string on invalid date', () => { - expect(formatMessageTime('not-a-date')).toBe('not-a-date'); - }); -}); diff --git a/apps/kimi-web/test/latestTodos.test.ts b/apps/kimi-web/test/latestTodos.test.ts deleted file mode 100644 index 1ecf755641..0000000000 --- a/apps/kimi-web/test/latestTodos.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -// apps/kimi-web/test/latestTodos.test.ts -// -// The floating todo card shows the CURRENT list: every TodoList write carries -// the full list, [] clears it, and a call without `todos` is a read-only -// query. These tests pin that derivation from a real transcript shape. - -import { describe, expect, it } from 'vitest'; -import type { AppMessage } from '../src/api/types'; -import { latestTodos } from '../src/composables/latestTodos'; - -let n = 0; -function assistantToolUse(toolName: string, input: unknown): AppMessage { - n += 1; - return { - id: `msg_${n}`, - sessionId: 'sess_1', - role: 'assistant', - content: [{ type: 'toolUse', toolCallId: `t${n}`, toolName, input }], - createdAt: new Date().toISOString(), - }; -} - -describe('latestTodos', () => { - it('returns the newest full-list write', () => { - const msgs = [ - assistantToolUse('TodoList', { todos: [{ title: '旧任务', status: 'pending' }] }), - assistantToolUse('TodoList', { - todos: [ - { title: '改投影层', status: 'done' }, - { title: '加卡片组件', status: 'in_progress' }, - { title: '补测试', status: 'pending' }, - ], - }), - ]; - expect(latestTodos(msgs)).toEqual([ - { title: '改投影层', status: 'done' }, - { title: '加卡片组件', status: 'in_progress' }, - { title: '补测试', status: 'pending' }, - ]); - }); - - it('ignores read-only queries (no todos field) and falls back to the last write', () => { - const msgs = [ - assistantToolUse('TodoList', { todos: [{ title: 'A', status: 'pending' }] }), - assistantToolUse('TodoList', {}), - ]; - expect(latestTodos(msgs)).toEqual([{ title: 'A', status: 'pending' }]); - }); - - it('an empty-array write clears the list', () => { - const msgs = [ - assistantToolUse('TodoList', { todos: [{ title: 'A', status: 'pending' }] }), - assistantToolUse('TodoList', { todos: [] }), - ]; - expect(latestTodos(msgs)).toEqual([]); - }); - - it('accepts alias tool names, string input and TodoWrite-style items', () => { - const msgs = [ - assistantToolUse( - 'TodoWrite', - JSON.stringify({ todos: [{ content: 'B', status: 'completed' }] }), - ), - ]; - expect(latestTodos(msgs)).toEqual([{ title: 'B', status: 'done' }]); - }); - - it('returns [] when no todo tool was ever called', () => { - expect(latestTodos([assistantToolUse('bash', { command: 'ls' })])).toEqual([]); - }); -}); diff --git a/apps/kimi-web/test/lib-logic.test.ts b/apps/kimi-web/test/lib-logic.test.ts new file mode 100644 index 0000000000..02a2392b82 --- /dev/null +++ b/apps/kimi-web/test/lib-logic.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { + collectFilePathAliases, + findFilePathLinks, + parseFilePathLinkCandidate, +} from '../src/lib/filePathLinks'; +import { parseDiff } from '../src/lib/parseDiff'; +import { normalizeToolName, toolSummary } from '../src/lib/toolMeta'; + +describe('parseDiff', () => { + it('parses multiple files and keeps hunk line numbers', () => { + const diff = [ + 'diff --git a/src/a.ts b/src/a.ts', + 'index 1111111..2222222 100644', + '--- a/src/a.ts', + '+++ b/src/a.ts', + '@@ -1,2 +1,3 @@', + ' const a = 1;', + '-const b = 2;', + '+const b = 3;', + '+const c = 4;', + 'diff --git a/src/comment.sql b/src/comment.sql', + '@@ -5,1 +5,1 @@', + '--- old comment', + '+++ new comment', + ].join('\n'); + + expect(parseDiff(diff)).toEqual([ + { type: 'hunk', text: '@@ -1,2 +1,3 @@' }, + { type: 'context', text: 'const a = 1;', oldNo: 1, newNo: 1 }, + { type: 'del', text: 'const b = 2;', oldNo: 2 }, + { type: 'add', text: 'const b = 3;', newNo: 2 }, + { type: 'add', text: 'const c = 4;', newNo: 3 }, + { type: 'hunk', text: '@@ -5,1 +5,1 @@' }, + { type: 'del', text: '-- old comment', oldNo: 5 }, + { type: 'add', text: '++ new comment', newNo: 5 }, + ]); + }); +}); + +describe('filePathLinks', () => { + it('rejects URLs and bare unknown filenames', () => { + expect(parseFilePathLinkCandidate('https://example.com/a.ts')).toBeNull(); + expect(parseFilePathLinkCandidate('e2e-success.png')).toBeNull(); + }); + + it('finds path links with line numbers and resolves aliases', () => { + const aliases = collectFilePathAliases(''); + expect(aliases.get('demo.png')).toBe('/assets/demo.png'); + + expect( + findFilePathLinks('Open src/a.ts#L12 and demo.png.', { aliases }), + ).toMatchObject([ + { path: 'src/a.ts', line: 12, text: 'src/a.ts#L12' }, + { path: '/assets/demo.png', text: 'demo.png' }, + ]); + }); +}); + +describe('toolMeta', () => { + it('normalizes common tool aliases', () => { + expect(normalizeToolName('WebFetch')).toBe('web_fetch'); + expect(normalizeToolName('MultiEdit')).toBe('multi_edit'); + expect(normalizeToolName('TodoWrite')).toBe('todo'); + expect(normalizeToolName('rg')).toBe('grep'); + }); + + it('summarizes tool arguments for card headers', () => { + expect( + toolSummary('Read', JSON.stringify({ path: 'src/a.ts', offset: 10, limit: 5 })), + ).toBe('src/a.ts:10-15'); + expect(toolSummary('Read', '{}')).toBe(''); + expect(toolSummary('Bash', JSON.stringify({ command: 'pnpm test' }))).toBe('pnpm test'); + expect( + toolSummary('WebFetch', JSON.stringify({ url: 'https://example.com/path/to' })), + ).toBe('example.com/path'); + }); +}); diff --git a/apps/kimi-web/test/markdown-performance.test.ts b/apps/kimi-web/test/markdown-performance.test.ts deleted file mode 100644 index a2e691fc79..0000000000 --- a/apps/kimi-web/test/markdown-performance.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { markdownRenderPlan } from '../src/lib/markdownPerformance'; - -describe('markdown render plan', () => { - it('keeps normal code blocks highlighted', () => { - const plan = markdownRenderPlan('```ts\nconst ok = true;\n```'); - expect(plan.codeRenderer).toBe('shiki'); - expect(plan.codeFenceCount).toBe(1); - }); - - it('uses plain pre rendering for one very large code block', () => { - const plan = markdownRenderPlan(`\`\`\`txt\n${'x'.repeat(31_000)}\n\`\`\``); - expect(plan.codeRenderer).toBe('pre'); - }); - - it('uses plain pre rendering when many code blocks mount together', () => { - const blocks = Array.from({ length: 33 }, (_, i) => `\`\`\`ts\nconst n${i} = ${i};\n\`\`\``).join('\n'); - const plan = markdownRenderPlan(blocks); - expect(plan.codeRenderer).toBe('pre'); - }); - - it('uses plain pre rendering for very large messages', () => { - const plan = markdownRenderPlan(`intro\n\n${'text\n'.repeat(24_000)}`); - expect(plan.codeRenderer).toBe('pre'); - }); -}); diff --git a/apps/kimi-web/test/markdown-streaming-placeholders.test.ts b/apps/kimi-web/test/markdown-streaming-placeholders.test.ts deleted file mode 100644 index 9c85f34309..0000000000 --- a/apps/kimi-web/test/markdown-streaming-placeholders.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { mount, type VueWrapper } from '@vue/test-utils'; -import { createI18n } from 'vue-i18n'; -import { nextTick } from 'vue'; -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; -import { MarkdownRender } from 'markstream-vue'; - -import Markdown from '../src/components/Markdown.vue'; - -const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { en: {} }, - missingWarn: false, - fallbackWarn: false, -}); - -let mounted: VueWrapper[] = []; - -beforeAll(() => { - window.matchMedia = vi.fn().mockReturnValue({ - matches: false, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }); -}); - -afterEach(() => { - for (const wrapper of mounted.splice(0)) wrapper.unmount(); -}); - -function visibleByVShow(wrapper: VueWrapper): boolean { - return !/\bdisplay:\s*none\b/.test(wrapper.attributes('style') ?? ''); -} - -function isSettled(wrapper: VueWrapper): boolean { - if (wrapper.findAll('.node-placeholder').length > 0) return false; - const visibleSkeletons = wrapper.findAll('.code-loading-placeholder').filter(visibleByVShow); - if (visibleSkeletons.length > 0) return false; - return wrapper.findAll('[data-node-index]').length > 0; -} - -// Poll until markstream finishes rendering the real nodes. A fixed timeout was -// flaky under full-suite parallel load: markstream's shiki/parse queue can take -// longer than 1s when the CPU is busy, leaving `[data-node-index]` empty. -async function waitForSettled(wrapper: VueWrapper, timeoutMs = 8000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - await nextTick(); - if (isSettled(wrapper)) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - // One last check so the assertion below produces a useful diff on failure. - await nextTick(); -} - -describe('markdown streaming placeholders', () => { - it('keeps settled code blocks mounted instead of viewport-deferred', () => { - const wrapper = mount(Markdown, { - attachTo: document.body, - props: { text: '```ts\nconst ready = true;\n```', streaming: false }, - global: { plugins: [i18n], provide: { resolveImage: undefined } }, - }); - mounted.push(wrapper); - - const renderer = wrapper.findComponent(MarkdownRender); - expect(renderer.exists()).toBe(true); - expect(renderer.props('batchRendering')).toBe(true); - expect(renderer.props('deferNodesUntilVisible')).toBe(false); - }); - - it('does not show markstream placeholders while a large message is streaming', async () => { - const text = Array.from( - { length: 480 }, - (_, i) => `Paragraph ${i}\n\n\`\`\`ts\nconst value${i} = ${i};\n\`\`\``, - ).join('\n\n'); - - const wrapper = mount(Markdown, { - attachTo: document.body, - props: { text, streaming: true }, - global: { plugins: [i18n], provide: { resolveImage: undefined } }, - }); - mounted.push(wrapper); - - await waitForSettled(wrapper); - - expect(wrapper.findAll('.node-placeholder')).toHaveLength(0); - const visibleCodeSkeletons = wrapper.findAll('.code-loading-placeholder').filter(visibleByVShow); - expect(visibleCodeSkeletons).toHaveLength(0); - expect(wrapper.findAll('[data-node-index]').length).toBeGreaterThan(0); - }, 10000); -}); diff --git a/apps/kimi-web/test/model-picker.test.ts b/apps/kimi-web/test/model-picker.test.ts deleted file mode 100644 index 6c4bc13b2d..0000000000 --- a/apps/kimi-web/test/model-picker.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { createI18n } from 'vue-i18n'; -import { afterEach, describe, expect, it } from 'vitest'; - -import ModelPicker from '../src/components/ModelPicker.vue'; -import type { AppModel } from '../src/api/types'; - -const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { - en: { - model: { - allTab: 'All', - close: 'Close', - contextSuffix: '{size}k ctx', - dialogLabel: 'Switch model', - emptyNoMatch: 'No matching models', - emptyNoModels: 'No models', - footerHint: 'Navigate', - loading: 'Loading', - providerTabs: 'Model providers', - searchPlaceholder: 'Search', - title: 'Switch model', - unavailable: 'Unavailable', - }, - }, - }, - missingWarn: false, - fallbackWarn: false, -}); - -const models: AppModel[] = [ - { - id: 'kimi/k2', - provider: 'kimi', - model: 'k2', - displayName: 'Kimi K2', - maxContextSize: 128000, - }, - { - id: 'openai/gpt-5', - provider: 'openai', - model: 'gpt-5', - displayName: 'GPT-5', - maxContextSize: 256000, - }, - { - id: 'openai/gpt-4o', - provider: 'openai', - model: 'gpt-4o', - displayName: 'GPT-4o', - maxContextSize: 128000, - }, -]; - -afterEach(() => { - document.body.innerHTML = ''; -}); - -describe('ModelPicker provider tabs', () => { - it('filters the fixed model list by provider tab', async () => { - const wrapper = mount(ModelPicker, { - props: { - models, - current: 'kimi/k2', - }, - global: { plugins: [i18n] }, - }); - - expect(wrapper.findAll('.model-row')).toHaveLength(3); - - await wrapper.findAll('.tab-btn').find((button) => button.text() === 'openai')!.trigger('click'); - - expect(wrapper.findAll('.model-row')).toHaveLength(2); - expect(wrapper.text()).toContain('GPT-5'); - expect(wrapper.text()).not.toContain('Kimi K2'); - - await wrapper.findAll('.tab-btn').find((button) => button.text() === 'All')!.trigger('click'); - - expect(wrapper.findAll('.model-row')).toHaveLength(3); - }); -}); - -describe('ModelPicker dialog focus', () => { - it('is a modal that focuses the search box and restores focus on close', async () => { - // An opener that "owns" focus before the dialog appears. - const opener = document.createElement('button'); - document.body.appendChild(opener); - opener.focus(); - expect(document.activeElement).toBe(opener); - - const wrapper = mount(ModelPicker, { - props: { models, current: 'kimi/k2' }, - global: { plugins: [i18n] }, - attachTo: document.body, - }); - - const dialog = wrapper.find('.dialog'); - expect(dialog.attributes('aria-modal')).toBe('true'); - - await nextTick(); - // Opening moves focus into the dialog (the search field). - expect(document.activeElement).toBe(wrapper.find('.search-input').element); - - wrapper.unmount(); - await nextTick(); - // Closing returns focus to whoever opened it. - expect(document.activeElement).toBe(opener); - - opener.remove(); - }); -}); - -describe('ModelPicker starred models', () => { - it('pins starred models to the top in the All tab', async () => { - const wrapper = mount(ModelPicker, { - props: { - models, - current: 'kimi/k2', - starredIds: ['openai/gpt-4o'], - }, - global: { plugins: [i18n] }, - }); - - const rows = wrapper.findAll('.model-row'); - expect(rows).toHaveLength(3); - expect(rows[0]!.text()).toContain('GPT-4o'); - expect(rows[1]!.text()).toContain('Kimi K2'); - expect(rows[2]!.text()).toContain('GPT-5'); - }); - - it('does not reorder models inside a provider tab', async () => { - const wrapper = mount(ModelPicker, { - props: { - models, - current: 'kimi/k2', - starredIds: ['openai/gpt-4o'], - }, - global: { plugins: [i18n] }, - }); - - await wrapper.findAll('.tab-btn').find((button) => button.text() === 'openai')!.trigger('click'); - - const rows = wrapper.findAll('.model-row'); - expect(rows).toHaveLength(2); - expect(rows[0]!.text()).toContain('GPT-5'); - expect(rows[1]!.text()).toContain('GPT-4o'); - }); - - it('emits toggle-star when the star button is clicked without selecting the model', async () => { - const wrapper = mount(ModelPicker, { - props: { - models, - current: 'kimi/k2', - starredIds: [], - }, - global: { plugins: [i18n] }, - }); - - const starBtn = wrapper.findAll('.star-btn').find((button) => - button.element.closest('.model-row')?.textContent?.includes('GPT-5'), - ); - expect(starBtn).toBeDefined(); - await starBtn!.trigger('click'); - - expect(wrapper.emitted('toggle-star')).toHaveLength(1); - expect(wrapper.emitted('toggle-star')![0]).toEqual(['openai/gpt-5']); - expect(wrapper.emitted('select')).toBeUndefined(); - }); - - it('keeps starred models first while searching in the All tab', async () => { - const wrapper = mount(ModelPicker, { - props: { - models, - current: 'kimi/k2', - starredIds: ['openai/gpt-5'], - }, - global: { plugins: [i18n] }, - }); - - const search = wrapper.find('.search-input'); - await search.setValue('gpt'); - - const rows = wrapper.findAll('.model-row'); - expect(rows).toHaveLength(2); - expect(rows[0]!.text()).toContain('GPT-5'); - expect(rows[1]!.text()).toContain('GPT-4o'); - }); -}); diff --git a/apps/kimi-web/test/question-card-recommended.test.ts b/apps/kimi-web/test/question-card-recommended.test.ts deleted file mode 100644 index 9f40d923d9..0000000000 --- a/apps/kimi-web/test/question-card-recommended.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { createI18n } from 'vue-i18n'; -import { afterEach, describe, expect, it } from 'vitest'; - -import QuestionCard from '../src/components/QuestionCard.vue'; -import type { UIQuestion } from '../src/types'; - -const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { - en: { - question: { - title: 'Question', - step: '{current}/{total}', - prev: 'Prev', - next: 'Next', - expand: 'Expand', - minimize: 'Minimize', - otherDefault: 'Other', - submit: 'Submit', - dismiss: 'Dismiss', - }, - }, - }, - missingWarn: false, - fallbackWarn: false, -}); - -const mounted: ReturnType[] = []; - -function question(overrides: Partial = {}): UIQuestion { - return { - questionId: 'qreq_1', - sessionId: 'sess_1', - questions: [ - { - id: 'q1', - question: 'Pick one', - options: [ - { id: 'a', label: 'A' }, - { id: 'b', label: 'B', recommended: true }, - ], - ...overrides, - }, - ], - }; -} - -function mountCard(input: UIQuestion) { - const wrapper = mount(QuestionCard, { - props: { question: input }, - global: { - plugins: [i18n], - stubs: { Markdown: true }, - }, - }); - mounted.push(wrapper); - return wrapper; -} - -afterEach(() => { - for (const wrapper of mounted.splice(0)) wrapper.unmount(); -}); - -describe('QuestionCard recommended defaults', () => { - it('preselects the recommended single-select option so Enter submits it', async () => { - const wrapper = mountCard(question()); - const options = wrapper.findAll('.qopt'); - - expect(options[1]!.classes()).toContain('selected'); - - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); - expect(wrapper.emitted('answer')?.[0]?.[1]).toMatchObject({ - answers: { - q1: { kind: 'single', optionId: 'b' }, - }, - }); - }); - - it('preselects all recommended multi-select options', () => { - const wrapper = mountCard(question({ - multiSelect: true, - options: [ - { id: 'a', label: 'A', recommended: true }, - { id: 'b', label: 'B', description: '推荐' }, - { id: 'c', label: 'C' }, - ], - })); - - expect(wrapper.findAll('.qopt').map((option) => option.classes().includes('selected'))).toEqual([ - true, - true, - false, - ]); - }); -}); diff --git a/apps/kimi-web/test/reconnect-streaming.test.ts b/apps/kimi-web/test/reconnect-streaming.test.ts deleted file mode 100644 index 1b1f28b011..0000000000 --- a/apps/kimi-web/test/reconnect-streaming.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { createAgentProjector } from '../src/api/daemon/agentEventProjector'; -import type { AppEvent } from '../src/api/types'; - -// Reproduce the "after one ws disconnect, streaming only shows whole blocks" -// bug at the projector layer. The projector survives reconnects and session -// switches (it is created once per connectEvents / page load), so any state it -// corrupts on reconnect stays broken until a full reload. - -function deltas(events: AppEvent[]): string[] { - return events - .filter((e): e is Extract => e.type === 'assistantDelta') - .map((e) => e.delta.text ?? e.delta.thinking ?? ''); -} - -function hasResync(events: AppEvent[]): boolean { - return events.some((e) => e.type === 'historyCompacted'); -} - -describe('reconnect streaming recovery (projector)', () => { - it('streams a normal turn delta-by-delta', () => { - const p = createAgentProjector(); - const sid = 'sess_1'; - p.project('turn.started', { turnId: 1 }, sid); - p.project('turn.step.started', { turnId: 1 }, sid); - const a = p.project('assistant.delta', { delta: 'Hel' }, sid, { offset: 0 }); - const b = p.project('assistant.delta', { delta: 'lo ' }, sid, { offset: 3 }); - const c = p.project('assistant.delta', { delta: 'wor' }, sid, { offset: 6 }); - expect(deltas([...a, ...b, ...c])).toEqual(['Hel', 'lo ', 'wor']); - }); - - it('a NEW turn after a mid-turn reconnect (no resync) still streams', () => { - const p = createAgentProjector(); - const sid = 'sess_1'; - - // ---- Turn 1 streams up to offset 9, then ws drops (deltas 9..40 lost) ---- - p.project('turn.started', { turnId: 1 }, sid); - p.project('turn.step.started', { turnId: 1 }, sid); - p.project('assistant.delta', { delta: 'aaaaaaaaa' }, sid, { offset: 0 }); // turnTextLen -> 9 - - // ws drops. Daemon keeps streaming turn 1 to assistantText length 40, then - // the step + turn complete DURING the disconnect. On reconnect the durable - // tail is replayed (deltas are volatile => NOT replayed). The cursor is - // still servable, so NO resync_required fires. - const completed = p.project('turn.step.completed', { turnId: 1, usage: {} }, sid); - const ended = p.project('turn.ended', { turnId: 1, reason: 'completed' }, sid); - expect(hasResync([...completed, ...ended])).toBe(false); - - // ---- Turn 2 (brand new prompt) after reconnect ---- - // Daemon resets assistantText=0 for turn 2; first delta offset 0. - p.project('turn.started', { turnId: 2 }, sid); - p.project('turn.step.started', { turnId: 2 }, sid); - const d1 = p.project('assistant.delta', { delta: 'Hi ' }, sid, { offset: 0 }); - const d2 = p.project('assistant.delta', { delta: 'there' }, sid, { offset: 3 }); - - // BUG would show as these being skipped (empty) because turnTextLen is stale. - expect(deltas([...d1, ...d2])).toEqual(['Hi ', 'there']); - }); - - it('a new turn whose turn.started was missed on reconnect still streams', () => { - // The real failure mode: after a reconnect the durable replay and the live - // volatile deltas race on the cursor, so turn 2's `turn.started` is not - // re-delivered to the projector, but turn 2's deltas (offset 0,1,2…) are. - // If turn.ended left turnTextLen stale at turn 1's length, every turn-2 - // delta has offset < turnTextLen and is SILENTLY skipped (skip has no - // recovery, unlike gap) — streaming dies until a full page reload. - const p = createAgentProjector(); - const sid = 'sess_1'; - - // Turn 1 streams 50 chars then ends. - p.project('turn.started', { turnId: 1 }, sid); - p.project('turn.step.started', { turnId: 1 }, sid); - p.project('assistant.delta', { delta: 'a'.repeat(50) }, sid, { offset: 0 }); - p.project('turn.step.completed', { turnId: 1, usage: {} }, sid); - p.project('turn.ended', { turnId: 1, reason: 'completed' }, sid); - - // Turn 2 — turn.started MISSED (race), but a step.started + live deltas land. - p.project('turn.step.started', { turnId: 2 }, sid); - const d1 = p.project('assistant.delta', { delta: 'Hi ' }, sid, { offset: 0 }); - const d2 = p.project('assistant.delta', { delta: 'there' }, sid, { offset: 3 }); - - expect(deltas([...d1, ...d2])).toEqual(['Hi ', 'there']); - }); - - it('reconnect WITHIN turn 1 (durable step.started replay) keeps streaming', () => { - const p = createAgentProjector(); - const sid = 'sess_1'; - - p.project('turn.started', { turnId: 1 }, sid); - p.project('turn.step.started', { turnId: 1 }, sid); - p.project('assistant.delta', { delta: 'aaaaaaaaa' }, sid, { offset: 0 }); // len 9 - - // ws drops mid-step-1. Daemon streams to 40, step 1 completes, step 2 - // starts (durable). On reconnect those durable events replay. - p.project('turn.step.completed', { turnId: 1, usage: {} }, sid); - p.project('turn.step.started', { turnId: 1 }, sid); // new assistant msg, turnTextLen NOT reset - - // Live deltas of step 2 resume. Daemon assistantText is cumulative across - // steps -> offset continues from 40. - const r = p.project('assistant.delta', { delta: 'X' }, sid, { offset: 40 }); - // offset 40 > turnTextLen 9 -> should detect a gap and request resync. - expect(hasResync(r)).toBe(true); - }); -}); diff --git a/apps/kimi-web/test/session-meta-updated.test.ts b/apps/kimi-web/test/session-meta-updated.test.ts deleted file mode 100644 index 7cdbecb341..0000000000 --- a/apps/kimi-web/test/session-meta-updated.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -// apps/kimi-web/test/session-meta-updated.test.ts -// -// The daemon emits `session.meta.updated` whenever a session's title or last -// user prompt changes. The projector must forward BOTH fields so the cached -// session list stays fresh — otherwise sidebar search by the latest prompt -// text goes stale until a full reload. - -import { describe, expect, it } from 'vitest'; -import { createAgentProjector } from '../src/api/daemon/agentEventProjector'; -import { createInitialState, reduceAppEvent, type KimiClientState } from '../src/api/daemon/eventReducer'; -import type { AppEvent, AppSession } from '../src/api/types'; - -const SESSION = 'sess_1'; - -function seedSession(): AppSession { - return { - id: SESSION, - title: 'Old title', - createdAt: '2026-06-11T00:00:00.000Z', - updatedAt: '2026-06-11T00:00:00.000Z', - status: 'idle', - archived: false, - cwd: '/repo', - model: 'kimi-test', - usage: { - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheCreationTokens: 0, - totalCostUsd: 0, - contextTokens: 0, - contextLimit: 128_000, - turnCount: 0, - }, - messageCount: 0, - lastSeq: 0, - lastPrompt: 'old prompt', - }; -} - -function play(events: [string, unknown][], initial?: KimiClientState): { state: KimiClientState; appEvents: AppEvent[] } { - const projector = createAgentProjector(); - let state = initial ?? createInitialState(); - const appEvents: AppEvent[] = []; - let seq = 0; - for (const [type, payload] of events) { - for (const appEvent of projector.project(type, payload, SESSION)) { - appEvents.push(appEvent); - state = reduceAppEvent(state, appEvent, { sessionId: SESSION, seq: ++seq }); - } - } - return { state, appEvents }; -} - -describe('session.meta.updated pipeline', () => { - it('forwards lastPrompt so the cached session stays searchable', () => { - const initial: KimiClientState = { ...createInitialState(), sessions: [seedSession()] }; - - const { state, appEvents } = play( - [['session.meta.updated', { patch: { lastPrompt: 'fix the sidebar search' } }]], - initial, - ); - - expect(appEvents).toEqual([ - { type: 'sessionMetaUpdated', sessionId: SESSION, lastPrompt: 'fix the sidebar search' }, - ]); - const session = state.sessions.find((s) => s.id === SESSION); - expect(session?.lastPrompt).toBe('fix the sidebar search'); - // Title untouched when not present in the patch. - expect(session?.title).toBe('Old title'); - }); - - it('still forwards title and patches it alongside lastPrompt', () => { - const initial: KimiClientState = { ...createInitialState(), sessions: [seedSession()] }; - - const { state } = play( - [['session.meta.updated', { patch: { title: 'New title', lastPrompt: 'latest prompt' } }]], - initial, - ); - - const session = state.sessions.find((s) => s.id === SESSION); - expect(session?.title).toBe('New title'); - expect(session?.lastPrompt).toBe('latest prompt'); - }); - - it('emits nothing when the patch carries neither title nor lastPrompt', () => { - const { appEvents } = play([['session.meta.updated', { patch: {} }]]); - expect(appEvents).toEqual([]); - }); -}); diff --git a/apps/kimi-web/test/session-row.test.ts b/apps/kimi-web/test/session-row.test.ts deleted file mode 100644 index 5b6dc17605..0000000000 --- a/apps/kimi-web/test/session-row.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -// apps/kimi-web/test/session-row.test.ts -// -// The sidebar row spins ONLY while the session is busy (running with a real -// task), and surfaces the 5-state lifecycle status: awaiting shows its pending -// tag, aborted shows a distinct "stopped" tag — neither spins. - -import { mount } from '@vue/test-utils'; -import { createI18n } from 'vue-i18n'; -import { describe, expect, it } from 'vitest'; - -import SessionRow from '../src/components/SessionRow.vue'; -import enWorkspace from '../src/i18n/locales/en/workspace'; -import enSidebar from '../src/i18n/locales/en/sidebar'; -import type { Session } from '../src/types'; - -const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { en: { workspace: enWorkspace, sidebar: enSidebar } }, - missingWarn: false, - fallbackWarn: false, -}); - -function row(session: Partial, extra: Record = {}) { - const full: Session = { id: 's1', title: 'Demo', time: '1m', status: 'idle', busy: false, ...session }; - return mount(SessionRow, { - props: { session: full, active: false, ...extra }, - global: { plugins: [i18n] }, - }); -} - -describe('SessionRow status / busy', () => { - it('spins only when busy', () => { - expect(row({ status: 'running', busy: true }).find('.run-ico').exists()).toBe(true); - // Awaiting input is not "working" — no spinner even though status != idle. - expect(row({ status: 'awaitingApproval', busy: false }).find('.run-ico').exists()).toBe(false); - expect(row({ status: 'aborted', busy: false }).find('.run-ico').exists()).toBe(false); - expect(row({ status: 'idle', busy: false }).find('.run-ico').exists()).toBe(false); - }); - - it('shows the awaiting tag from status even without loaded pending counts', () => { - const w = row({ status: 'awaitingApproval', busy: false }); - expect(w.find('.tag-approve').exists()).toBe(true); - expect(w.find('.tag-aborted').exists()).toBe(false); - }); - - it('shows a distinct aborted tag', () => { - const w = row({ status: 'aborted', busy: false }); - expect(w.find('.tag-aborted').exists()).toBe(true); - expect(w.text()).toContain('Stopped'); - }); - - it('shows no status tag for a plain idle session', () => { - const w = row({ status: 'idle', busy: false }); - expect(w.find('.tag-approve').exists()).toBe(false); - expect(w.find('.tag-ask').exists()).toBe(false); - expect(w.find('.tag-aborted').exists()).toBe(false); - }); - - it('emits archive after confirming via the kebab menu', async () => { - const w = row({ id: 'only', title: 'Only' }); - - await w.find('.kebab').trigger('click'); - await w.find('.menu-item.archive').trigger('click'); - expect(w.find('.archive-confirm').exists()).toBe(true); - - await w.find('.btn-confirm').trigger('click'); - expect(w.emitted('archive')).toEqual([['only']]); - }); -}); diff --git a/apps/kimi-web/test/session-url.test.ts b/apps/kimi-web/test/session-url.test.ts deleted file mode 100644 index 3c27d1fafe..0000000000 --- a/apps/kimi-web/test/session-url.test.ts +++ /dev/null @@ -1,315 +0,0 @@ -// apps/kimi-web/test/session-url.test.ts -// -// Session ↔ URL binding without a router: clicking a session pushes -// /sessions/; loading the app honours a deep link (fetching the session -// when it is beyond the first page); back/forward drive selection via -// popstate without re-writing the URL; archiving the active session repairs -// the address bar with replaceState. - -import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { AppSession, AppWarning, KimiEventHandlers, KimiWebApi } from '../src/api/types'; -import { readSessionIdFromLocation, sessionUrl } from '../src/lib/sessionRoute'; - -const now = '2026-06-11T00:00:00.000Z'; - -function session(id: string): AppSession { - return { - id, - title: id, - createdAt: now, - updatedAt: now, - status: 'idle', - archived: false, - cwd: '/repo', - model: 'kimi-test', - usage: { - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheCreationTokens: 0, - totalCostUsd: 0, - contextTokens: 0, - contextLimit: 128_000, - turnCount: 0, - }, - messageCount: 0, - lastSeq: 0, - }; -} - -async function setup(opts: { - sessions?: AppSession[]; - /** Sessions only reachable via getSession (beyond the first page). */ - extraSessions?: AppSession[]; - /** Sessions that vanish when their transcript is loaded. */ - messageMissingSessions?: string[]; - snapshotErrors?: Record; - initialPath?: string; -}) { - vi.resetModules(); - vi.stubGlobal('WebSocket', class WebSocket {}); - window.history.replaceState(null, '', opts.initialPath ?? '/'); - - const listed = opts.sessions ?? []; - const extras = opts.extraSessions ?? []; - const messageMissingSessions = new Set(opts.messageMissingSessions ?? []); - const snapshotErrors = opts.snapshotErrors ?? {}; - - let handlers: KimiEventHandlers | undefined; - const eventConn = { - subscribe: vi.fn(), - unsubscribe: vi.fn(), - bindNextPromptId: vi.fn(), - seedSnapshot: vi.fn(), - abort: vi.fn(), - close: vi.fn(), - }; - const api = { - getHealth: vi.fn(async () => ({ status: 'ok', uptimeSec: 1 })), - getMeta: vi.fn(async () => ({ daemonVersion: 't', serverId: 's', startedAt: now, capabilities: {} })), - getAuth: vi.fn(async () => ({ ready: true, defaultModel: 'kimi-test', managedProvider: null })), - listModels: vi.fn(async () => []), - listWorkspaces: vi.fn(async () => []), - getFsHome: vi.fn(async () => ({ home: '/home', recentRoots: [] })), - listSessions: vi.fn(async () => ({ items: listed, hasMore: false })), - getSession: vi.fn(async (id: string) => { - const found = extras.find((s) => s.id === id) ?? listed.find((s) => s.id === id); - if (!found) throw new Error('SESSION_NOT_FOUND'); - return found; - }), - archiveSession: vi.fn(async () => ({ archived: true })), - getSessionSnapshot: vi.fn(async (id: string) => { - if (Object.prototype.hasOwnProperty.call(snapshotErrors, id)) { - throw snapshotErrors[id]; - } - if (messageMissingSessions.has(id)) { - throw Object.assign(new Error(`session ${id} does not exist`), { - name: 'DaemonApiError', - code: 40401, - }); - } - const found = extras.find((s) => s.id === id) ?? listed.find((s) => s.id === id) ?? session(id); - return { - asOfSeq: 0, - epoch: 'ep_test', - session: found, - messages: [], - hasMoreMessages: false, - inFlightTurn: null, - pendingApprovals: [], - pendingQuestions: [], - }; - }), - listTasks: vi.fn(async () => []), - getGitStatus: vi.fn(async () => ({ branch: 'main', ahead: 0, behind: 0, entries: {}, additions: 0, deletions: 0 })), - getSessionStatus: vi.fn(async () => ({ - model: 'kimi-test', - thinkingLevel: 'high', - permission: 'manual', - planMode: false, - swarmMode: false, - contextTokens: 0, - maxContextTokens: 128_000, - contextUsage: 0, - })), - connectEvents: vi.fn((nextHandlers: KimiEventHandlers) => { - handlers = nextHandlers; - return eventConn; - }), - getFileUrl: vi.fn((fileId: string) => `/files/${fileId}`), - } as unknown as KimiWebApi; - - vi.doMock('../src/api', () => ({ getKimiWebApi: () => api })); - const { useKimiWebClient } = await import('../src/composables/useKimiWebClient'); - - return { - api, - client: useKimiWebClient(), - getHandlers: () => { - if (!handlers) throw new Error('connectEvents was not called'); - return handlers; - }, - }; -} - -function warningText(warning: AppWarning): string { - return typeof warning === 'string' ? warning : `${warning.title} ${warning.message ?? ''}`; -} - -/** Simulate back/forward: the browser changes the URL itself, then fires - popstate. jsdom's history traversal is unreliable, so emulate directly. */ -function firePopState(path: string): void { - window.history.replaceState(null, '', path); - window.dispatchEvent(new PopStateEvent('popstate')); -} - -afterEach(() => { - vi.unstubAllGlobals(); - vi.resetModules(); - vi.clearAllMocks(); - localStorage.removeItem('kimi-locale'); - window.history.replaceState(null, '', '/'); -}); - -describe('sessionRoute helpers', () => { - it('parses /sessions/ and nothing else', () => { - expect(readSessionIdFromLocation({ pathname: '/sessions/abc' })).toBe('abc'); - expect(readSessionIdFromLocation({ pathname: '/sessions/a%2Fb' })).toBe('a/b'); - expect(readSessionIdFromLocation({ pathname: '/' })).toBeUndefined(); - expect(readSessionIdFromLocation({ pathname: '/sessions/' })).toBeUndefined(); - expect(readSessionIdFromLocation({ pathname: '/sessions/a/b' })).toBeUndefined(); - expect(readSessionIdFromLocation({ pathname: '/settings' })).toBeUndefined(); - expect(readSessionIdFromLocation({ pathname: '/sessions/%E0%A4%A' })).toBeUndefined(); // bad escape - }); - - it('builds canonical URLs', () => { - expect(sessionUrl('abc')).toBe('/sessions/abc'); - expect(sessionUrl(undefined)).toBe('/'); - }); -}); - -describe('session ↔ URL binding', () => { - it('selectSession pushes /sessions/; re-selecting the same session does not stack entries', async () => { - const { client } = await setup({ sessions: [session('sess_1'), session('sess_2')] }); - await client.load(); - expect(window.location.pathname).toBe('/sessions/sess_1'); // auto-select → replace - - const lenAfterLoad = window.history.length; - await client.selectSession('sess_2'); - expect(window.location.pathname).toBe('/sessions/sess_2'); - expect(window.history.length).toBe(lenAfterLoad + 1); - - await client.selectSession('sess_2'); - expect(window.history.length).toBe(lenAfterLoad + 1); - }); - - it('load() honours a deep link to a listed session without adding a history entry', async () => { - const { client } = await setup({ - sessions: [session('sess_1'), session('sess_2')], - initialPath: '/sessions/sess_2', - }); - const lenBefore = window.history.length; - await client.load(); - - expect(client.activeSessionId.value).toBe('sess_2'); - expect(window.location.pathname).toBe('/sessions/sess_2'); - expect(window.history.length).toBe(lenBefore); - }); - - it('load() fetches a deep-linked session beyond the first page via getSession', async () => { - const old = session('sess_old'); - const { api, client } = await setup({ - sessions: [session('sess_1')], - extraSessions: [old], - initialPath: '/sessions/sess_old', - }); - await client.load(); - - expect(api.getSession).toHaveBeenCalledWith('sess_old'); - expect(client.activeSessionId.value).toBe('sess_old'); - // Appended (not prepended) so the recency ordering stays intact. - expect(client.sessions.value.map((s) => s.id)).toEqual(['sess_1', 'sess_old']); - }); - - it('load() falls back to the most recent session and repairs a dead deep link', async () => { - const { client } = await setup({ - sessions: [session('sess_1')], - initialPath: '/sessions/sess_gone', - }); - await client.load(); - - expect(client.activeSessionId.value).toBe('sess_1'); - expect(window.location.pathname).toBe('/sessions/sess_1'); - }); - - it('load() repairs a deep link when the listed session vanishes before its snapshot loads', async () => { - const { api, client } = await setup({ - sessions: [session('sess_gone'), session('sess_1')], - messageMissingSessions: ['sess_gone'], - initialPath: '/sessions/sess_gone', - }); - await client.load(); - - expect(api.getSessionSnapshot).toHaveBeenCalledWith('sess_gone'); - expect(client.activeSessionId.value).toBe('sess_1'); - expect(client.sessions.value.map((s) => s.id)).toEqual(['sess_1']); - expect(window.location.pathname).toBe('/sessions/sess_1'); - expect(client.warnings.value.some((w) => warningText(w).includes('Failed to load session snapshot'))).toBe(false); - }); - - it('load() surfaces snapshot network failures as actionable diagnostics', async () => { - localStorage.setItem('kimi-locale', 'en'); - const networkError = Object.assign(new Error('Network error calling GET /sessions/sess_1/snapshot'), { - name: 'DaemonNetworkError', - method: 'GET', - path: '/sessions/sess_1/snapshot', - url: 'http://127.0.0.1:58627/api/v1/sessions/sess_1/snapshot', - requestId: '01HZ0000000000000000000000', - phase: 'fetch', - timeoutMs: 30000, - cause: new TypeError('Failed to fetch'), - }); - const { client } = await setup({ - sessions: [session('sess_1')], - snapshotErrors: { sess_1: networkError }, - }); - - await client.load(); - - expect(client.warnings.value).toHaveLength(1); - const [warning] = client.warnings.value; - expect(typeof warning).toBe('object'); - if (typeof warning === 'string') throw new Error('expected structured warning'); - expect(warning).toMatchObject({ - severity: 'error', - title: 'Cannot load current conversation', - message: expect.stringContaining('could not load the current conversation'), - }); - expect(warning.details).toEqual( - expect.arrayContaining([ - { label: 'Operation', value: 'getSessionSnapshot' }, - { label: 'Session ID', value: 'sess_1' }, - { label: 'Request', value: 'GET /sessions/sess_1/snapshot' }, - { label: 'Endpoint', value: 'http://127.0.0.1:58627/api/v1/sessions/sess_1/snapshot' }, - { label: 'Request ID', value: '01HZ0000000000000000000000' }, - { label: 'Cause', value: 'TypeError: Failed to fetch' }, - ]), - ); - }); - - it('popstate selects the session from the URL without writing the URL again', async () => { - const { client } = await setup({ sessions: [session('sess_1'), session('sess_2')] }); - await client.load(); - await client.selectSession('sess_2'); - - const lenBefore = window.history.length; - firePopState('/sessions/sess_1'); - await vi.waitFor(() => { - expect(client.activeSessionId.value).toBe('sess_1'); - }); - expect(window.location.pathname).toBe('/sessions/sess_1'); - expect(window.history.length).toBe(lenBefore); - }); - - it('popstate to "/" clears the active session', async () => { - const { client } = await setup({ sessions: [session('sess_1')] }); - await client.load(); - expect(client.activeSessionId.value).toBe('sess_1'); - - firePopState('/'); - expect(client.activeSessionId.value).toBe(''); // composable maps undefined → '' - }); - - it('archiving the active session replaces the URL with the next session', async () => { - const { client } = await setup({ sessions: [session('sess_1'), session('sess_2')] }); - await client.load(); - expect(client.activeSessionId.value).toBe('sess_1'); - - const lenBefore = window.history.length; - await client.archiveSession('sess_1'); - - expect(client.activeSessionId.value).toBe('sess_2'); - expect(window.location.pathname).toBe('/sessions/sess_2'); - expect(window.history.length).toBe(lenBefore); - }); -}); diff --git a/apps/kimi-web/test/set-model-rollback.test.ts b/apps/kimi-web/test/set-model-rollback.test.ts deleted file mode 100644 index 6a5e7c97e6..0000000000 --- a/apps/kimi-web/test/set-model-rollback.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { AppModel, AppSession, KimiEventHandlers, KimiWebApi } from '../src/api/types'; - -const now = '2026-06-11T00:00:00.000Z'; - -function session(id: string, model: string): AppSession { - return { - id, - title: id, - createdAt: now, - updatedAt: now, - status: 'idle', - cwd: '/repo', - model, - usage: { - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheCreationTokens: 0, - totalCostUsd: 0, - contextTokens: 0, - contextLimit: 128_000, - turnCount: 0, - }, - messageCount: 0, - lastSeq: 0, - }; -} - -async function setup(opts: { updateRejects: boolean; models?: AppModel[] }) { - vi.resetModules(); - vi.stubGlobal('WebSocket', class WebSocket {}); - - const created = session('sess_1', 'model-old'); - // The daemon's authoritative model — only a successful updateSession moves it. - let currentModel = 'model-old'; - const eventConn = { - subscribe: vi.fn(), - unsubscribe: vi.fn(), - bindNextPromptId: vi.fn(), - seedSnapshot: vi.fn(), - abort: vi.fn(), - close: vi.fn(), - }; - const api = { - createSession: vi.fn(async () => created), - getSessionSnapshot: vi.fn(async () => ({ - asOfSeq: 0, - epoch: 'ep_test', - session: created, - messages: [], - hasMoreMessages: false, - inFlightTurn: null, - pendingApprovals: [], - pendingQuestions: [], - })), - updateSession: vi.fn(async (_sid: string, patch: { model?: string }) => { - if (opts.updateRejects) throw new Error('daemon unreachable'); - if (patch.model) currentModel = patch.model; - return session('sess_1', currentModel); - }), - listModels: vi.fn(async () => opts.models ?? []), - getSessionStatus: vi.fn(async () => ({ - model: currentModel, - thinkingLevel: 'high', - permission: 'manual', - planMode: false, - swarmMode: false, - contextTokens: 0, - maxContextTokens: 128_000, - contextUsage: 0, - })), - listTasks: vi.fn(async () => []), - getGitStatus: vi.fn(async () => ({ branch: 'main', ahead: 0, behind: 0, entries: {}, additions: 0, deletions: 0 })), - connectEvents: vi.fn((h: KimiEventHandlers) => { - void h; - return eventConn; - }), - getFileUrl: vi.fn((fileId: string) => `/files/${fileId}`), - } as unknown as KimiWebApi; - - vi.doMock('../src/api', () => ({ getKimiWebApi: () => api })); - const { useKimiWebClient } = await import('../src/composables/useKimiWebClient'); - const client = useKimiWebClient(); - await client.createSession('/repo'); - if (opts.models !== undefined) await client.loadModels(); - return { client, api }; -} - -afterEach(() => { - vi.unstubAllGlobals(); - vi.resetModules(); - vi.clearAllMocks(); -}); - -describe('setModel failure handling', () => { - it('rolls the picker back and warns when the switch cannot reach the daemon', async () => { - const { client } = await setup({ updateRejects: true }); - expect(client.status.value.modelId).toBe('model-old'); - - await client.setModel('model-new'); - - // The optimistic pick must not stick — the UI cannot claim a switch that - // never landed. - expect(client.status.value.modelId).toBe('model-old'); - expect(client.warnings.value.length).toBeGreaterThan(0); - }); - - it('keeps the new model and does not warn on success', async () => { - const { client } = await setup({ updateRejects: false }); - await client.setModel('model-new'); - expect(client.status.value.modelId).toBe('model-new'); - expect(client.warnings.value.length).toBe(0); - }); - - it('forces thinking on when switching to an always-thinking model', async () => { - const { client, api } = await setup({ - updateRejects: false, - models: [ - { - id: 'model-old', - provider: 'kimi', - model: 'model-old', - maxContextSize: 128_000, - capabilities: ['thinking'], - }, - { - id: 'model-new', - provider: 'kimi', - model: 'model-new', - maxContextSize: 128_000, - capabilities: ['thinking', 'always_thinking'], - }, - ], - }); - - client.setThinking('off'); - expect(client.thinking.value).toBe('off'); - - await client.setModel('model-new'); - - expect(client.thinking.value).toBe('high'); - expect(api.updateSession).toHaveBeenLastCalledWith('sess_1', { - model: 'model-new', - thinking: 'high', - }); - }); -}); diff --git a/apps/kimi-web/test/settings-dialog.test.ts b/apps/kimi-web/test/settings-dialog.test.ts deleted file mode 100644 index 6819f59000..0000000000 --- a/apps/kimi-web/test/settings-dialog.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { createI18n } from 'vue-i18n'; -import { afterEach, describe, expect, it } from 'vitest'; - -import SettingsDialog from '../src/components/SettingsDialog.vue'; -import enSettings from '../src/i18n/locales/en/settings'; -import type { AppConfig, AppModel } from '../src/api/types'; - -const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { - en: { - settings: enSettings, - theme: { - label: 'Theme', - modern: 'Modern', - kimi: 'Kimi', - colorSchemeLabel: 'Color scheme', - light: 'Light', - dark: 'Dark', - system: 'System', - }, - sidebar: { - daemon: 'Daemon', - language: 'Language', - notSignedIn: 'Not signed in', - signIn: 'Sign in', - signOut: 'Sign out', - }, - onboarding: { reopen: 'Open onboarding' }, - newSession: { close: 'Close' }, - }, - }, - missingWarn: false, - fallbackWarn: false, -}); - -const config: AppConfig = { - providers: { - kimi: { - type: 'moonshot', - defaultModel: 'kimi/k2', - hasApiKey: true, - }, - openai: { - type: 'openai', - hasApiKey: false, - }, - }, - defaultModel: 'kimi/k2', - models: { - 'kimi/k2': { provider: 'kimi', model: 'k2' }, - 'openai/gpt-5': { provider: 'openai', model: 'gpt-5' }, - }, - defaultPermissionMode: 'manual', - defaultThinking: true, - defaultPlanMode: false, - mergeAllAvailableSkills: false, - telemetry: true, - raw: { secret: 'must-not-render' }, -}; - -const models: AppModel[] = [ - { - id: 'kimi/k2', - provider: 'kimi', - model: 'k2', - displayName: 'Kimi K2', - maxContextSize: 128000, - }, - { - id: 'openai/gpt-5', - provider: 'openai', - model: 'gpt-5', - displayName: 'GPT-5', - maxContextSize: 256000, - }, -]; - -function mountDialog() { - return mount(SettingsDialog, { - props: { - theme: 'modern', - colorScheme: 'system', - uiFontSize: 15, - authReady: true, - accountModel: 'kimi/k2', - notify: true, - notifyPermission: 'granted', - betaToc: false, - config, - models, - configSaving: false, - serverVersion: '1.2.3', - }, - global: { - plugins: [i18n], - stubs: { LanguageSwitcher: true }, - }, - }); -} - -afterEach(() => { - document.body.innerHTML = ''; -}); - -describe('SettingsDialog tabs', () => { - it('renders side tabs and switches panels', async () => { - const wrapper = mountDialog(); - - expect(wrapper.text()).toContain('General'); - - const generalTab = wrapper.findAll('.tab').find((button) => button.text() === 'General'); - const agentTab = wrapper.findAll('.tab').find((button) => button.text() === 'Agent'); - const advancedTab = wrapper.findAll('.tab').find((button) => button.text() === 'Advanced'); - const experimentalTab = wrapper.findAll('.tab').find((button) => button.text() === 'Experimental'); - - expect(generalTab!.classes('on')).toBe(true); - expect(agentTab!.classes('on')).toBe(false); - - await agentTab!.trigger('click'); - expect(generalTab!.classes('on')).toBe(false); - expect(agentTab!.classes('on')).toBe(true); - - const agentPanel = wrapper.find('#settings-panel-agent'); - expect(agentPanel.isVisible()).toBe(true); - const generalPanel = wrapper.find('#settings-panel-general'); - expect(generalPanel.isVisible()).toBe(false); - - await advancedTab!.trigger('click'); - expect(advancedTab!.classes('on')).toBe(true); - expect(agentTab!.classes('on')).toBe(false); - - await experimentalTab!.trigger('click'); - expect(experimentalTab!.classes('on')).toBe(true); - expect(advancedTab!.classes('on')).toBe(false); - }); -}); - -describe('SettingsDialog config controls', () => { - it('renders redacted daemon config and emits partial config patches', async () => { - const wrapper = mountDialog(); - - const agentTab = wrapper.findAll('.tab').find((button) => button.text() === 'Agent'); - await agentTab!.trigger('click'); - - expect(wrapper.text()).toContain('Agent defaults'); - expect(wrapper.text()).toContain('Kimi K2'); - expect(wrapper.text()).toContain('Credential configured'); - expect(wrapper.text()).toContain('Missing credential'); - expect(wrapper.text()).not.toContain('must-not-render'); - - await wrapper.find('.select-field').setValue('openai/gpt-5'); - expect(wrapper.emitted('updateConfig')?.[0]?.[0]).toEqual({ defaultModel: 'openai/gpt-5' }); - - const auto = wrapper.findAll('.opt').find((button) => button.text() === 'Auto'); - await auto!.trigger('click'); - expect(wrapper.emitted('updateConfig')?.[1]?.[0]).toEqual({ defaultPermissionMode: 'auto' }); - - const planRow = wrapper.findAll('.row').find((row) => row.text().includes('Plan mode by default')); - await planRow!.find('button.switch').trigger('click'); - expect(wrapper.emitted('updateConfig')?.[2]?.[0]).toEqual({ defaultPlanMode: true }); - }); - - it('groups default model options by provider', async () => { - const wrapper = mountDialog(); - - const agentTab = wrapper.findAll('.tab').find((button) => button.text() === 'Agent'); - await agentTab!.trigger('click'); - - const groups = wrapper.findAll('optgroup'); - expect(groups.length).toBe(2); - expect(groups[0]!.attributes('label')).toBe('kimi'); - expect(groups[1]!.attributes('label')).toBe('openai'); - - const kimiOptions = groups[0]!.findAll('option'); - expect(kimiOptions.some((o) => o.attributes('value') === 'kimi/k2')).toBe(true); - - const openaiOptions = groups[1]!.findAll('option'); - expect(openaiOptions.some((o) => o.attributes('value') === 'openai/gpt-5')).toBe(true); - }); - - it('renders server version on the General tab', () => { - const wrapper = mountDialog(); - - // General is the default active tab. - expect(wrapper.text()).toContain('Server version'); - expect(wrapper.text()).toContain('1.2.3'); - }); -}); - -describe('SettingsDialog dialog focus', () => { - it('is a modal that takes focus on open and restores it on close', async () => { - const opener = document.createElement('button'); - document.body.appendChild(opener); - opener.focus(); - expect(document.activeElement).toBe(opener); - - const wrapper = mount(SettingsDialog, { - props: { - theme: 'modern', - colorScheme: 'system', - uiFontSize: 15, - authReady: true, - accountModel: 'kimi/k2', - notify: true, - notifyPermission: 'granted', - betaToc: false, - config, - models, - configSaving: false, - serverVersion: '1.2.3', - }, - global: { plugins: [i18n], stubs: { LanguageSwitcher: true } }, - attachTo: document.body, - }); - - const dialog = wrapper.find('.dialog'); - expect(dialog.attributes('aria-modal')).toBe('true'); - - await nextTick(); - // Opening moves focus into the dialog. - expect(document.activeElement).toBe(dialog.element); - - wrapper.unmount(); - await nextTick(); - // Closing returns focus to the opener. - expect(document.activeElement).toBe(opener); - - opener.remove(); - }); -}); diff --git a/apps/kimi-web/test/setup.ts b/apps/kimi-web/test/setup.ts deleted file mode 100644 index 7b2fb543c7..0000000000 --- a/apps/kimi-web/test/setup.ts +++ /dev/null @@ -1,71 +0,0 @@ -// apps/kimi-web/test/setup.ts -// -// Node 24 exposes an experimental global localStorage that is unavailable -// unless Node is started with --localstorage-file. The app and tests expect -// browser-like storage, so pin the globals to jsdom storage when available and -// fall back to a tiny in-memory implementation otherwise. - -function createMemoryStorage(): Storage { - const data = new Map(); - return { - get length() { - return data.size; - }, - clear() { - data.clear(); - }, - getItem(key: string) { - return data.get(key) ?? null; - }, - key(index: number) { - return Array.from(data.keys()).at(index) ?? null; - }, - removeItem(key: string) { - data.delete(key); - }, - setItem(key: string, value: string) { - data.set(key, String(value)); - }, - }; -} - -function usableStorage(storage: Storage | undefined): Storage { - if (!storage) return createMemoryStorage(); - try { - const key = '__kimi_web_test_storage__'; - storage.setItem(key, '1'); - storage.removeItem(key); - return storage; - } catch { - return createMemoryStorage(); - } -} - -function defineStorage(name: 'localStorage' | 'sessionStorage', storage: Storage): void { - Object.defineProperty(globalThis, name, { - configurable: true, - value: storage, - }); - if (typeof window !== 'undefined') { - try { - Object.defineProperty(window, name, { - configurable: true, - value: storage, - }); - } catch { - // Some jsdom/browser-like environments expose storage as non-configurable. - } - } -} - -function readWindowStorage(name: 'localStorage' | 'sessionStorage'): Storage | undefined { - if (typeof window === 'undefined') return undefined; - try { - return window[name]; - } catch { - return undefined; - } -} - -defineStorage('localStorage', usableStorage(readWindowStorage('localStorage'))); -defineStorage('sessionStorage', usableStorage(readWindowStorage('sessionStorage'))); diff --git a/apps/kimi-web/test/side-chat-panel.test.ts b/apps/kimi-web/test/side-chat-panel.test.ts deleted file mode 100644 index 710b173a53..0000000000 --- a/apps/kimi-web/test/side-chat-panel.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { createI18n } from 'vue-i18n'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { nextTick } from 'vue'; -import SideChatPanel from '../src/components/SideChatPanel.vue'; -import type { ChatTurn } from '../src/types'; - -const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { - en: { - sideChat: { - title: 'Side chat', - subtitle: 'Ask a follow-up', - placeholder: 'Ask a question…', - send: 'Send', - empty: 'No messages yet.', - }, - thinking: { close: 'Close' }, - }, - }, - missingWarn: false, - fallbackWarn: false, -}); - -function mockBodyScroll(el: HTMLElement, scrollHeight: number): void { - Object.defineProperty(el, 'scrollHeight', { - configurable: true, - get: () => scrollHeight, - }); - Object.defineProperty(el, 'scrollTop', { - configurable: true, - writable: true, - value: 0, - }); -} - -afterEach(() => { - document.body.innerHTML = ''; - vi.restoreAllMocks(); -}); - -describe('SideChatPanel', () => { - it('scrolls to bottom when Enter sends a message', async () => { - const wrapper = mount(SideChatPanel, { - props: { turns: [], running: false, sending: false }, - global: { - plugins: [i18n], - stubs: { ChatPane: true }, - }, - attachTo: document.body, - }); - await nextTick(); - - const bodyEl = wrapper.find('.sc-body').element as HTMLElement; - mockBodyScroll(bodyEl, 500); - - const textarea = wrapper.get('textarea'); - await textarea.setValue('hello'); - await textarea.trigger('keydown', { key: 'Enter', isComposing: false }); - await nextTick(); - - expect(bodyEl.scrollTop).toBe(500); - expect(wrapper.emitted('send')).toEqual([['hello']]); - }); - - it('keeps scrolling to bottom while a response streams in', async () => { - const turns: ChatTurn[] = [ - { id: 'u1', role: 'user', no: 1, text: 'hello' }, - { id: 'a1', role: 'assistant', no: 2, text: '' }, - ]; - - const wrapper = mount(SideChatPanel, { - props: { turns, running: true, sending: false }, - global: { - plugins: [i18n], - stubs: { ChatPane: true }, - }, - attachTo: document.body, - }); - await nextTick(); - - const bodyEl = wrapper.find('.sc-body').element as HTMLElement; - mockBodyScroll(bodyEl, 800); - - await wrapper.setProps({ - turns: [ - { id: 'u1', role: 'user', no: 1, text: 'hello' }, - { id: 'a1', role: 'assistant', no: 2, text: 'first line' }, - ], - }); - await nextTick(); - - expect(bodyEl.scrollTop).toBe(800); - }); - - it('does not auto-scroll while the panel is idle', async () => { - const turns: ChatTurn[] = [ - { id: 'u1', role: 'user', no: 1, text: 'hello' }, - ]; - - const wrapper = mount(SideChatPanel, { - props: { turns, running: false, sending: false }, - global: { - plugins: [i18n], - stubs: { ChatPane: true }, - }, - attachTo: document.body, - }); - await nextTick(); - - const bodyEl = wrapper.find('.sc-body').element as HTMLElement; - mockBodyScroll(bodyEl, 300); - bodyEl.scrollTop = 50; - - await wrapper.setProps({ - turns: [ - { id: 'u1', role: 'user', no: 1, text: 'hello' }, - { id: 'u2', role: 'user', no: 2, text: 'later' }, - ], - }); - await nextTick(); - - expect(bodyEl.scrollTop).toBe(50); - }); - - it('renders a header with title, first user message subtitle, and a close button', async () => { - const turns: ChatTurn[] = [ - { id: 'u1', role: 'user', no: 1, text: 'explain this code' }, - ]; - - const wrapper = mount(SideChatPanel, { - props: { turns, running: false, sending: false }, - global: { - plugins: [i18n], - stubs: { ChatPane: true }, - }, - attachTo: document.body, - }); - await nextTick(); - - expect(wrapper.find('.sc-header').exists()).toBe(true); - expect(wrapper.find('.sc-title').text()).toBe('Side chat'); - expect(wrapper.find('.sc-subtitle').text()).toBe('explain this code'); - - await wrapper.find('.sc-close').trigger('click'); - expect(wrapper.emitted('close')).toHaveLength(1); - }); - - it('uses the title prop when provided', async () => { - const wrapper = mount(SideChatPanel, { - props: { turns: [], running: false, sending: false, title: 'Custom title' }, - global: { - plugins: [i18n], - stubs: { ChatPane: true }, - }, - attachTo: document.body, - }); - await nextTick(); - - expect(wrapper.find('.sc-title').text()).toBe('Custom title'); - }); -}); diff --git a/apps/kimi-web/test/side-chat.test.ts b/apps/kimi-web/test/side-chat.test.ts deleted file mode 100644 index 396b8f0686..0000000000 --- a/apps/kimi-web/test/side-chat.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -// apps/kimi-web/test/side-chat.test.ts -// -// Side chat ("BTW"): openSideChat starts a TUI-style forked agent, sends the -// question to the parent session with agentId, echoes it into the side-chat -// transcript, and never creates a sidebar session. - -import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { AppSession, KimiEventHandlers, KimiWebApi } from '../src/api/types'; - -const now = '2026-06-11T00:00:00.000Z'; - -function session(id: string, extra: Partial = {}): AppSession { - return { - id, - title: id, - createdAt: now, - updatedAt: now, - status: 'idle', - cwd: '/repo', - model: 'kimi-test', - usage: { - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheCreationTokens: 0, - totalCostUsd: 0, - contextTokens: 0, - contextLimit: 128_000, - turnCount: 0, - }, - messageCount: 0, - lastSeq: 0, - ...extra, - }; -} - -async function setup() { - vi.resetModules(); - vi.stubGlobal('WebSocket', class WebSocket {}); - - let handlers: KimiEventHandlers | undefined; - const eventConn = { - subscribe: vi.fn(), - unsubscribe: vi.fn(), - bindNextPromptId: vi.fn(), - seedSnapshot: vi.fn(), - markSideChannelAgent: vi.fn(), - abort: vi.fn(), - close: vi.fn(), - }; - let promptN = 0; - const created = session('sess_1'); - const api = { - createSession: vi.fn(async () => created), - getSessionSnapshot: vi.fn(async () => ({ - asOfSeq: 0, - epoch: 'ep_test', - session: created, - messages: [], - hasMoreMessages: false, - inFlightTurn: null, - pendingApprovals: [], - pendingQuestions: [], - })), - submitPrompt: vi.fn(async () => { - promptN += 1; - return { promptId: `pr_${promptN}`, userMessageId: `msg_real_${promptN}`, status: 'running' }; - }), - listTasks: vi.fn(async () => []), - getGitStatus: vi.fn(async () => ({ branch: 'main', ahead: 0, behind: 0, entries: {}, additions: 0, deletions: 0 })), - getSessionStatus: vi.fn(async () => ({ - model: 'kimi-test', - thinkingLevel: 'high', - permission: 'manual', - planMode: false, - swarmMode: false, - contextTokens: 0, - maxContextTokens: 128_000, - contextUsage: 0, - })), - connectEvents: vi.fn((nextHandlers: KimiEventHandlers) => { - handlers = nextHandlers; - return eventConn; - }), - getFileUrl: vi.fn((fileId: string) => `/files/${fileId}`), - startBtw: vi.fn(async () => ({ agentId: 'agent_btw' })), - } as unknown as KimiWebApi; - - vi.doMock('../src/api', () => ({ getKimiWebApi: () => api })); - const { useKimiWebClient } = await import('../src/composables/useKimiWebClient'); - - return { - api, - client: useKimiWebClient(), - eventConn, - getHandlers: () => { - if (!handlers) throw new Error('connectEvents was not called'); - return handlers; - }, - }; -} - -afterEach(() => { - vi.unstubAllGlobals(); - vi.resetModules(); - vi.clearAllMocks(); -}); - -describe('side chat (BTW)', () => { - it('opens a side-channel agent, sends the question, and echoes it', async () => { - const { api, client, eventConn, getHandlers } = await setup(); - await client.createSession('/repo'); - - await client.openSideChat('what does this do?'); - - // A BTW agent is started under the active session and marked as side-channel - // so its streamed text deltas are not dropped like background subagents. - expect(api.startBtw).toHaveBeenCalledWith('sess_1'); - expect(eventConn.markSideChannelAgent).toHaveBeenCalledWith('agent_btw'); - // The question goes to the SAME session, scoped to the BTW agent. - const call = (api.submitPrompt as ReturnType).mock.calls[0]!; - expect(call[0]).toBe('sess_1'); - expect(call[1]).toMatchObject({ - agentId: 'agent_btw', - content: [ - { type: 'text', text: 'what does this do?' }, - ], - }); - - // The side-chat panel is open and shows the question. - expect(client.sideChatVisible.value).toBe(true); - const userTurns = client.sideChatTurns.value.filter((t) => t.role === 'user'); - expect(userTurns.map((t) => t.text)).toEqual(['what does this do?']); - - getHandlers().onEvent( - { - type: 'taskProgress', - sessionId: 'sess_1', - taskId: 'agent_btw', - outputChunk: 'It checks the diff.', - stream: 'stdout', - }, - { sessionId: 'sess_1', seq: 2 }, - ); - - const assistantTurns = client.sideChatTurns.value.filter((t) => t.role === 'assistant'); - expect(assistantTurns.map((t) => t.text)).toEqual(['It checks the diff.']); - }); - - it('keeps BTW user messages out of the main conversation transcript', async () => { - const { api, client, getHandlers } = await setup(); - await client.createSession('/repo'); - - await client.openSideChat('what does this do?'); - - const submitResult = await (api.submitPrompt as ReturnType).mock.results[0]!.value; - getHandlers().onEvent( - { - type: 'messageCreated', - message: { - id: submitResult.userMessageId, - sessionId: 'sess_1', - role: 'user', - content: [{ type: 'text', text: 'what does this do?' }], - createdAt: now, - promptId: submitResult.promptId, - }, - }, - { sessionId: 'sess_1', seq: 2 }, - ); - - // The side chat still shows the user question. - expect(client.sideChatTurns.value.filter((t) => t.role === 'user').map((t) => t.text)).toEqual([ - 'what does this do?', - ]); - // But it must not leak into the main session transcript. - expect(client.turns.value.filter((t) => t.role === 'user').map((t) => t.text)).toEqual([]); - }); - - it('renders side-channel agent text deltas as the assistant response', async () => { - const { client, getHandlers } = await setup(); - await client.createSession('/repo'); - - await client.openSideChat('what does this do?'); - - getHandlers().onEvent( - { - type: 'agentDelta', - sessionId: 'sess_1', - agentId: 'agent_btw', - delta: { text: 'It checks ' }, - }, - { sessionId: 'sess_1', seq: 2 }, - ); - getHandlers().onEvent( - { - type: 'agentDelta', - sessionId: 'sess_1', - agentId: 'agent_btw', - delta: { text: 'the diff.' }, - }, - { sessionId: 'sess_1', seq: 3 }, - ); - - const assistantTurns = client.sideChatTurns.value.filter((t) => t.role === 'assistant'); - expect(assistantTurns.map((t) => t.text)).toEqual(['It checks the diff.']); - expect(client.sideChatRunning.value).toBe(true); - - getHandlers().onEvent( - { - type: 'agentTurnEnded', - sessionId: 'sess_1', - agentId: 'agent_btw', - }, - { sessionId: 'sess_1', seq: 4 }, - ); - - expect(client.sideChatRunning.value).toBe(false); - }); - - it('does not create a child session for the sidebar', async () => { - const { api, client } = await setup(); - await client.createSession('/repo'); - - await client.openSideChat(); - - expect(api.startBtw).toHaveBeenCalledWith('sess_1'); - expect(api.createChildSession).toBeUndefined(); - const ids = client.sessionsForView.value.map((s) => s.id); - expect(ids).toEqual(['sess_1']); - }); - - it('keeps the question in the panel when task progress is not available yet', async () => { - const { api, client } = await setup(); - await client.createSession('/repo'); - - await client.openSideChat('what does this do?'); - - expect(api.submitPrompt).toHaveBeenCalledWith( - 'sess_1', - expect.objectContaining({ - agentId: 'agent_btw', - content: [ - { type: 'text', text: 'what does this do?' }, - ], - }), - ); - expect(client.sideChatTurns.value.filter((t) => t.role === 'user').map((t) => t.text)).toEqual([ - 'what does this do?', - ]); - }); - - it('does not make the main session look busy while the BTW agent is sending', async () => { - const { client } = await setup(); - await client.createSession('/repo'); - // Simulate the daemon reporting the parent session as running before the - // task list has been refreshed to show the BTW agent. - client.sessions.value[0]!.status = 'running'; - - await client.openSideChat('what does this do?'); - - expect(client.activity.value).toBe('idle'); - }); -}); diff --git a/apps/kimi-web/test/sidebar-search.test.ts b/apps/kimi-web/test/sidebar-search.test.ts deleted file mode 100644 index 9faec5425a..0000000000 --- a/apps/kimi-web/test/sidebar-search.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -// apps/kimi-web/test/sidebar-search.test.ts -// -// The sidebar search box filters the already-loaded sessions instantly by -// title + last prompt (case-insensitive substring), across all workspaces. - -import { mount } from '@vue/test-utils'; -import { createI18n } from 'vue-i18n'; -import { describe, expect, it } from 'vitest'; - -import Sidebar from '../src/components/Sidebar.vue'; -import enWorkspace from '../src/i18n/locales/en/workspace'; -import enSidebar from '../src/i18n/locales/en/sidebar'; -import enSettings from '../src/i18n/locales/en/settings'; -import type { Session } from '../src/types'; - -const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { en: { workspace: enWorkspace, sidebar: enSidebar, settings: enSettings } }, - missingWarn: false, - fallbackWarn: false, -}); - -const sessions: Session[] = [ - { id: 's1', title: 'Refactor auth', time: '1m', status: 'idle', busy: false, lastPrompt: 'extract the token refresh logic' }, - { id: 's2', title: 'Fix tests', time: '2m', status: 'idle', busy: false, lastPrompt: 'make the sidebar spec pass' }, - { id: 's3', title: 'Write docs', time: '3m', status: 'idle', busy: false }, -]; - -function mountSidebar() { - return mount(Sidebar, { - props: { - activeWorkspace: null, - activeWorkspaceId: null, - sessions, - groups: [], - activeId: '', - }, - global: { - plugins: [i18n], - stubs: { LanguageSwitcher: true }, - }, - }); -} - -describe('Sidebar session search', () => { - it('shows the grouped list when the query is empty', () => { - const wrapper = mountSidebar(); - expect(wrapper.find('.search-input').exists()).toBe(true); - // No flat results / no "no results" empty state rendered while not searching. - expect(wrapper.text()).not.toContain('No matching sessions'); - }); - - it('filters by title (case-insensitive)', async () => { - const wrapper = mountSidebar(); - await wrapper.find('.search-input').setValue('refactor'); - - expect(wrapper.text()).toContain('Refactor auth'); - expect(wrapper.text()).not.toContain('Fix tests'); - expect(wrapper.text()).not.toContain('Write docs'); - }); - - it('filters by last prompt', async () => { - const wrapper = mountSidebar(); - await wrapper.find('.search-input').setValue('sidebar spec'); - - expect(wrapper.text()).toContain('Fix tests'); - expect(wrapper.text()).not.toContain('Refactor auth'); - }); - - it('shows an empty state when nothing matches', async () => { - const wrapper = mountSidebar(); - await wrapper.find('.search-input').setValue('zzzz-no-match'); - - expect(wrapper.text()).toContain('No matching sessions'); - }); - - it('clears the query and restores the grouped list', async () => { - const wrapper = mountSidebar(); - await wrapper.find('.search-input').setValue('refactor'); - expect(wrapper.find('.search-clear').exists()).toBe(true); - - await wrapper.find('.search-clear').trigger('click'); - expect(wrapper.find('.search-clear').exists()).toBe(false); - expect(wrapper.text()).not.toContain('No matching sessions'); - }); - - it('clears the query on Escape so it does not bubble to abort a run', async () => { - const wrapper = mountSidebar(); - const input = wrapper.find('.search-input'); - await input.setValue('refactor'); - expect(wrapper.find('.search-clear').exists()).toBe(true); - - await input.trigger('keydown', { key: 'Escape' }); - - // Query cleared → back to the grouped list, no results panel. - expect((input.element as HTMLInputElement).value).toBe(''); - expect(wrapper.text()).not.toContain('No matching sessions'); - }); -}); diff --git a/apps/kimi-web/test/slash-skills.test.ts b/apps/kimi-web/test/slash-skills.test.ts deleted file mode 100644 index bbb880506d..0000000000 --- a/apps/kimi-web/test/slash-skills.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { SLASH_COMMANDS, buildSlashItems, filterCommands } from '../src/lib/slashCommands'; - -const skills = [ - { name: 'brainstorm', description: 'Turn an idea into a design' }, - { name: 'deep-research', description: 'Fan-out web research' }, - { name: 'xxx-context', description: 'Manage context' }, -]; - -describe('slash menu with session skills', () => { - it('appends skills as / after the built-in commands', () => { - const items = buildSlashItems(skills); - expect(items.length).toBe(SLASH_COMMANDS.length + skills.length); - const brainstorm = items.find((i) => i.name === '/brainstorm'); - expect(brainstorm).toMatchObject({ - name: '/brainstorm', - desc: 'Turn an idea into a design', - isSkill: true, - }); - }); - - it('built-in commands are not flagged as skills', () => { - const help = buildSlashItems(skills).find((i) => i.name === '/help'); - expect(help?.isSkill).toBeUndefined(); - }); - - it('filters built-ins and skills together by substring', () => { - const items = buildSlashItems(skills); - const research = filterCommands('/deep', items); - expect(research.map((i) => i.name)).toEqual(['/deep-research']); - }); - - it('matching a skill substring excludes unrelated built-ins', () => { - const items = buildSlashItems(skills); - const brain = filterCommands('/brain', items); - expect(brain.every((i) => i.isSkill)).toBe(true); - expect(brain.map((i) => i.name)).toContain('/brainstorm'); - }); - - it('empty/slash query returns everything', () => { - const items = buildSlashItems(skills); - expect(filterCommands('/', items).length).toBe(items.length); - }); - - it('flags session skills as accepting input so they stay in the composer', () => { - const items = buildSlashItems(skills); - const brainstorm = items.find((i) => i.name === '/brainstorm'); - expect(brainstorm?.acceptsInput).toBe(true); - }); - - it('matches substrings anywhere in the command name, not only as a prefix', () => { - const items = buildSlashItems(skills); - expect(filterCommands('/context', items).map((i) => i.name)).toEqual([ - '/xxx-context', - ]); - expect(filterCommands('/research', items).map((i) => i.name)).toEqual([ - '/deep-research', - ]); - }); - - it('ranks exact and prefix matches ahead of substring matches', () => { - const items = buildSlashItems([{ name: 'log', description: 'Write a log' }]); - const names = filterCommands('/log', items).map((i) => i.name); - expect(names[0]).toBe('/log'); - expect(names).toContain('/login'); - }); -}); diff --git a/apps/kimi-web/test/start-session-and-send.test.ts b/apps/kimi-web/test/start-session-and-send.test.ts deleted file mode 100644 index 5d542e2cb3..0000000000 --- a/apps/kimi-web/test/start-session-and-send.test.ts +++ /dev/null @@ -1,379 +0,0 @@ -// apps/kimi-web/test/start-session-and-send.test.ts -// -// startSessionAndSendPrompt: when there is no active session (e.g. after clicking -// "+"), sending a message should create the session first, then submit the prompt. -// The session list must never contain duplicates regardless of whether the REST -// create response or the WebSocket sessionCreated broadcast arrives first. - -import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { - AppSession, - AppSessionSnapshot, - KimiEventHandlers, - KimiWebApi, -} from '../src/api/types'; - -const now = '2026-06-11T00:00:00.000Z'; - -function makeSession(id: string, overrides?: Partial): AppSession { - return { - id, - title: id, - createdAt: now, - updatedAt: now, - status: 'idle', - cwd: '/repo', - model: 'kimi-test', - usage: { - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheCreationTokens: 0, - totalCostUsd: 0, - contextTokens: 0, - contextLimit: 128_000, - turnCount: 0, - }, - messageCount: 0, - lastSeq: 0, - ...overrides, - }; -} - -async function setup() { - vi.resetModules(); - vi.stubGlobal('WebSocket', class WebSocket {}); - - let handlers: KimiEventHandlers | undefined; - const eventConn = { - subscribe: vi.fn(), - unsubscribe: vi.fn(), - bindNextPromptId: vi.fn(), - seedSnapshot: vi.fn(), - abort: vi.fn(), - close: vi.fn(), - }; - - const created = makeSession('sess_new'); - const api = { - createSession: vi.fn(async () => created), - submitPrompt: vi.fn(async () => ({ promptId: 'pr_1', userMessageId: 'msg_real' })), - addWorkspace: vi.fn(async () => ({ id: 'ws_repo', root: '/repo', name: 'repo', isGitRepo: false, sessionCount: 0 })), - deleteWorkspace: vi.fn(async () => ({ deleted: true })), - listWorkspaces: vi.fn(async () => []), - browseFs: vi.fn(async (path?: string) => ({ path: path ?? '/home/user', parent: null, entries: [] })), - getFsHome: vi.fn(async () => ({ home: '/home/user', recentRoots: [] })), - listSessions: vi.fn(async () => ({ items: [], hasMore: false })), - getHealth: vi.fn(async () => ({ ok: true })), - getMeta: vi.fn(async () => ({ daemonVersion: '0.0.1' })), - getSessionStatus: vi.fn(async () => ({ - model: 'kimi-test', - thinkingLevel: 'high', - permission: 'manual', - planMode: false, - swarmMode: false, - contextTokens: 0, - maxContextTokens: 128_000, - contextUsage: 0, - })), - getSessionSnapshot: vi.fn(async () => ({ - asOfSeq: 0, - epoch: 'ep_test', - session: created, - messages: [], - hasMoreMessages: false, - inFlightTurn: null, - pendingApprovals: [], - pendingQuestions: [], - })), - listTasks: vi.fn(async () => []), - getGitStatus: vi.fn(async () => ({ branch: 'main', ahead: 0, behind: 0, entries: {}, additions: 0, deletions: 0 })), - connectEvents: vi.fn((nextHandlers: KimiEventHandlers) => { - handlers = nextHandlers; - return eventConn; - }), - getFileUrl: vi.fn((fileId: string) => `/files/${fileId}`), - } as unknown as KimiWebApi; - - vi.doMock('../src/api', () => ({ getKimiWebApi: () => api })); - const { useKimiWebClient } = await import('../src/composables/useKimiWebClient'); - - return { - api, - client: useKimiWebClient(), - eventConn, - getHandlers: () => { - if (!handlers) throw new Error('connectEvents was not called'); - return handlers; - }, - }; -} - -afterEach(() => { - vi.unstubAllGlobals(); - vi.resetModules(); - vi.clearAllMocks(); -}); - -describe('startSessionAndSendPrompt', () => { - it('creates a session then submits the prompt in one flow', async () => { - const { api, client } = await setup(); - await client.addWorkspaceByPath('/repo'); - - await client.startSessionAndSendPrompt('ws_repo', 'hello world'); - - expect(api.createSession).toHaveBeenCalledTimes(1); - expect(api.createSession).toHaveBeenCalledWith( - expect.objectContaining({ workspaceId: 'ws_repo', cwd: '/repo' }), - ); - expect(api.submitPrompt).toHaveBeenCalledTimes(1); - expect(api.submitPrompt).toHaveBeenCalledWith( - 'sess_new', - expect.objectContaining({ content: [{ type: 'text', text: 'hello world' }] }), - ); - expect(client.activeSessionId.value).toBe('sess_new'); - expect(client.sessions.value).toHaveLength(1); - expect(client.sessions.value[0]!.id).toBe('sess_new'); - }); - - it('keeps sessionLoading true while the snapshot is in flight (no empty-composer flash)', async () => { - const { api, client } = await setup(); - await client.addWorkspaceByPath('/repo'); - - // Hold the snapshot open so we can observe the state between selecting the - // freshly created session and the user's message landing. - let resolveSnap!: (value: AppSessionSnapshot) => void; - vi.mocked(api.getSessionSnapshot).mockImplementation( - () => new Promise((resolve) => { resolveSnap = resolve; }), - ); - - const flow = client.startSessionAndSendPrompt('ws_repo', 'hello world'); - - // Wait until selectSession reaches the snapshot fetch. - await vi.waitFor(() => expect(api.getSessionSnapshot).toHaveBeenCalledTimes(1)); - - // The new session is active but its snapshot has not returned yet. The - // empty-conversation composer renders only when `turns.length === 0 && - // !sessionLoading`; sessionLoading MUST stay true here so it does not flash - // before the optimistic user message arrives. - expect(client.activeSessionId.value).toBe('sess_new'); - expect(client.sessionLoading.value).toBe(true); - - resolveSnap({ - asOfSeq: 0, - epoch: 'ep_test', - session: makeSession('sess_new'), - messages: [], - hasMoreMessages: false, - inFlightTurn: null, - pendingApprovals: [], - pendingQuestions: [], - }); - await flow; - - // Loading cleared and the user's message was submitted + shown optimistically. - expect(client.sessionLoading.value).toBe(false); - expect(api.submitPrompt).toHaveBeenCalledTimes(1); - expect(client.turns.value.some((t) => t.role === 'user')).toBe(true); - }); - - it('applies a model picked in the draft state (no session yet) to the created session', async () => { - const { api, client } = await setup(); - await client.addWorkspaceByPath('/repo'); - - // Onboarding composer: no active session — the pick must still register. - expect(client.activeSessionId.value).toBeFalsy(); - await client.setModel('provider/kimi-next'); - - // The dropdown reflects the draft pick immediately (not the daemon default). - expect(client.status.value.modelId).toBe('provider/kimi-next'); - - await client.startSessionAndSendPrompt('ws_repo', 'hello'); - - expect(api.createSession).toHaveBeenCalledWith( - expect.objectContaining({ model: 'provider/kimi-next' }), - ); - }); - - it('does not duplicate the session when WebSocket broadcast arrives after REST', async () => { - const { api, client, getHandlers } = await setup(); - await client.addWorkspaceByPath('/repo'); - - await client.startSessionAndSendPrompt('ws_repo', 'hello'); - - // Simulate the late WebSocket sessionCreated broadcast - getHandlers().onEvent( - { type: 'sessionCreated', session: makeSession('sess_new') }, - { sessionId: 'sess_new', seq: 1 }, - ); - - expect(client.sessions.value).toHaveLength(1); - expect(client.sessions.value[0]!.id).toBe('sess_new'); - }); - - it('does not duplicate the session when WebSocket broadcast arrives before REST', async () => { - const { client, getHandlers } = await setup(); - await client.addWorkspaceByPath('/repo'); - - // Establish the event connection first - await client.startSessionAndSendPrompt('ws_repo', 'first'); - - // Broadcast the same session (simulating WS arriving before REST) - getHandlers().onEvent( - { type: 'sessionCreated', session: makeSession('sess_new') }, - { sessionId: 'sess_new', seq: 1 }, - ); - - // Now REST returns — calling startSessionAndSendPrompt again with the same id. - // The upsert filter in the method removes the duplicate. - await client.startSessionAndSendPrompt('ws_repo', 'hello'); - - expect(client.sessions.value.filter((s) => s.id === 'sess_new')).toHaveLength(1); - }); -}); - -describe('plan mode sync from the agent', () => { - it('activates the composer plan toggle when the agent reports plan mode', async () => { - const { client, getHandlers } = await setup(); - await client.addWorkspaceByPath('/repo'); - await client.startSessionAndSendPrompt('ws_repo', 'enter plan mode and write hello.ts'); - - expect(client.planMode.value).toBe(false); - - // The agent auto-entered plan mode and reports it via agent.status.updated, - // which the projector forwards on sessionUsageUpdated. - getHandlers().onEvent( - { - type: 'sessionUsageUpdated', - sessionId: 'sess_new', - usage: makeSession('sess_new').usage, - planMode: true, - }, - { sessionId: 'sess_new', seq: 2 }, - ); - - expect(client.planMode.value).toBe(true); - }); - - it('ignores plan/swarm mode updates from a background session', async () => { - const { client, getHandlers } = await setup(); - await client.addWorkspaceByPath('/repo'); - await client.startSessionAndSendPrompt('ws_repo', 'active session prompt'); - - expect(client.planMode.value).toBe(false); - expect(client.swarmMode.value).toBe(false); - - getHandlers().onEvent( - { - type: 'sessionUsageUpdated', - sessionId: 'sess_background', - usage: makeSession('sess_background').usage, - planMode: true, - swarmMode: true, - }, - { sessionId: 'sess_background', seq: 3 }, - ); - - expect(client.planMode.value).toBe(false); - expect(client.swarmMode.value).toBe(false); - }); -}); - -describe('openWorkspaceDraft', () => { - it('clears activeSessionId without removing sessions', async () => { - const { client } = await setup(); - await client.addWorkspaceByPath('/repo'); - await client.createSession('/repo'); - - expect(client.activeSessionId.value).toBe('sess_new'); - expect(client.sessions.value).toHaveLength(1); - - client.openWorkspaceDraft('ws_repo'); - - expect(client.activeSessionId.value).toBe(''); - expect(client.sessions.value).toHaveLength(1); - expect(client.activeWorkspaceId.value).toBe('ws_repo'); - }); - - it('clears the active session when the active workspace is removed', async () => { - const { api, client } = await setup(); - await client.addWorkspaceByPath('/repo'); - await client.startSessionAndSendPrompt('ws_repo', 'hello'); - - expect(client.activeSessionId.value).toBe('sess_new'); - expect(client.activeWorkspaceId.value).toBe('ws_repo'); - - await client.deleteWorkspace('ws_repo'); - - expect(api.deleteWorkspace).toHaveBeenCalledWith('ws_repo'); - expect(client.activeSessionId.value).toBe(''); - expect(client.activeWorkspaceId.value).toBeNull(); - expect(client.sessions.value).toHaveLength(1); - }); -}); - -describe('folder browser fallback', () => { - it('returns an empty path when browseFs fails so the dialog can fall back', async () => { - const { api, client } = await setup(); - (api.browseFs as ReturnType).mockRejectedValueOnce(new Error('fs browse unavailable')); - - await expect(client.browseFs('/repo')).resolves.toEqual({ - path: '', - parent: null, - entries: [], - }); - }); -}); - -describe('createSession dedup', () => { - it('createSession does not duplicate when broadcast arrived first', async () => { - const { api, client, getHandlers } = await setup(); - - // Establish the event connection first - await client.createSession('/repo'); - - // Now hijack createSession for the race test - let resolveCreate!: (s: AppSession) => void; - const createPromise = new Promise((r) => { - resolveCreate = r; - }); - (api.createSession as ReturnType).mockReturnValue(createPromise); - - const promise = client.createSession('/repo'); - - // Broadcast arrives first - getHandlers().onEvent( - { type: 'sessionCreated', session: makeSession('sess_new') }, - { sessionId: 'sess_new', seq: 1 }, - ); - - resolveCreate(makeSession('sess_new')); - await promise; - - // Should still be just the original session (no duplicate) - expect(client.sessions.value.filter((s) => s.id === 'sess_new')).toHaveLength(1); - }); -}); - -describe('createSessionInWorkspace dedup', () => { - it('createSessionInWorkspace does not duplicate when broadcast arrived first', async () => { - const { api, client, getHandlers } = await setup(); - await client.addWorkspaceByPath('/repo'); - - // Establish the event connection first - await client.createSessionInWorkspace('ws_repo'); - - // Broadcast the same session (simulating WS arriving before REST) - getHandlers().onEvent( - { type: 'sessionCreated', session: makeSession('sess_new', { workspaceId: 'ws_repo' }) }, - { sessionId: 'sess_new', seq: 1 }, - ); - - // Now REST returns — calling createSessionInWorkspace again with the same id. - // The upsert filter in the method removes the duplicate. - await client.createSessionInWorkspace('ws_repo'); - - // Should still be just the original session (no duplicate) - expect(client.sessions.value.filter((s) => s.id === 'sess_new')).toHaveLength(1); - }); -}); diff --git a/apps/kimi-web/test/steer.test.ts b/apps/kimi-web/test/steer.test.ts deleted file mode 100644 index 37dd5e0d46..0000000000 --- a/apps/kimi-web/test/steer.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -// apps/kimi-web/test/steer.test.ts -// -// steerPrompt (TUI ctrl+s parity): while a turn is running, the composer text -// plus any locally queued prompts merge into ONE message that is submitted -// (daemon parks it) and then steered into the active turn. When the session is -// idle it degrades to a normal send. - -import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { AppSession, KimiEventHandlers, KimiWebApi } from '../src/api/types'; - -const now = '2026-06-11T00:00:00.000Z'; - -function session(id: string): AppSession { - return { - id, - title: id, - createdAt: now, - updatedAt: now, - status: 'idle', - cwd: '/repo', - model: 'kimi-test', - usage: { - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheCreationTokens: 0, - totalCostUsd: 0, - contextTokens: 0, - contextLimit: 128_000, - turnCount: 0, - }, - messageCount: 0, - lastSeq: 0, - }; -} - -async function setup(opts?: { submitStatuses?: ('running' | 'queued')[] }) { - vi.resetModules(); - vi.stubGlobal('WebSocket', class WebSocket {}); - - let handlers: KimiEventHandlers | undefined; - const eventConn = { - subscribe: vi.fn(), - unsubscribe: vi.fn(), - bindNextPromptId: vi.fn(), - seedSnapshot: vi.fn(), - abort: vi.fn(), - close: vi.fn(), - }; - const statuses = [...(opts?.submitStatuses ?? [])]; - let promptN = 0; - const created = session('sess_1'); - const api = { - createSession: vi.fn(async () => created), - getSessionSnapshot: vi.fn(async () => ({ - asOfSeq: 0, - epoch: 'ep_test', - session: created, - messages: [], - hasMoreMessages: false, - inFlightTurn: null, - pendingApprovals: [], - pendingQuestions: [], - })), - submitPrompt: vi.fn(async () => { - promptN += 1; - return { - promptId: `pr_${promptN}`, - userMessageId: `msg_real_${promptN}`, - status: statuses.shift() ?? 'running', - }; - }), - steerPrompts: vi.fn(async (_sid: string, ids: string[]) => ({ steered: true, promptIds: ids })), - listTasks: vi.fn(async () => []), - getGitStatus: vi.fn(async () => ({ branch: 'main', ahead: 0, behind: 0, entries: {}, additions: 0, deletions: 0 })), - getSessionStatus: vi.fn(async () => ({ - model: 'kimi-test', - thinkingLevel: 'high', - permission: 'manual', - planMode: false, - swarmMode: false, - contextTokens: 0, - maxContextTokens: 128_000, - contextUsage: 0, - })), - connectEvents: vi.fn((nextHandlers: KimiEventHandlers) => { - handlers = nextHandlers; - return eventConn; - }), - getFileUrl: vi.fn((fileId: string) => `/files/${fileId}`), - } as unknown as KimiWebApi; - - vi.doMock('../src/api', () => ({ getKimiWebApi: () => api })); - const { useKimiWebClient } = await import('../src/composables/useKimiWebClient'); - - return { - api, - client: useKimiWebClient(), - getHandlers: () => { - if (!handlers) throw new Error('connectEvents was not called'); - return handlers; - }, - }; -} - -afterEach(() => { - vi.unstubAllGlobals(); - vi.resetModules(); - vi.clearAllMocks(); -}); - -describe('steerPrompt', () => { - it('submits then steers the parked prompt while a turn is running', async () => { - const { api, client } = await setup({ submitStatuses: ['running', 'queued'] }); - await client.createSession('/repo'); - await client.sendPrompt('first'); // turn in flight - await client.steerPrompt('change of plan'); // steer into it - - expect(api.submitPrompt).toHaveBeenCalledTimes(2); - expect(api.steerPrompts).toHaveBeenCalledWith('sess_1', ['pr_2']); - // The steered text shows up in the transcript like any user message. - const userTurns = client.turns.value.filter((t) => t.role === 'user'); - expect(userTurns.map((t) => t.text)).toEqual(['first', 'change of plan']); - }); - - it('carries an image attachment into the steered prompt and the transcript echo', async () => { - const { api, client } = await setup({ submitStatuses: ['running', 'queued'] }); - await client.createSession('/repo'); - await client.sendPrompt('first'); // turn in flight - await client.steerPrompt('look at this', [{ fileId: 'file_1', kind: 'image' }]); - - // The image rides the steered prompt's content alongside the text. - const steered = (api.submitPrompt as ReturnType).mock.calls[1]![1] as { - content: { type: string; text?: string; source?: { kind: string; fileId: string } }[]; - }; - expect(steered.content).toEqual([ - { type: 'text', text: 'look at this' }, - { type: 'image', source: { kind: 'file', fileId: 'file_1' } }, - ]); - - // The optimistic transcript echo shows the image too. - const lastUser = client.turns.value.filter((t) => t.role === 'user').at(-1)!; - expect(lastUser.images).toEqual([{ url: '/files/file_1', alt: undefined, kind: 'image' }]); - }); - - it('carries a video attachment as a video content block and a video echo', async () => { - const { api, client } = await setup({ submitStatuses: ['running', 'queued'] }); - await client.createSession('/repo'); - await client.sendPrompt('first'); - await client.steerPrompt('watch this', [{ fileId: 'clip_1', kind: 'video' }]); - - // A video attachment serializes to a `video` content block (not `image`). - const steered = (api.submitPrompt as ReturnType).mock.calls[1]![1] as { - content: { type: string; text?: string; source?: { kind: string; fileId: string } }[]; - }; - expect(steered.content).toEqual([ - { type: 'text', text: 'watch this' }, - { type: 'video', source: { kind: 'file', fileId: 'clip_1' } }, - ]); - - // The transcript echo carries the video kind so the bubble renders