refactor(test): simplify plugin composition contracts

This commit is contained in:
murashit 2026-07-17 07:39:16 +09:00
parent ed942d1c9b
commit 40153a1da5
6 changed files with 836 additions and 1004 deletions

View file

@ -39,6 +39,19 @@ describe("AppServerQueryCache", () => {
expect(fetchThreads).toHaveBeenCalledOnce();
});
it("preserves the last-known-good active thread list when a refresh fails", async () => {
const fetchThreads = vi
.fn()
.mockResolvedValueOnce([thread("cached")])
.mockRejectedValueOnce(new Error("offline"));
const cache = cacheWithThreads(fetchThreads);
await cache.refreshActiveThreads();
await expect(cache.refreshActiveThreads()).rejects.toThrow("offline");
expect(cache.activeThreadsSnapshot()).toEqual([thread("cached")]);
});
it("shares concurrent active thread refreshes within one resource identity", async () => {
const pending = deferred<readonly ReturnType<typeof thread>[]>();
const fetchThreads = vi.fn(() => pending.promise);

View file

@ -4,21 +4,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StaleAppServerResourceContextError } from "../../../../src/app-server/query/resource-store";
import type { Thread } from "../../../../src/domain/threads/model";
import { activeThreadId } from "../../../../src/features/chat/application/state/root-reducer";
import { type ChatStateStore, createChatStateStore } from "../../../../src/features/chat/application/state/store";
import { HistoryController } from "../../../../src/features/chat/application/threads/history-controller";
import { ChatResumeWorkTracker } from "../../../../src/features/chat/application/threads/resume-work";
import type { ChatPanelEnvironment } from "../../../../src/features/chat/host/contracts";
import { createChatViewDeferredTasks } from "../../../../src/features/chat/host/session/deferred-work";
import { ChatPanelSessionRuntime } from "../../../../src/features/chat/host/session-runtime";
import { ChatComposerController } from "../../../../src/features/chat/panel/composer-controller";
import { ThreadStreamPresenter } from "../../../../src/features/chat/panel/surface/thread-stream-presenter";
import { createChatThreadStreamScrollBinding } from "../../../../src/features/chat/panel/thread-stream-scroll-binding";
import { createThreadNameMutationCoordinator } from "../../../../src/features/threads/workflows/thread-name-mutation-coordinator";
import { type CodexPanelSettings, DEFAULT_SETTINGS } from "../../../../src/settings/model";
import { waitForAsyncWork } from "../../../support/async";
import { deferred, waitForAsyncWork } from "../../../support/async";
import { installObsidianDomShims } from "../../../support/dom";
import { chatPanelSettingsAccess } from "../support/settings";
import { composerModelFromChatState } from "../support/shell-selectors";
installObsidianDomShims();
@ -34,15 +31,24 @@ describe("ChatPanelSessionRuntime actions", () => {
document.body.replaceChildren();
});
it("invalidates thread work through the runtime action", () => {
const { runtime, resumeWork } = sessionRuntimeFixture();
it("invalidates active resume, history, and restoration work through the runtime action", async () => {
const { runtime, resumeWork, stateStore } = sessionRuntimeFixture();
const resume = resumeWork.begin("thread-1");
const invalidateHistory = vi.spyOn(HistoryController.prototype, "invalidate");
stateStore.dispatch({ type: "thread-stream/history-loading-set", loading: true });
stateStore.dispatch({ type: "panel/restored-thread-applied", threadId: "thread-1", fallbackTitle: null });
const restored = deferred<void>();
const loadRestoredThread = vi.fn(() => restored.promise);
const firstRestoration = runtime.thread.restoration.ensureLoaded(loadRestoredThread);
runtime.actions.invalidateThreadWork();
expect(resumeWork.isStale(resume)).toBe(true);
expect(invalidateHistory).toHaveBeenCalledOnce();
expect(stateStore.getState().threadStream.loadingHistory).toBe(false);
const secondRestoration = runtime.thread.restoration.ensureLoaded(loadRestoredThread);
expect(loadRestoredThread).toHaveBeenCalledTimes(2);
restored.resolve(undefined);
await Promise.all([firstRestoration, secondRestoration]);
});
it("clears rename work before replacing the app-server context", () => {
@ -92,16 +98,11 @@ describe("ChatPanelSessionRuntime actions", () => {
expect(stateStore.getState().threadList.listedThreads).toEqual([]);
});
it("starts a new thread from runtime state and composer actions", async () => {
const focusComposer = vi.spyOn(ChatComposerController.prototype, "focusComposer").mockImplementation(() => undefined);
it("fans out active-thread identity changes after starting a new thread", async () => {
const refreshLiveState = vi.fn();
const requestWorkspaceLayoutSave = vi.fn();
const refreshTabHeader = vi.fn();
const { runtime, stateStore } = sessionRuntimeFixture({
environment: {
obsidian: {
requestWorkspaceLayoutSave,
},
plugin: {
workspace: {
refreshThreadsViewLiveState: refreshLiveState,
@ -127,47 +128,14 @@ describe("ChatPanelSessionRuntime actions", () => {
serviceTier: null,
approvalsReviewer: null,
});
stateStore.dispatch({ type: "ui/panel-set", panel: "history" });
await runtime.actions.startNewThread();
expect(activeThreadId(stateStore.getState())).toBeNull();
expect(stateStore.getState().ui.toolbarPanel).toBeNull();
expect(stateStore.getState().connection.statusText).toBe("New chat.");
expect(focusComposer).toHaveBeenCalledOnce();
expect(refreshTabHeader).toHaveBeenCalledOnce();
expect(requestWorkspaceLayoutSave).toHaveBeenCalledOnce();
expect(refreshLiveState).toHaveBeenCalledOnce();
});
it("does not clear the current thread while a turn is busy", async () => {
const focusComposer = vi.spyOn(ChatComposerController.prototype, "focusComposer").mockImplementation(() => undefined);
const { runtime, stateStore } = sessionRuntimeFixture();
stateStore.dispatch({
type: "turn/optimistic-started",
item: {
id: "local-user",
kind: "dialogue",
dialogueKind: "user",
role: "user",
text: "prompt",
},
pendingTurnStart: { anchorItemId: "local-user", promptSubmitHookItemIds: [] },
});
stateStore.dispatch({
type: "turn/started",
threadId: "thread-1",
turnId: "turn-1",
});
await runtime.actions.startNewThread();
expect(activeThreadId(stateStore.getState())).toBe("thread-1");
expect(focusComposer).not.toHaveBeenCalled();
});
it("wires reconnect cleanup through the runtime toolbar action", async () => {
const { runtime, stateStore, deferredTasks } = sessionRuntimeFixture();
const { runtime, stateStore } = sessionRuntimeFixture();
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicyKnown: true,
@ -183,10 +151,7 @@ describe("ChatPanelSessionRuntime actions", () => {
serviceTier: null,
approvalsReviewer: null,
});
const invalidateConnection = vi.spyOn(runtime.connection.actions, "invalidate");
const clearDiagnostics = vi.spyOn(deferredTasks, "clearDiagnostics");
const resetConnection = vi.spyOn(runtime.connection.manager, "resetConnection");
const ensureConnected = vi.spyOn(runtime.connection.actions, "ensureConnected").mockResolvedValue(undefined);
vi.spyOn(runtime.connection.actions, "ensureConnected").mockResolvedValue(undefined);
vi.spyOn(runtime.connection.manager, "isConnected").mockReturnValue(true);
const resumeThread = vi.spyOn(runtime.thread.resume, "resumeThread").mockResolvedValue(undefined);
@ -195,22 +160,57 @@ describe("ChatPanelSessionRuntime actions", () => {
await waitForAsyncWork(() => {
expect(resumeThread).toHaveBeenCalledWith("thread-1");
});
expect(invalidateConnection).toHaveBeenCalledOnce();
expect(clearDiagnostics).toHaveBeenCalledOnce();
expect(resetConnection).toHaveBeenCalledOnce();
expect(stateStore.getState().connection.statusText).toBe("Reconnecting...");
expect(ensureConnected).toHaveBeenCalledOnce();
});
it("disposes presenter and composer resources through the complete runtime lifecycle", async () => {
const disposePresenter = vi.spyOn(ThreadStreamPresenter.prototype, "dispose").mockImplementation(() => undefined);
const disposeComposer = vi.spyOn(ChatComposerController.prototype, "dispose").mockImplementation(() => undefined);
const { runtime } = sessionRuntimeFixture();
it("disposes scheduled, subscribed, composer, scroll, and connection resources", async () => {
vi.useFakeTimers();
const unsubscribeThreads = vi.fn();
const unsubscribeMetadata = vi.fn();
const unsubscribeModels = vi.fn();
const refreshLiveState = vi.fn();
const { runtime, stateStore, deferredTasks, threadStreamScrollBinding } = sessionRuntimeFixture({
environment: {
plugin: {
workspace: { refreshThreadsViewLiveState: refreshLiveState },
threadCatalog: { observeActive: vi.fn(() => unsubscribeThreads) },
appServerQueries: {
observeAppServerMetadataResult: vi.fn(() => unsubscribeMetadata),
observeModelsResult: vi.fn(() => unsubscribeModels),
},
},
},
});
runtime.runtime.sharedState.subscribe();
const diagnostics = vi.fn();
const warmup = vi.fn();
deferredTasks.scheduleDiagnostics(diagnostics);
deferredTasks.scheduleAppServerWarmup(warmup);
const dispatchScrollCommand = vi.fn();
threadStreamScrollBinding.mountScrollPort({ dispatchScrollCommand });
const composer = document.body.createEl("textarea");
runtime.composer.controller.renderState(composerModelFromChatState(stateStore.getState()), { submit: vi.fn() }).onComposer(composer);
composer.focus();
expect(runtime.composer.controller.hasFocus()).toBe(true);
const disconnect = vi.spyOn(runtime.connection.manager, "disconnect");
const unmount = vi.fn();
await runtime.dispose(() => undefined);
await runtime.dispose(unmount);
expect(disposePresenter).toHaveBeenCalledOnce();
expect(disposeComposer).toHaveBeenCalledOnce();
expect([unsubscribeThreads, unsubscribeMetadata, unsubscribeModels].every((unsubscribe) => unsubscribe.mock.calls.length === 1)).toBe(
true,
);
expect(runtime.composer.controller.hasFocus()).toBe(false);
threadStreamScrollBinding.showLatest();
expect(dispatchScrollCommand).not.toHaveBeenCalled();
expect(unmount).toHaveBeenCalledOnce();
expect(disconnect).toHaveBeenCalledOnce();
expect(refreshLiveState).toHaveBeenCalledOnce();
await vi.runAllTimersAsync();
expect(diagnostics).not.toHaveBeenCalled();
expect(warmup).not.toHaveBeenCalled();
expect(refreshLiveState).toHaveBeenCalledTimes(2);
});
function sessionRuntimeFixture(options: { environment?: PartialChatPanelEnvironment } = {}): {
@ -218,21 +218,23 @@ describe("ChatPanelSessionRuntime actions", () => {
stateStore: ChatStateStore;
resumeWork: ChatResumeWorkTracker;
deferredTasks: ReturnType<typeof createChatViewDeferredTasks>;
threadStreamScrollBinding: ReturnType<typeof createChatThreadStreamScrollBinding>;
} {
const stateStore = createChatStateStore();
const resumeWork = new ChatResumeWorkTracker();
const deferredTasks = createChatViewDeferredTasks(() => window);
const threadStreamScrollBinding = createChatThreadStreamScrollBinding();
const environment = chatPanelEnvironmentFixture(options.environment);
const runtime = new ChatPanelSessionRuntime({
environment,
stateStore,
deferredTasks,
resumeWork,
threadStreamScrollBinding: createChatThreadStreamScrollBinding(),
threadStreamScrollBinding,
getClosing: () => false,
viewWindow: () => window,
});
return { runtime, stateStore, resumeWork, deferredTasks };
return { runtime, stateStore, resumeWork, deferredTasks, threadStreamScrollBinding };
}
interface PartialChatPanelEnvironment {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,279 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Thread } from "../src/domain/threads/model";
import type { CodexChatView } from "../src/features/chat/host/view.obsidian";
import type CodexPanelPlugin from "../src/main";
import { deferred } from "./support/async";
import { installObsidianDomShims } from "./support/dom";
import {
chatView,
flushMicrotasks,
leaf,
panelSnapshot,
pluginWithLeaves,
publishCodexPath,
thread,
threadListClient,
} from "./support/plugin-fixtures";
installObsidianDomShims();
const { withShortLivedAppServerClientMock } = vi.hoisted(() => ({
withShortLivedAppServerClientMock: vi.fn(),
}));
vi.mock("../src/app-server/connection/short-lived-client", () => ({
withShortLivedAppServerClient: withShortLivedAppServerClientMock,
}));
function threadCatalog(plugin: CodexPanelPlugin) {
return plugin.runtime.chatHost().threadCatalog;
}
describe("CodexPanelPlugin runtime integration", () => {
beforeEach(() => {
vi.useRealTimers();
withShortLivedAppServerClientMock.mockReset();
});
it("applies known archive mutations to open chat surfaces", async () => {
const { CodexChatView } = await import("../src/features/chat/host/view.obsidian");
const firstLeaf = leaf();
firstLeaf.view = chatView(CodexChatView, firstLeaf);
const firstArchived = vi.spyOn((firstLeaf.view as CodexChatView).surface, "applyThreadArchived").mockImplementation(() => undefined);
const firstRefresh = vi.spyOn((firstLeaf.view as CodexChatView).surface, "refreshSharedThreads").mockResolvedValue(undefined);
const secondLeaf = leaf();
secondLeaf.view = chatView(CodexChatView, secondLeaf);
const secondArchived = vi.spyOn((secondLeaf.view as CodexChatView).surface, "applyThreadArchived").mockImplementation(() => undefined);
const secondRefresh = vi.spyOn((secondLeaf.view as CodexChatView).surface, "refreshSharedThreads").mockResolvedValue(undefined);
const plugin = await pluginWithLeaves([firstLeaf, secondLeaf]);
threadCatalog(plugin).apply({ type: "thread-archived", threadId: "thread-1" });
expect(firstArchived).toHaveBeenCalledWith("thread-1");
expect(secondArchived).toHaveBeenCalledWith("thread-1");
expect(firstRefresh).not.toHaveBeenCalled();
expect(secondRefresh).not.toHaveBeenCalled();
});
it("keeps catalog archive notifications separate from explicit panel close requests", async () => {
const { CodexChatView } = await import("../src/features/chat/host/view.obsidian");
const restoredMatchingLeaf = leaf({ state: { threadId: "thread-1", threadTitle: "Restored" } });
const matchingLeaf = leaf();
matchingLeaf.view = chatView(CodexChatView, matchingLeaf);
vi.spyOn((matchingLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ threadId: "thread-1" }));
vi.spyOn((matchingLeaf.view as CodexChatView).surface, "refreshSharedThreads").mockResolvedValue(undefined);
const otherLeaf = leaf();
otherLeaf.view = chatView(CodexChatView, otherLeaf);
vi.spyOn((otherLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ threadId: "thread-2" }));
vi.spyOn((otherLeaf.view as CodexChatView).surface, "refreshSharedThreads").mockResolvedValue(undefined);
const plugin = await pluginWithLeaves([restoredMatchingLeaf, matchingLeaf, otherLeaf]);
threadCatalog(plugin).apply({ type: "thread-archived", threadId: "thread-1" });
expect(restoredMatchingLeaf.detach).not.toHaveBeenCalled();
expect(matchingLeaf.detach).not.toHaveBeenCalled();
expect(otherLeaf.detach).not.toHaveBeenCalled();
plugin.runtime.threadsHost().closeOpenPanelsForThread("thread-1");
expect(restoredMatchingLeaf.detach).toHaveBeenCalledOnce();
expect(matchingLeaf.detach).toHaveBeenCalledOnce();
expect(otherLeaf.detach).not.toHaveBeenCalled();
});
it("applies known rename mutations to open chat surfaces", async () => {
const { CodexChatView } = await import("../src/features/chat/host/view.obsidian");
const firstLeaf = leaf();
firstLeaf.view = chatView(CodexChatView, firstLeaf);
const firstRenamed = vi.spyOn((firstLeaf.view as CodexChatView).surface, "applyThreadRenamed").mockImplementation(() => undefined);
const firstRefresh = vi.spyOn((firstLeaf.view as CodexChatView).surface, "refreshSharedThreads").mockResolvedValue(undefined);
const secondLeaf = leaf();
secondLeaf.view = chatView(CodexChatView, secondLeaf);
const secondRenamed = vi.spyOn((secondLeaf.view as CodexChatView).surface, "applyThreadRenamed").mockImplementation(() => undefined);
const secondRefresh = vi.spyOn((secondLeaf.view as CodexChatView).surface, "refreshSharedThreads").mockResolvedValue(undefined);
const plugin = await pluginWithLeaves([firstLeaf, secondLeaf]);
threadCatalog(plugin).apply({ type: "thread-renamed", threadId: "thread-1", name: "Renamed thread" });
expect(firstRenamed).toHaveBeenCalledWith("thread-1", "Renamed thread");
expect(secondRenamed).toHaveBeenCalledWith("thread-1", "Renamed thread");
expect(firstRefresh).not.toHaveBeenCalled();
expect(secondRefresh).not.toHaveBeenCalled();
});
it("keeps shared thread list refreshes separate across app-server cache contexts", async () => {
const { CodexChatView } = await import("../src/features/chat/host/view.obsidian");
const connectedLeaf = leaf();
connectedLeaf.view = chatView(CodexChatView, connectedLeaf);
const connectedView = connectedLeaf.view as CodexChatView;
vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true }));
vi.spyOn(connectedView.surface, "canServeAppServerContext").mockReturnValue(true);
let resolveFirst!: (threads: Thread[]) => void;
const runWithAppServerClient = vi.spyOn(connectedView.surface, "runWithAppServerClient");
const plugin = await pluginWithLeaves([connectedLeaf]);
await publishCodexPath(plugin, "codex-a");
runWithAppServerClient.mockImplementation((operation) =>
operation(
threadListClient(() =>
plugin.settings.codexPath === "codex-a"
? new Promise<Thread[]>((resolve) => {
resolveFirst = resolve;
})
: Promise.resolve([thread("second")]),
),
),
);
const first = threadCatalog(plugin).refreshActive();
const staleFirst = expect(first).rejects.toThrow("Codex app-server resource context changed while loading.");
await flushMicrotasks();
await publishCodexPath(plugin, "codex-b");
const second = threadCatalog(plugin).refreshActive();
await flushMicrotasks();
expect(runWithAppServerClient).toHaveBeenCalledTimes(2);
await expect(second).resolves.toEqual([thread("second")]);
expect(threadCatalog(plugin).activeSnapshot()).toEqual([thread("second")]);
resolveFirst([thread("first")]);
await staleFirst;
expect(threadCatalog(plugin).activeSnapshot()).toEqual([thread("second")]);
await publishCodexPath(plugin, "codex-a");
expect(threadCatalog(plugin).activeSnapshot()).toBeNull();
});
it("does not reuse a connected panel whose app-server context does not match the shared query", async () => {
const { CodexChatView } = await import("../src/features/chat/host/view.obsidian");
const connectedLeaf = leaf();
connectedLeaf.view = chatView(CodexChatView, connectedLeaf);
const connectedView = connectedLeaf.view as CodexChatView;
vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true }));
vi.spyOn(connectedView.surface, "canServeAppServerContext").mockReturnValue(false);
const runWithAppServerClient = vi.spyOn(connectedView.surface, "runWithAppServerClient").mockResolvedValue([thread("wrong-context")]);
withShortLivedAppServerClientMock.mockImplementation(
(_codexPath: string, _vaultPath: string, operation: (client: ReturnType<typeof threadListClient>) => Promise<unknown>) =>
operation(threadListClient(() => Promise.resolve([thread("matching-context")]))),
);
const plugin = await pluginWithLeaves([connectedLeaf]);
await publishCodexPath(plugin, "codex-b");
await expect(threadCatalog(plugin).refreshActive()).resolves.toEqual([thread("matching-context")]);
expect(runWithAppServerClient).not.toHaveBeenCalled();
expect(withShortLivedAppServerClientMock).toHaveBeenCalledWith("codex-b", "/vault", expect.any(Function), {});
expect(threadCatalog(plugin).activeSnapshot()).toEqual([thread("matching-context")]);
});
it("uses a short-lived client when the operation declares an unhandled server-request policy", async () => {
const { CodexChatView } = await import("../src/features/chat/host/view.obsidian");
const connectedLeaf = leaf();
connectedLeaf.view = chatView(CodexChatView, connectedLeaf);
const connectedView = connectedLeaf.view as CodexChatView;
vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true }));
const runWithAppServerClient = vi.spyOn(connectedView.surface, "runWithAppServerClient").mockResolvedValue("chat-result");
const shortLivedClient = { request: vi.fn().mockResolvedValue({}) };
withShortLivedAppServerClientMock.mockImplementation(
(_codexPath: string, _vaultPath: string, operation: (client: typeof shortLivedClient) => Promise<unknown>) =>
operation(shortLivedClient),
);
const plugin = await pluginWithLeaves([connectedLeaf]);
plugin.settings.codexPath = "codex";
const result = await plugin.runtime.withClient((client) => client.request("config/read", { cwd: "/vault", includeLayers: true }), {
serverRequests: { kind: "reject", message: "Settings refresh does not handle server requests." },
});
expect(result).toEqual({});
expect(runWithAppServerClient).not.toHaveBeenCalled();
expect(withShortLivedAppServerClientMock).toHaveBeenCalledWith("codex", "/vault", expect.any(Function), {
serverRequests: { kind: "reject", message: "Settings refresh does not handle server requests." },
});
expect(shortLivedClient.request).toHaveBeenCalledWith("config/read", { cwd: "/vault", includeLayers: true });
});
it("rejects a connected-panel result when the app-server context changes during the operation", async () => {
const { CodexChatView } = await import("../src/features/chat/host/view.obsidian");
const connectedLeaf = leaf();
connectedLeaf.view = chatView(CodexChatView, connectedLeaf);
const connectedView = connectedLeaf.view as CodexChatView;
vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ connected: true }));
vi.spyOn(connectedView.surface, "canServeAppServerContext").mockReturnValue(true);
const result = deferred<string>();
vi.spyOn(connectedView.surface, "runWithAppServerClient").mockReturnValue(result.promise);
const plugin = await pluginWithLeaves([connectedLeaf]);
await publishCodexPath(plugin, "codex-a");
const operation = plugin.runtime.withClient(() => Promise.resolve("unused"));
await publishCodexPath(plugin, "codex-b");
result.resolve("stale-result");
await expect(operation).rejects.toThrow("Codex app-server resource context changed while loading.");
});
it("rejects a short-lived result when the app-server context changes during the operation", async () => {
const result = deferred<string>();
withShortLivedAppServerClientMock.mockReturnValue(result.promise);
const plugin = await pluginWithLeaves([]);
await publishCodexPath(plugin, "codex-a");
const operation = plugin.runtime.withClient(() => Promise.resolve("unused"), {
serverRequests: { kind: "reject", message: "test" },
});
await publishCodexPath(plugin, "codex-b");
result.resolve("stale-result");
await expect(operation).rejects.toThrow("Codex app-server resource context changed while loading.");
});
it("publishes a persisted context and its new resource lease atomically", async () => {
const plugin = await pluginWithLeaves([]);
const firstLease = plugin.runtime.appServerContextLease();
const save = deferred<void>();
const saveSettings = vi.spyOn(plugin, "saveSettings").mockReturnValue(save.promise);
const publication = plugin.runtime.settingTabHost().publishSettings({ ...plugin.settings, codexPath: "codex-next" });
await Promise.resolve();
expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({ codexPath: "codex-next" }));
expect(plugin.settings.codexPath).toBe(firstLease.context.codexPath);
expect(plugin.runtime.appServerContextLease()).toBe(firstLease);
save.resolve(undefined);
await publication;
expect(plugin.settings.codexPath).toBe("codex-next");
expect(plugin.runtime.appServerContextLease()).toEqual({
context: { codexPath: "codex-next", vaultPath: "/vault" },
generation: firstLease.generation + 1,
});
});
it("coordinates context replacement with every open Threads view", async () => {
const { CodexThreadsView } = await import("../src/features/threads-view/view.obsidian");
const prepareAppServerContextChange = vi.fn();
const refreshSettings = vi.fn();
const threadsView = Object.assign(Object.create(CodexThreadsView.prototype), {
prepareAppServerContextChange,
refreshSettings,
}) as InstanceType<typeof CodexThreadsView>;
const threadsLeaf = leaf();
threadsLeaf.view = threadsView;
const plugin = await pluginWithLeaves([], { threadsLeaves: [threadsLeaf] });
await publishCodexPath(plugin, "codex-next");
expect(prepareAppServerContextChange).toHaveBeenCalledOnce();
expect(refreshSettings).toHaveBeenCalledOnce();
});
it("cancels selection rewrites before publishing a new app-server context", async () => {
const plugin = await pluginWithLeaves([]);
const closeAll = vi.fn();
plugin.runtime.setSelectionRewriteController({ closeAll });
await publishCodexPath(plugin, "codex-next");
expect(closeAll).toHaveBeenCalledOnce();
});
});

View file

@ -0,0 +1,194 @@
// @vitest-environment jsdom
import { FileSystemAdapter } from "obsidian";
import { vi } from "vitest";
import { VIEW_TYPE_CODEX_PANEL, VIEW_TYPE_CODEX_THREADS } from "../../src/constants";
import type { Thread } from "../../src/domain/threads/model";
import type { CodexChatHost } from "../../src/features/chat/host/contracts";
import type { CodexChatView } from "../../src/features/chat/host/view.obsidian";
import { createThreadNameMutationCoordinator } from "../../src/features/threads/workflows/thread-name-mutation-coordinator";
import type CodexPanelPlugin from "../../src/main";
import { type CodexPanelSettings, DEFAULT_SETTINGS } from "../../src/settings/model";
import { chatPanelSettingsAccess } from "../features/chat/support/settings";
export async function pluginWithLeaves(leaves: TestLeaf[], options: { threadsLeaves?: TestLeaf[] } = {}): Promise<CodexPanelPlugin> {
const { default: CodexPanelPlugin } = await import("../../src/main");
const adapter = new FileSystemAdapter();
vi.spyOn(adapter, "getBasePath").mockReturnValue("/vault");
const plugin = new CodexPanelPlugin(
{
vault: { adapter },
workspace: {
getLeavesOfType: vi.fn((type: string) => {
if (type === VIEW_TYPE_CODEX_PANEL) return leaves;
if (type === VIEW_TYPE_CODEX_THREADS) return options.threadsLeaves ?? [];
return [];
}),
revealLeaf: vi.fn().mockResolvedValue(undefined),
getRightLeaf: vi.fn(() => null),
getMostRecentLeaf: vi.fn(() => null),
getActiveViewOfType: vi.fn(() => null),
ensureSideLeaf: vi.fn(() => Promise.reject(new Error("Unexpected ensureSideLeaf call."))),
on: vi.fn(() => ({})),
activeLeaf: null,
rightSplit: {},
},
} as never,
{} as never,
);
plugin.settings = { ...DEFAULT_SETTINGS };
plugin.vaultPath = "/vault";
plugin.runtime.initialize();
return plugin;
}
export async function publishCodexPath(plugin: CodexPanelPlugin, codexPath: string): Promise<void> {
await plugin.runtime.settingTabHost().publishSettings({ ...plugin.settings, codexPath });
}
export interface TestLeaf {
view: unknown;
getViewState: ReturnType<typeof vi.fn>;
getRoot: ReturnType<typeof vi.fn>;
parent: object;
setViewState: ReturnType<typeof vi.fn>;
loadIfDeferred: ReturnType<typeof vi.fn>;
detach: ReturnType<typeof vi.fn>;
}
export function leaf(options: { state?: Record<string, unknown> } = {}): TestLeaf {
return {
view: null,
getViewState: vi.fn(() => ({ type: VIEW_TYPE_CODEX_PANEL, state: options.state ?? {} })),
getRoot: vi.fn(() => ({})),
parent: {},
setViewState: vi.fn().mockResolvedValue(undefined),
loadIfDeferred: vi.fn().mockResolvedValue(undefined),
detach: vi.fn(),
};
}
export function chatView(CodexChatViewCtor: typeof CodexChatView, leaf: TestLeaf): CodexChatView {
const containerEl = document.createElement("div");
containerEl.createDiv();
containerEl.createDiv();
return new CodexChatViewCtor(
{
...leaf,
app: {
workspace: {
getActiveFile: vi.fn(() => null),
getLastOpenFiles: vi.fn(() => []),
on: vi.fn(() => ({})),
openLinkText: vi.fn(),
requestSaveLayout: vi.fn(),
},
vault: {
on: vi.fn(() => ({})),
offref: vi.fn(),
getFiles: vi.fn(() => []),
getMarkdownFiles: vi.fn(() => []),
getAbstractFileByPath: vi.fn(() => null),
},
metadataCache: {
on: vi.fn(() => ({})),
offref: vi.fn(),
getFirstLinkpathDest: vi.fn(() => null),
fileToLinktext: vi.fn((_file: unknown, _sourcePath: string) => ""),
getFileCache: vi.fn(() => null),
},
},
containerEl,
} as never,
chatHostFixture(),
);
}
function chatHostFixture(): CodexChatHost {
const settings: CodexPanelSettings = { ...DEFAULT_SETTINGS, codexPath: "codex", sendShortcut: "enter" };
return {
threadNameMutations: createThreadNameMutationCoordinator(),
settingsRef: {
settings: chatPanelSettingsAccess(settings),
vaultPath: "/vault",
},
workspace: {
openThreadInNewView: vi.fn(),
focusThreadInOpenView: vi.fn(),
openTurnDiff: vi.fn(),
refreshThreadsViewLiveState: vi.fn(),
openSideChat: vi.fn(),
},
appServerQueries: {
contextLease: () => ({ context: { codexPath: settings.codexPath, vaultPath: "/vault" }, generation: 1 }),
appServerMetadataSnapshot: vi.fn(() => null),
refreshAppServerMetadata: vi.fn(() => Promise.resolve(null)),
refreshSkills: vi.fn(() => Promise.resolve(null)),
refreshRateLimits: vi.fn(() => Promise.resolve(null)),
modelsSnapshot: vi.fn(() => null),
fetchModels: vi.fn(() => Promise.resolve([])),
refreshModels: vi.fn(() => Promise.resolve([])),
observeAppServerMetadataResult: vi.fn(() => () => undefined),
observeModelsResult: vi.fn(() => () => undefined),
},
threadCatalog: {
apply: vi.fn(),
applyConnectionEvent: vi.fn(),
loadActive: vi.fn(() => Promise.resolve([])),
refreshActive: vi.fn(() => Promise.resolve([])),
activeSnapshot: vi.fn(() => null),
observeActive: vi.fn(() => () => undefined),
},
};
}
export function threadListClient(fetchThreads: () => Promise<readonly Thread[]>): never {
return {
request: async (method: string) => {
if (method !== "thread/list") throw new Error(`Unexpected app-server request: ${method}`);
return { data: await fetchThreads(), nextCursor: null };
},
} as never;
}
export async function flushMicrotasks(): Promise<void> {
await new Promise((resolve) => setImmediate(resolve));
}
export function thread(id: string): Thread {
return {
id,
preview: id,
createdAt: 1,
updatedAt: 1,
name: null,
archived: false,
provenance: { kind: "interactive" },
};
}
export function panelSnapshot(overrides: PanelSnapshotFixtureOverrides = {}): ReturnType<CodexChatView["surface"]["openPanelSnapshot"]> {
const { pendingApprovals, pendingUserInputs, pendingMcpElicitations, ...snapshotOverrides } = overrides;
const pendingRequests = snapshotOverrides.pendingRequests ?? {
approvals: pendingApprovals ?? 0,
userInputs: pendingUserInputs ?? 0,
mcpElicitations: pendingMcpElicitations ?? 0,
actionable: (pendingApprovals ?? 0) + (pendingUserInputs ?? 0) + (pendingMcpElicitations ?? 0),
};
return {
viewId: "view",
threadId: "thread",
turnLifecycle: { kind: "idle" },
pendingRequests,
hasComposerDraft: false,
connected: true,
...snapshotOverrides,
};
}
type PanelSnapshotFixtureOverrides = Partial<ReturnType<CodexChatView["surface"]["openPanelSnapshot"]>> & {
pendingApprovals?: number;
pendingUserInputs?: number;
pendingMcpElicitations?: number;
};

View file

@ -0,0 +1,258 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { VIEW_TYPE_CODEX_PANEL } from "../../src/constants";
import type { CodexChatView } from "../../src/features/chat/host/view.obsidian";
import type CodexPanelPlugin from "../../src/main";
import { WorkspacePanelCoordinator } from "../../src/workspace/panel-coordinator";
import { installObsidianDomShims } from "../support/dom";
import { chatView, leaf, panelSnapshot, pluginWithLeaves } from "../support/plugin-fixtures";
installObsidianDomShims();
describe("WorkspacePanelCoordinator", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("cancels a pending reconciliation without cancelling later schedules", async () => {
vi.useFakeTimers();
const panelLeaf = leaf();
const coordinator = panels(await pluginWithLeaves([panelLeaf]));
coordinator.scheduleWorkspacePanelReconcile();
coordinator.reset();
await vi.advanceTimersByTimeAsync(0);
expect(panelLeaf.loadIfDeferred).not.toHaveBeenCalled();
coordinator.scheduleWorkspacePanelReconcile();
await vi.advanceTimersByTimeAsync(0);
expect(panelLeaf.loadIfDeferred).toHaveBeenCalledOnce();
});
it("includes deferred restored panels in snapshots", async () => {
const restoredLeaf = leaf({ state: { threadId: "thread-1", threadTitle: "Restored thread" } });
const coordinator = panels(await pluginWithLeaves([restoredLeaf]));
expect(coordinator.getOpenPanelSnapshots()).toMatchObject([
{ threadId: "thread-1", connected: false, lastFocused: false, pendingRequests: { actionable: 0 } },
]);
});
it("focuses an already open thread before reusing an empty panel", async () => {
const { CodexChatView } = await import("../../src/features/chat/host/view.obsidian");
const openLeaf = leaf();
openLeaf.view = chatView(CodexChatView, openLeaf);
vi.spyOn((openLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(
panelSnapshot({ viewId: "open", threadId: "thread-1" }),
);
const focus = vi.spyOn((openLeaf.view as CodexChatView).surface, "focusThread").mockResolvedValue(undefined);
const emptyLeaf = leaf();
emptyLeaf.view = chatView(CodexChatView, emptyLeaf);
const open = vi.spyOn((emptyLeaf.view as CodexChatView).surface, "openThread").mockResolvedValue(undefined);
const plugin = await pluginWithLeaves([openLeaf, emptyLeaf]);
await panels(plugin).openThreadInAvailableView("thread-1");
expect(focus).toHaveBeenCalledWith("thread-1");
expect(open).not.toHaveBeenCalled();
});
it("reuses an idle empty panel but skips one with an actionable request", async () => {
const { CodexChatView } = await import("../../src/features/chat/host/view.obsidian");
const pendingLeaf = leaf();
pendingLeaf.view = chatView(CodexChatView, pendingLeaf);
vi.spyOn((pendingLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(
panelSnapshot({ viewId: "pending", threadId: null, pendingMcpElicitations: 1 }),
);
const openPending = vi.spyOn((pendingLeaf.view as CodexChatView).surface, "openThread").mockResolvedValue(undefined);
const emptyLeaf = leaf();
emptyLeaf.view = chatView(CodexChatView, emptyLeaf);
vi.spyOn((emptyLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(
panelSnapshot({ viewId: "empty", threadId: null }),
);
const openEmpty = vi.spyOn((emptyLeaf.view as CodexChatView).surface, "openThread").mockResolvedValue(undefined);
await panels(await pluginWithLeaves([pendingLeaf, emptyLeaf])).openThreadInAvailableView("thread-1");
expect(openPending).not.toHaveBeenCalled();
expect(openEmpty).toHaveBeenCalledWith("thread-1");
});
it("uses the active Codex panel for current-view selections", async () => {
const { CodexChatView } = await import("../../src/features/chat/host/view.obsidian");
const fallbackLeaf = leaf();
fallbackLeaf.view = chatView(CodexChatView, fallbackLeaf);
const fallbackOpen = vi.spyOn((fallbackLeaf.view as CodexChatView).surface, "openThread").mockResolvedValue(undefined);
const activeLeaf = leaf();
activeLeaf.view = chatView(CodexChatView, activeLeaf);
const activeView = activeLeaf.view as CodexChatView;
const activeOpen = vi.spyOn(activeView.surface, "openThread").mockResolvedValue(undefined);
const plugin = await pluginWithLeaves([fallbackLeaf, activeLeaf]);
(plugin.app.workspace.getActiveViewOfType as ReturnType<typeof vi.fn>).mockReturnValue(activeView);
(plugin.app.workspace.getMostRecentLeaf as ReturnType<typeof vi.fn>).mockReturnValue(fallbackLeaf);
await panels(plugin).openThreadInCurrentView("thread-1");
expect(activeOpen).toHaveBeenCalledWith("thread-1");
expect(fallbackOpen).not.toHaveBeenCalled();
});
it("preserves an already open destination before replacing the current panel", async () => {
const { CodexChatView } = await import("../../src/features/chat/host/view.obsidian");
const destinationLeaf = leaf();
destinationLeaf.view = chatView(CodexChatView, destinationLeaf);
vi.spyOn((destinationLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(
panelSnapshot({ viewId: "destination", threadId: "thread-1" }),
);
const focusDestination = vi.spyOn((destinationLeaf.view as CodexChatView).surface, "focusThread").mockResolvedValue(undefined);
const currentLeaf = leaf();
currentLeaf.view = chatView(CodexChatView, currentLeaf);
const openCurrent = vi.spyOn((currentLeaf.view as CodexChatView).surface, "openThread").mockResolvedValue(undefined);
const plugin = await pluginWithLeaves([destinationLeaf, currentLeaf]);
(plugin.app.workspace.getMostRecentLeaf as ReturnType<typeof vi.fn>).mockReturnValue(currentLeaf);
await panels(plugin).openThreadInCurrentView("thread-1");
expect(focusDestination).toHaveBeenCalledWith("thread-1");
expect(openCurrent).not.toHaveBeenCalled();
});
it("restores an exact deferred destination before replacing the current panel", async () => {
const restoredLeaf = leaf({ state: { threadId: "thread-1" } });
const currentLeaf = leaf();
const { CodexChatView } = await import("../../src/features/chat/host/view.obsidian");
currentLeaf.view = chatView(CodexChatView, currentLeaf);
const openCurrent = vi.spyOn((currentLeaf.view as CodexChatView).surface, "openThread").mockResolvedValue(undefined);
const restoredView = chatView(CodexChatView, restoredLeaf);
const focusRestored = vi.spyOn(restoredView.surface, "focusThread").mockResolvedValue(undefined);
restoredLeaf.loadIfDeferred.mockImplementation(async () => {
restoredLeaf.view = restoredView;
});
const plugin = await pluginWithLeaves([restoredLeaf, currentLeaf]);
(plugin.app.workspace.getMostRecentLeaf as ReturnType<typeof vi.fn>).mockReturnValue(currentLeaf);
await panels(plugin).openThreadInCurrentView("thread-1");
expect(restoredLeaf.loadIfDeferred).toHaveBeenCalledOnce();
expect(focusRestored).toHaveBeenCalledWith("thread-1");
expect(openCurrent).not.toHaveBeenCalled();
});
it("uses the most recent panel and otherwise falls back to the first panel", async () => {
const { CodexChatView } = await import("../../src/features/chat/host/view.obsidian");
const firstLeaf = leaf();
firstLeaf.view = chatView(CodexChatView, firstLeaf);
const openFirst = vi.spyOn((firstLeaf.view as CodexChatView).surface, "openThread").mockResolvedValue(undefined);
const recentLeaf = leaf();
recentLeaf.view = chatView(CodexChatView, recentLeaf);
const openRecent = vi.spyOn((recentLeaf.view as CodexChatView).surface, "openThread").mockResolvedValue(undefined);
const plugin = await pluginWithLeaves([firstLeaf, recentLeaf]);
const mostRecentLeaf = plugin.app.workspace.getMostRecentLeaf as ReturnType<typeof vi.fn>;
mostRecentLeaf.mockReturnValue(recentLeaf);
await panels(plugin).openThreadInCurrentView("thread-1");
expect(openRecent).toHaveBeenCalledWith("thread-1");
expect(openFirst).not.toHaveBeenCalled();
openRecent.mockClear();
mostRecentLeaf.mockReturnValue(null);
await panels(plugin).openThreadInCurrentView("thread-2");
expect(openFirst).toHaveBeenCalledWith("thread-2");
expect(openRecent).not.toHaveBeenCalled();
});
it("loads a deferred current panel before opening the selected thread", async () => {
const restoredLeaf = leaf({ state: { threadId: "restored-thread" } });
const plugin = await pluginWithLeaves([restoredLeaf]);
(plugin.app.workspace.getMostRecentLeaf as ReturnType<typeof vi.fn>).mockReturnValue(restoredLeaf);
const { CodexChatView } = await import("../../src/features/chat/host/view.obsidian");
const view = chatView(CodexChatView, restoredLeaf);
const open = vi.spyOn(view.surface, "openThread").mockResolvedValue(undefined);
restoredLeaf.loadIfDeferred.mockImplementation(async () => {
restoredLeaf.view = view;
});
await panels(plugin).openThreadInCurrentView("selected-thread");
expect(restoredLeaf.loadIfDeferred).toHaveBeenCalledOnce();
expect(open).toHaveBeenCalledWith("selected-thread");
});
it("opens a thread in a new panel without a separate pre-connect", async () => {
const newLeaf = leaf();
const plugin = await pluginWithLeaves([]);
(plugin.app.workspace.getRightLeaf as ReturnType<typeof vi.fn>).mockReturnValue(newLeaf);
const { CodexChatView } = await import("../../src/features/chat/host/view.obsidian");
const view = chatView(CodexChatView, newLeaf);
newLeaf.setViewState.mockImplementation(async () => {
newLeaf.view = view;
});
const connect = vi.spyOn(view.surface, "connect").mockResolvedValue(undefined);
const open = vi.spyOn(view.surface, "openThread").mockResolvedValue(undefined);
await panels(plugin).openThreadInNewView("thread-1");
expect(connect).not.toHaveBeenCalled();
expect(open).toHaveBeenCalledWith("thread-1");
});
it("activates and marks the active Codex panel", async () => {
const { CodexChatView } = await import("../../src/features/chat/host/view.obsidian");
const firstLeaf = leaf();
firstLeaf.view = chatView(CodexChatView, firstLeaf);
vi.spyOn((firstLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(
panelSnapshot({ viewId: "first", threadId: "thread-1" }),
);
const activeLeaf = leaf();
activeLeaf.view = chatView(CodexChatView, activeLeaf);
const activeView = activeLeaf.view as CodexChatView;
vi.spyOn(activeView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "active", threadId: "thread-2" }));
const connect = vi.spyOn(activeView.surface, "connect").mockResolvedValue(undefined);
const focus = vi.spyOn(activeView.surface, "focusThread").mockResolvedValue(undefined);
const plugin = await pluginWithLeaves([firstLeaf, activeLeaf]);
(plugin.app.workspace.getActiveViewOfType as ReturnType<typeof vi.fn>).mockReturnValue(activeView);
await expect(panels(plugin).activateView()).resolves.toBe(activeView);
expect(connect).toHaveBeenCalledOnce();
expect(focus).toHaveBeenCalledOnce();
expect(panels(plugin).getOpenPanelSnapshots()).toMatchObject([
{ viewId: "first", lastFocused: false },
{ viewId: "active", lastFocused: true },
]);
});
it("opens empty panels and side chats with their distinct startup contracts", async () => {
const emptyLeaf = leaf();
const sideLeaf = leaf();
const plugin = await pluginWithLeaves([]);
const { CodexChatView } = await import("../../src/features/chat/host/view.obsidian");
const emptyView = chatView(CodexChatView, emptyLeaf);
const sideView = chatView(CodexChatView, sideLeaf);
const connect = vi.spyOn(emptyView.surface, "connect").mockResolvedValue(undefined);
const openSideChat = vi.spyOn(sideView.surface, "openSideChat").mockResolvedValue(true);
(plugin.app.workspace.getRightLeaf as ReturnType<typeof vi.fn>).mockReturnValueOnce(emptyLeaf).mockReturnValueOnce(sideLeaf);
emptyLeaf.setViewState.mockImplementation(async () => {
emptyLeaf.view = emptyView;
});
sideLeaf.setViewState.mockImplementation(async () => {
sideLeaf.view = sideView;
});
await panels(plugin).openNewPanel();
await panels(plugin).openSideChat("source", "Source thread");
expect(connect).toHaveBeenCalledOnce();
expect(sideLeaf.setViewState).toHaveBeenCalledWith({
type: VIEW_TYPE_CODEX_PANEL,
active: true,
state: { version: 2, ephemeralSource: { threadId: "source", title: "Source thread" } },
});
expect(openSideChat).toHaveBeenCalledWith({ sourceThreadId: "source", sourceThreadTitle: "Source thread" });
});
});
function panels(plugin: CodexPanelPlugin): WorkspacePanelCoordinator {
return new WorkspacePanelCoordinator({ app: plugin.app, refreshThreadsViewLiveState: vi.fn() });
}