mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
refactor(test): simplify plugin composition contracts
This commit is contained in:
parent
ed942d1c9b
commit
40153a1da5
6 changed files with 836 additions and 1004 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
279
tests/plugin-runtime.integration.test.ts
Normal file
279
tests/plugin-runtime.integration.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
194
tests/support/plugin-fixtures.ts
Normal file
194
tests/support/plugin-fixtures.ts
Normal 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;
|
||||
};
|
||||
258
tests/workspace/panel-coordinator.test.ts
Normal file
258
tests/workspace/panel-coordinator.test.ts
Normal 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() });
|
||||
}
|
||||
Loading…
Reference in a new issue