From 3759f76e9038e15cf7f16da03338f7aba5453b37 Mon Sep 17 00:00:00 2001 From: murashit Date: Fri, 26 Jun 2026 11:33:25 +0900 Subject: [PATCH] Trim chat view connection integration tests --- tests/features/chat/view-connection.test.ts | 414 ++------------------ 1 file changed, 26 insertions(+), 388 deletions(-) diff --git a/tests/features/chat/view-connection.test.ts b/tests/features/chat/view-connection.test.ts index b640a15c..8616e3cd 100644 --- a/tests/features/chat/view-connection.test.ts +++ b/tests/features/chat/view-connection.test.ts @@ -4,7 +4,6 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite import type { ServerNotification } from "../../../src/app-server/connection/rpc-messages"; import { modelMetadataFromCatalogModels } from "../../../src/app-server/protocol/catalog"; import type { ThreadRecord } from "../../../src/app-server/protocol/thread"; -import { StaleAppServerSharedQueryContextError } from "../../../src/app-server/query/shared-queries"; import type { ModelMetadata } from "../../../src/domain/catalog/metadata"; import type { ObservedDataResult } from "../../../src/domain/observed-data"; import { emptyRuntimeConfigSnapshot } from "../../../src/domain/runtime/config"; @@ -18,9 +17,13 @@ import { notices } from "../../mocks/obsidian"; import { deferred, waitForAsyncWork } from "../../support/async"; import { installObsidianDomShims } from "../../support/dom"; -const ESTIMATED_MESSAGE_BLOCK_HEIGHT = 96; type TestCodexChatHost = CodexChatHost; let CodexChatView: typeof import("../../../src/features/chat/host/view")["CodexChatView"]; +interface TrackedView { + view: { onClose(): Promise | void }; + opened: boolean; +} +let createdViews: TrackedView[] = []; const connectionMock = vi.hoisted(() => { const state = { @@ -122,7 +125,11 @@ describe("CodexChatView connection lifecycle", () => { restoreDefaultMessageViewportMetrics = mockMessageViewportOffsetMetrics({ clientHeight: 320, clientWidth: 240 }); }); - afterEach(() => { + afterEach(async () => { + for (const entry of createdViews.reverse()) { + if (entry.opened) await entry.view.onClose(); + } + createdViews = []; restoreDefaultMessageViewportMetrics?.(); restoreDefaultMessageViewportMetrics = null; document.body.replaceChildren(); @@ -173,48 +180,6 @@ describe("CodexChatView connection lifecycle", () => { expect(client.listThreads).toHaveBeenCalledOnce(); }); - it("refreshes shared thread lists after connecting", async () => { - const refresh = vi - .fn() - .mockResolvedValue([{ id: "thread-1", preview: "Restored thread", name: null, archived: false, createdAt: 1, updatedAt: 1 }]); - const client = connectedClient({ - listThreads: vi.fn(), - }); - connectionMock.state.client = client; - const view = await chatView({ - host: chatHost({ refreshActive: refresh }), - }); - - await view.onOpen(); - await view.surface.connect(); - - expect(refresh).toHaveBeenCalledOnce(); - expect(client.listThreads).not.toHaveBeenCalled(); - requiredButton(view.containerEl, '[aria-label="Show thread list"]').click(); - await waitForAsyncWork(() => { - expect(view.containerEl.textContent).toContain("Restored thread"); - }); - }); - - it("ignores stale shared thread refreshes after connecting", async () => { - const refresh = vi.fn().mockRejectedValue(new StaleAppServerSharedQueryContextError()); - connectionMock.state.client = connectedClient({ - listThreads: vi.fn(), - }); - const view = await chatView({ - host: chatHost({ refreshActive: refresh }), - }); - - await view.onOpen(); - await view.surface.connect(); - - expect(refresh).toHaveBeenCalledOnce(); - expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: true }); - expect(notices).toEqual([]); - requiredButton(view.containerEl, '[aria-label="Show thread list"]').click(); - expect(view.containerEl.textContent).not.toContain("stale"); - }); - it("loads app-server metadata after connecting", async () => { connectionMock.state.client = connectedClient(); const view = await chatView(); @@ -522,32 +487,6 @@ describe("CodexChatView connection lifecycle", () => { expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" }); }); - it("does not revive a completed turn when the startTurn response arrives late", async () => { - const startTurn = deferred<{ turn: { id: string } }>(); - const client = connectedClient({ - startTurn: vi.fn(() => startTurn.promise), - }); - connectionMock.state.client = client; - const view = await chatView(); - - await view.onOpen(); - await view.surface.openThread("thread-1"); - view.surface.setComposerText("hello"); - await submitComposerByEnter(view); - await waitForAsyncWork(() => { - expect(client.startTurn).toHaveBeenCalled(); - }); - - connectionMock.state.onNotification?.(turnStartedNotification("thread-1", "turn-1")); - connectionMock.state.onNotification?.(turnCompletedNotification("thread-1", "turn-1")); - expect(view.surface.openPanelSnapshot()).toMatchObject({ turnLifecycle: { kind: "idle" } }); - - startTurn.resolve({ turn: { id: "turn-1" } }); - await flushAsyncTicks(); - - expect(view.surface.openPanelSnapshot()).toMatchObject({ turnLifecycle: { kind: "idle" } }); - }); - it("requests a workspace layout save after resuming a thread", async () => { const requestSaveLayout = vi.fn(); const client = connectedClient(); @@ -591,144 +530,6 @@ describe("CodexChatView connection lifecycle", () => { expect(focus).toHaveBeenCalledWith({ preventScroll: true }); }); - it("does not start a thread when /clear is given arguments", async () => { - const client = connectedClient({ - startThread: vi.fn().mockResolvedValue(startedThread("thread-new")), - }); - connectionMock.state.client = client; - const view = await chatView(); - - await view.onOpen(); - await view.onOpen(); - await view.surface.connect(); - view.surface.setComposerText("/clear hello"); - await submitComposerByEnter(view); - - expect(client.startThread).not.toHaveBeenCalled(); - expect(client.startTurn).not.toHaveBeenCalled(); - }); - - it("focuses an open panel instead of resuming a duplicate slash resume thread", async () => { - const focusThreadInOpenView = vi.fn().mockResolvedValue(true); - const host = chatHost({ - focusThreadInOpenView, - }); - connectionMock.state.client = connectedClient({ - listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("thread-1")] }), - }); - const view = await chatView({ host }); - - await view.onOpen(); - await view.surface.connect(); - view.surface.setComposerText("/resume thread-1"); - await submitComposerByEnter(view); - - expect(focusThreadInOpenView).toHaveBeenCalledWith("thread-1"); - expect(connectionMock.state.client["resumeThread"]).not.toHaveBeenCalled(); - }); - - it("resumes slash resume threads in the current panel when they are not already open", async () => { - const focusThreadInOpenView = vi.fn().mockResolvedValue(false); - const client = connectedClient({ - listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("thread-1")] }), - threadTurnsList: vi.fn().mockResolvedValue({ data: [turnWithUserMessage("restored prompt")], nextCursor: null }), - }); - connectionMock.state.client = client; - const view = await chatView({ - host: chatHost({ - focusThreadInOpenView, - }), - }); - - await view.onOpen(); - await view.surface.connect(); - view.surface.setComposerText("/resume thread-1"); - await submitComposerByEnter(view); - - expect(focusThreadInOpenView).toHaveBeenCalledWith("thread-1"); - expect(client.resumeThread).toHaveBeenCalledWith("thread-1", "/vault"); - }); - - it("keeps resumed messages pinned to bottom after slash resume in the same empty panel", async () => { - const client = connectedClient({ - listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("thread-1")] }), - threadTurnsList: vi.fn().mockResolvedValue({ data: [turnWithUserMessage("restored prompt")], nextCursor: null }), - }); - connectionMock.state.client = client; - const view = await chatView(); - - await view.onOpen(); - await view.surface.connect(); - const messages = view.containerEl.querySelector(".codex-panel__messages"); - expect(messages).not.toBeNull(); - if (!messages) return; - const restoreMessagesLayout = mockMessagesLayout({ scrollHeight: ESTIMATED_MESSAGE_BLOCK_HEIGHT, clientHeight: 100 }); - messages.scrollTop = 0; - - view.surface.setComposerText("/resume thread-1"); - await submitComposerByEnter(view); - await waitForMessagesFrame(messages); - await waitForMessagesFrame(messages); - - const renderedMessages = view.containerEl.querySelector(".codex-panel__messages"); - expect(renderedMessages?.scrollTop).toBe(0); - restoreMessagesLayout(); - }); - - it("routes slash archive through shared panel notifications", async () => { - const applyThreadCatalogEvent = vi.fn(); - const host = chatHost({ - applyThreadCatalogEvent, - }); - const client = connectedClient({ - listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("thread-1")] }), - }); - connectionMock.state.client = client; - const view = await chatView({ host }); - - await view.onOpen(); - await view.surface.connect(); - view.surface.setComposerText("/archive thread-1"); - await submitComposerByEnter(view); - - expect(client.archiveThread).toHaveBeenCalledWith("thread-1"); - expect(applyThreadCatalogEvent).toHaveBeenCalledWith({ type: "thread-archived", threadId: "thread-1" }); - }); - - it("replaces the current panel with the forked thread after fork and archive", async () => { - const applyThreadCatalogEvent = vi.fn(); - const host = chatHost({ applyThreadCatalogEvent }); - const client = connectedClient({ - listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("source")] }), - resumeThread: vi.fn((threadId: string) => Promise.resolve(resumedThread(threadId))), - threadTurnsList: vi.fn().mockResolvedValue({ data: [completedTurn("turn-1")], nextCursor: null }), - forkThread: vi.fn().mockResolvedValue({ thread: threadFixture("forked") }), - }); - connectionMock.state.client = client; - const view = await chatView({ host }); - - await view.onOpen(); - await view.surface.connect(); - await view.surface.openThread("source"); - await waitForAsyncWork(() => { - expect(view.containerEl.textContent).toContain("done"); - }); - requiredButton(view.containerEl, ".codex-panel__fork-message").click(); - await waitForAsyncWork(() => { - expect(view.containerEl.querySelector(".codex-panel__fork-and-archive-message")).not.toBeNull(); - }); - requiredButton(view.containerEl, ".codex-panel__fork-and-archive-message").click(); - - await waitForAsyncWork(() => { - expect(client.forkThread).toHaveBeenCalledWith("source", "/vault"); - expect(client.archiveThread).toHaveBeenCalledWith("source"); - expect(client.resumeThread).toHaveBeenLastCalledWith("forked", "/vault"); - expect(view.getState()).toEqual({ version: 1, threadId: "forked", threadTitle: "Restored thread" }); - expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "forked" }); - expect(applyThreadCatalogEvent).toHaveBeenCalledWith({ type: "thread-archived", threadId: "source" }); - }); - }); - it("clears the active thread when another view archives it", async () => { const requestSaveLayout = vi.fn(); const client = connectedClient(); @@ -795,74 +596,6 @@ describe("CodexChatView connection lifecycle", () => { }); }); - it("scrolls resumed messages to the bottom after history hydrates", async () => { - const client = connectedClient({ - threadTurnsList: vi.fn().mockResolvedValue({ data: [turnWithUserMessage("restored prompt")], nextCursor: null }), - }); - connectionMock.state.client = client; - const view = await chatView(); - - await view.onOpen(); - const messages = view.containerEl.querySelector(".codex-panel__messages"); - expect(messages).not.toBeNull(); - if (!messages) return; - const restoreMessagesLayout = mockMessagesLayout({ scrollHeight: ESTIMATED_MESSAGE_BLOCK_HEIGHT, clientHeight: 100 }); - messages.scrollTop = 0; - - await view.surface.openThread("thread-1"); - await waitForMessagesFrame(messages); - await waitForMessagesFrame(messages); - - const renderedMessages = view.containerEl.querySelector(".codex-panel__messages"); - expect(renderedMessages?.scrollTop).toBe(0); - restoreMessagesLayout(); - }); - - it("leaves focused thread scroll position to the message stream", async () => { - const client = connectedClient({ - threadTurnsList: vi.fn().mockResolvedValue({ data: [turnWithUserMessage("restored prompt")], nextCursor: null }), - }); - connectionMock.state.client = client; - const view = await chatView(); - const restoreMessagesLayout = mockMessagesLayout({ scrollHeight: 1_000, clientHeight: 100 }); - - await view.onOpen(); - await view.surface.openThread("thread-1"); - const messages = view.containerEl.querySelector(".codex-panel__messages"); - expect(messages).not.toBeNull(); - if (!messages) return; - messages.scrollTop = 0; - - await view.surface.focusThread("thread-1"); - - expect(messages.scrollTop).toBe(0); - restoreMessagesLayout(); - }); - - it("leaves scroll position to the message stream when its leaf is focused", async () => { - const activeLeafChangeListeners: ((leaf: unknown) => void)[] = []; - const client = connectedClient({ - threadTurnsList: vi.fn().mockResolvedValue({ data: [turnWithUserMessage("restored prompt")], nextCursor: null }), - }); - connectionMock.state.client = client; - const view = await chatView({ activeLeafChangeListeners }); - const restoreMessagesLayout = mockMessagesLayout({ scrollHeight: 1_000, clientHeight: 100 }); - - await view.onOpen(); - await view.surface.openThread("thread-1"); - const messages = view.containerEl.querySelector(".codex-panel__messages"); - expect(messages).not.toBeNull(); - if (!messages) return; - messages.scrollTop = 0; - - activeLeafChangeListeners.forEach((listener) => { - listener((view as unknown as { leaf: unknown }).leaf); - }); - - expect(messages.scrollTop).toBe(0); - restoreMessagesLayout(); - }); - it("renders resumed thread metadata before history hydration completes", async () => { const history = deferred<{ data: unknown[]; nextCursor: null }>(); const client = connectedClient({ @@ -964,31 +697,6 @@ describe("CodexChatView connection lifecycle", () => { expect(view.getState()).toEqual({ version: 1, threadId: "thread-2", threadTitle: "Restored thread" }); expect(view.containerEl.textContent).not.toContain("first prompt"); }); - - it("scrolls restored messages to the bottom when the panel is focused", async () => { - const activeLeafChangeListeners: ((leaf: unknown) => void)[] = []; - const view = await chatView({ activeLeafChangeListeners }); - - await view.onOpen(); - const messages = view.containerEl.querySelector(".codex-panel__messages"); - expect(messages).not.toBeNull(); - if (!messages) return; - Object.defineProperty(messages, "scrollHeight", { value: ESTIMATED_MESSAGE_BLOCK_HEIGHT, configurable: true }); - Object.defineProperty(messages, "clientHeight", { value: 100, configurable: true }); - messages.scrollTop = 0; - - activeLeafChangeListeners.forEach((listener) => { - listener(view.leaf); - }); - await new Promise((resolve) => { - messages.win.requestAnimationFrame(() => { - resolve(); - }); - }); - - const renderedMessages = view.containerEl.querySelector(".codex-panel__messages"); - expect(renderedMessages?.scrollTop).toBe(0); - }); }); function connectedClient(overrides: Partial> = {}): ReturnType { @@ -1140,47 +848,6 @@ function completedTurn(turnId: string) { }; } -function turnStartedNotification(threadId: string, turnId: string): Extract { - return { - method: "turn/started", - params: { - threadId, - turn: { - id: turnId, - status: "inProgress", - error: null, - startedAt: 1, - completedAt: null, - durationMs: null, - itemsView: "full", - items: [], - }, - }, - }; -} - -function turnCompletedNotification(threadId: string, turnId: string): Extract { - return { - method: "turn/completed", - params: { - threadId, - turn: { - id: turnId, - status: "completed", - error: null, - startedAt: 1, - completedAt: 2, - durationMs: 1, - itemsView: "full", - items: [ - { type: "userMessage", id: "user-1", clientId: null, content: [{ type: "text", text: "hello", text_elements: [] }] }, - { type: "agentMessage", id: "agent-1", text: "done", phase: "final_answer", memoryCitation: null }, - ], - }, - }, - }; -} - function composerElement(view: { containerEl: HTMLElement }): HTMLTextAreaElement { const composer = view.containerEl.querySelector(".codex-panel__composer-input"); if (!composer) throw new Error("Expected composer input"); @@ -1191,43 +858,6 @@ function composerPlaceholder(view: { containerEl: HTMLElement }): string | null return composerElement(view).getAttribute("placeholder"); } -function waitForMessagesFrame(messages: HTMLElement): Promise { - return new Promise((resolve) => { - messages.win.requestAnimationFrame(() => { - resolve(); - }); - }); -} - -function mockMessagesLayout(metrics: { scrollHeight: number; clientHeight: number }): () => void { - const scrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight"); - const clientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight"); - const offsetHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); - Object.defineProperty(HTMLElement.prototype, "scrollHeight", { - configurable: true, - get() { - return this instanceof HTMLElement && this.classList.contains("codex-panel__messages") ? metrics.scrollHeight : 0; - }, - }); - Object.defineProperty(HTMLElement.prototype, "clientHeight", { - configurable: true, - get() { - return this instanceof HTMLElement && this.classList.contains("codex-panel__messages") ? metrics.clientHeight : 0; - }, - }); - Object.defineProperty(HTMLElement.prototype, "offsetHeight", { - configurable: true, - get() { - return this instanceof HTMLElement && this.classList.contains("codex-panel__messages") ? metrics.clientHeight : 0; - }, - }); - return () => { - restorePrototypeProperty(HTMLElement.prototype, "scrollHeight", scrollHeightDescriptor); - restorePrototypeProperty(HTMLElement.prototype, "clientHeight", clientHeightDescriptor); - restorePrototypeProperty(HTMLElement.prototype, "offsetHeight", offsetHeightDescriptor); - }; -} - function mockMessageViewportOffsetMetrics(metrics: { clientHeight: number; clientWidth: number }): () => void { const offsetHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); const offsetWidthDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"); @@ -1468,25 +1098,20 @@ function queryResult(data: T | null): ObservedDataResult { }; } -async function chatView( - options: { activeLeafChangeListeners?: ((leaf: unknown) => void)[]; host?: CodexChatHost; requestSaveLayout?: () => void } = {}, -) { +async function chatView(options: { host?: CodexChatHost; requestSaveLayout?: () => void } = {}) { const host = options.host ?? chatHost(); const containerEl = document.createElement("div"); document.body.appendChild(containerEl); containerEl.createDiv(); containerEl.createDiv(); - return new CodexChatView( + const view = new CodexChatView( { app: { workspace: { getActiveFile: vi.fn(() => null), getActiveViewOfType: vi.fn(() => null), getLastOpenFiles: vi.fn(() => []), - on: vi.fn((eventName: string, callback: (leaf: unknown) => void) => { - if (eventName === "active-leaf-change") options.activeLeafChangeListeners?.push(callback); - return {}; - }), + on: vi.fn(() => ({})), openLinkText: vi.fn(), requestSaveLayout: options.requestSaveLayout ?? vi.fn(), }, @@ -1509,4 +1134,17 @@ async function chatView( } as never, host, ); + const tracked: TrackedView = { view, opened: false }; + const onOpen = view.onOpen.bind(view); + const onClose = view.onClose.bind(view); + view.onOpen = async () => { + tracked.opened = true; + await onOpen(); + }; + view.onClose = async () => { + tracked.opened = false; + await onClose(); + }; + createdViews.push(tracked); + return view; }