mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
refactor(test): focus UI tests on observable behavior
This commit is contained in:
parent
86fc06998b
commit
ed942d1c9b
15 changed files with 815 additions and 1225 deletions
|
|
@ -70,7 +70,7 @@ describe("useChatSelector", () => {
|
|||
unmountUiRoot(parent);
|
||||
});
|
||||
|
||||
it("rerenders only the region whose selected state changed", async () => {
|
||||
it("isolates stream and composer updates to their subscribed regions", async () => {
|
||||
const store = createChatStateStore();
|
||||
const renders = { toolbar: vi.fn(), goal: vi.fn(), threadStream: vi.fn(), composer: vi.fn() };
|
||||
const parent = document.createElement("div");
|
||||
|
|
@ -78,27 +78,7 @@ describe("useChatSelector", () => {
|
|||
await act(async () => {
|
||||
renderUiRoot(parent, <SelectorRegions store={store} renders={renders} />);
|
||||
});
|
||||
expect(regionRenderCounts(renders)).toEqual([1, 1, 1, 1]);
|
||||
|
||||
await act(async () => {
|
||||
store.dispatch({ type: "composer/draft-set", draft: "draft" });
|
||||
});
|
||||
expect(regionRenderCounts(renders)).toEqual([1, 1, 1, 2]);
|
||||
|
||||
await act(async () => {
|
||||
store.dispatch({ type: "ui/goal-editor-started", threadId: null, objective: "Goal", tokenBudget: null });
|
||||
});
|
||||
expect(regionRenderCounts(renders)).toEqual([1, 2, 1, 2]);
|
||||
|
||||
await act(async () => {
|
||||
store.dispatch({ type: "ui/panel-set", panel: "history" });
|
||||
});
|
||||
expect(regionRenderCounts(renders)).toEqual([2, 2, 1, 2]);
|
||||
|
||||
await act(async () => {
|
||||
store.dispatch({ type: "ui/disclosure-set", bucket: "details", id: "item", open: true });
|
||||
});
|
||||
expect(regionRenderCounts(renders)).toEqual([2, 2, 2, 2]);
|
||||
clearRegionRenders(renders);
|
||||
|
||||
await act(async () => {
|
||||
store.dispatch({
|
||||
|
|
@ -106,16 +86,13 @@ describe("useChatSelector", () => {
|
|||
item: { id: "status", kind: "system", role: "system", text: "Status" },
|
||||
});
|
||||
});
|
||||
expect(regionRenderCounts(renders)).toEqual([2, 2, 3, 2]);
|
||||
expect(renderedRegions(renders)).toEqual(["threadStream"]);
|
||||
clearRegionRenders(renders);
|
||||
|
||||
await act(async () => {
|
||||
store.dispatch({
|
||||
type: "thread-stream/items-replaced",
|
||||
historyCursor: null,
|
||||
items: [{ id: "user", kind: "dialogue", dialogueKind: "user", role: "user", text: "Hello", turnId: "turn" }],
|
||||
});
|
||||
store.dispatch({ type: "composer/draft-set", draft: "draft" });
|
||||
});
|
||||
expect(regionRenderCounts(renders)).toEqual([2, 2, 4, 3]);
|
||||
expect(renderedRegions(renders)).toEqual(["composer"]);
|
||||
unmountUiRoot(parent);
|
||||
});
|
||||
});
|
||||
|
|
@ -155,13 +132,19 @@ function Region<Selection extends object>({
|
|||
return null;
|
||||
}
|
||||
|
||||
function regionRenderCounts(renders: {
|
||||
type RegionRenders = {
|
||||
toolbar: Mock<() => void>;
|
||||
goal: Mock<() => void>;
|
||||
threadStream: Mock<() => void>;
|
||||
composer: Mock<() => void>;
|
||||
}): number[] {
|
||||
return [renders.toolbar, renders.goal, renders.threadStream, renders.composer].map((rendered) => rendered.mock.calls.length);
|
||||
};
|
||||
|
||||
function clearRegionRenders(renders: RegionRenders): void {
|
||||
for (const rendered of Object.values(renders)) rendered.mockClear();
|
||||
}
|
||||
|
||||
function renderedRegions(renders: RegionRenders): string[] {
|
||||
return Object.entries(renders).flatMap(([region, rendered]) => (rendered.mock.calls.length > 0 ? [region] : []));
|
||||
}
|
||||
|
||||
interface ControllableStore extends ChatStateStore {
|
||||
|
|
|
|||
|
|
@ -31,9 +31,7 @@ describe("ChatPanelShell", () => {
|
|||
|
||||
expect(container.classList.contains("codex-panel")).toBe(true);
|
||||
expect(container.querySelector(".codex-panel__toolbar .codex-panel__toolbar-primary")).not.toBeNull();
|
||||
expect(container.querySelector(".codex-panel__body > .codex-panel__region--thread-stream")).toBe(
|
||||
container.querySelector(".codex-panel__body > .codex-panel__thread-stream"),
|
||||
);
|
||||
expect(container.querySelector(".codex-panel__region--thread-stream")).not.toBeNull();
|
||||
expect(container.querySelector<HTMLTextAreaElement>(".codex-panel__region--composer textarea")?.value).toBe("");
|
||||
expect(container.querySelector(".codex-panel__thread-stream-block")?.textContent).toContain("Send a message");
|
||||
|
||||
|
|
@ -183,7 +181,10 @@ describe("ChatPanelShell", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("repairs a missing toolbar through an explicit shell render", async () => {
|
||||
it.each([
|
||||
["a missing toolbar", (container: HTMLElement) => container.querySelector<HTMLElement>(":scope > .codex-panel__toolbar")?.remove()],
|
||||
["an unexpected root child", (container: HTMLElement) => container.appendChild(document.createElement("section"))],
|
||||
])("repairs %s through an explicit shell render", async (_description, damageRoot) => {
|
||||
const store = createChatStateStore();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
|
@ -194,34 +195,7 @@ describe("ChatPanelShell", () => {
|
|||
await settleShellEffects();
|
||||
});
|
||||
|
||||
container.querySelector<HTMLElement>(":scope > .codex-panel__toolbar")?.remove();
|
||||
|
||||
await act(async () => {
|
||||
store.dispatch({ type: "composer/draft-set", draft: "repair toolbar" });
|
||||
renderChatPanelShell(container, { stateStore: store, showToolbar: true, parts });
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
expect(container.querySelector(".codex-panel__toolbar .codex-panel__toolbar-primary")).not.toBeNull();
|
||||
expect(container.querySelector<HTMLTextAreaElement>(".codex-panel__region--composer textarea")?.value).toBe("repair toolbar");
|
||||
|
||||
await act(async () => {
|
||||
unmountChatPanelShell(container);
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs unexpected root-level DOM through an explicit shell render", async () => {
|
||||
const store = createChatStateStore();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const parts = shellParts();
|
||||
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, { stateStore: store, showToolbar: true, parts });
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
container.appendChild(document.createElement("section"));
|
||||
damageRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
store.dispatch({ type: "composer/draft-set", draft: "repair root" });
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
} from "../../../../../src/features/chat/panel/thread-stream-scroll-binding";
|
||||
import { ThreadStreamMarkdownRenderer } from "../../../../../src/features/chat/ui/thread-stream/markdown-renderer.obsidian";
|
||||
import { ThreadStreamViewport } from "../../../../../src/features/chat/ui/thread-stream/stream-blocks";
|
||||
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/dom/preact-root.dom";
|
||||
import { renderUiRoot } from "../../../../../src/shared/dom/preact-root.dom";
|
||||
import { notices } from "../../../../mocks/obsidian";
|
||||
import { installObsidianDomShims } from "../../../../support/dom";
|
||||
import { threadStreamModelFromChatState } from "../../support/shell-selectors";
|
||||
|
|
@ -26,8 +26,6 @@ import { chatStateFixture, chatStateWith } from "../../support/state";
|
|||
import { withChatStateThreadStreamItems } from "../../support/thread-stream";
|
||||
import { installThreadStreamViewportMetrics, pendingApproval } from "../../ui/thread-stream/test-helpers";
|
||||
|
||||
const ESTIMATED_THREAD_STREAM_BLOCK_HEIGHT = 96;
|
||||
|
||||
installObsidianDomShims();
|
||||
|
||||
function renderThreadStreamPresenter(parent: HTMLElement, presenter: ThreadStreamPresenter, state: ChatState): void {
|
||||
|
|
@ -268,7 +266,7 @@ describe("ThreadStreamPresenter scroll pinning", () => {
|
|||
cleanup();
|
||||
});
|
||||
|
||||
it("pins to the scroll container bottom without aligning the last stream item element", async () => {
|
||||
it("pins to the scroll container bottom", async () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, { activeThread: { id: "thread" } });
|
||||
state = withChatStateThreadStreamItems(state, [
|
||||
|
|
@ -287,17 +285,13 @@ describe("ThreadStreamPresenter scroll pinning", () => {
|
|||
|
||||
renderThreadStreamPresenter(parent, presenter, state);
|
||||
const viewport = threadStreamViewport(parent);
|
||||
Object.defineProperty(viewport, "scrollHeight", { value: ESTIMATED_THREAD_STREAM_BLOCK_HEIGHT, configurable: true });
|
||||
installThreadStreamViewportMetrics(viewport, { clientHeight: 100 });
|
||||
viewport.scrollTop = 920;
|
||||
installThreadStreamViewportMetrics(viewport, { clientHeight: 100, scrollHeight: 1000 });
|
||||
viewport.scrollTop = 100;
|
||||
|
||||
const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
|
||||
scrollIntoView.mockClear();
|
||||
scrollPortBinding.showLatest();
|
||||
await settleThreadStreamRender(viewport);
|
||||
|
||||
expect(viewport.scrollTop).toBe(0);
|
||||
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||
expect(viewport.scrollTop).toBe(900);
|
||||
});
|
||||
|
||||
it("repins after composer growth has changed the scroll viewport height", async () => {
|
||||
|
|
@ -316,12 +310,12 @@ describe("ThreadStreamPresenter scroll pinning", () => {
|
|||
]);
|
||||
const parent = document.createElement("div");
|
||||
const { presenter, scrollPortBinding } = threadStreamPresenter(state);
|
||||
|
||||
const viewport = parent.createDiv({ cls: "codex-panel__thread-stream" });
|
||||
renderThreadStreamPresenter(parent, presenter, state);
|
||||
const viewport = threadStreamViewport(parent);
|
||||
let scrollTop = 0;
|
||||
let layoutSettled = false;
|
||||
Object.defineProperties(viewport, {
|
||||
scrollHeight: { value: ESTIMATED_THREAD_STREAM_BLOCK_HEIGHT, configurable: true },
|
||||
scrollHeight: { value: 240, configurable: true },
|
||||
clientHeight: {
|
||||
get: () => (layoutSettled ? 100 : 160),
|
||||
configurable: true,
|
||||
|
|
@ -335,99 +329,22 @@ describe("ThreadStreamPresenter scroll pinning", () => {
|
|||
scrollTop: {
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => {
|
||||
scrollTop = Math.min(value, Math.max(0, ESTIMATED_THREAD_STREAM_BLOCK_HEIGHT - viewport.clientHeight));
|
||||
scrollTop = Math.min(value, Math.max(0, viewport.scrollHeight - viewport.clientHeight));
|
||||
},
|
||||
configurable: true,
|
||||
},
|
||||
});
|
||||
viewport.scrollTop = 1000;
|
||||
renderThreadStreamPresenter(viewport, presenter, state);
|
||||
await settleThreadStreamRender(viewport);
|
||||
expect(viewport.scrollTop).toBe(0);
|
||||
viewport.dispatchEvent(new Event("scroll"));
|
||||
expect(viewport.scrollTop).toBe(80);
|
||||
|
||||
scrollPortBinding.showLatest();
|
||||
expect(viewport.scrollTop).toBe(0);
|
||||
expect(viewport.scrollTop).toBe(80);
|
||||
|
||||
layoutSettled = true;
|
||||
await settleThreadStreamRender(viewport);
|
||||
|
||||
expect(viewport.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts scroll commands when no thread stream viewport is mounted", () => {
|
||||
const { presenter, scrollPortBinding } = threadStreamPresenter();
|
||||
|
||||
expect(() => {
|
||||
scrollPortBinding.showLatest();
|
||||
scrollPortBinding.scrollFromComposer({ kind: "scroll-by", direction: 1, amount: "text-lines" });
|
||||
scrollPortBinding.scrollFromComposer({ kind: "scroll-by", direction: -1, amount: "page" });
|
||||
scrollPortBinding.scrollFromComposer({ kind: "scroll-to", edge: "start" });
|
||||
presenter.dispose();
|
||||
scrollPortBinding.showLatest();
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("detaches the active scroll port when the thread stream unmounts", async () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, { activeThread: { id: "thread" } });
|
||||
state = withChatStateThreadStreamItems(state, [
|
||||
{
|
||||
id: "message",
|
||||
kind: "dialogue",
|
||||
role: "assistant",
|
||||
text: "Rendered message",
|
||||
turnId: "turn",
|
||||
dialogueKind: "assistantResponse",
|
||||
dialogueState: "completed",
|
||||
},
|
||||
]);
|
||||
const parent = document.createElement("div");
|
||||
const { presenter, scrollPortBinding } = threadStreamPresenter(state);
|
||||
renderThreadStreamPresenter(parent, presenter, state);
|
||||
const viewport = threadStreamViewport(parent);
|
||||
installThreadStreamViewportMetrics(viewport);
|
||||
await settleThreadStreamRender(viewport);
|
||||
|
||||
unmountUiRoot(parent);
|
||||
|
||||
expect(() => {
|
||||
scrollPortBinding.showLatest();
|
||||
scrollPortBinding.scrollFromComposer({ kind: "scroll-by", direction: 1, amount: "page" });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("binds scroll commands to the currently mounted thread stream viewport", async () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, { activeThread: { id: "thread" } });
|
||||
state = withChatStateThreadStreamItems(state, [
|
||||
{
|
||||
id: "message",
|
||||
kind: "dialogue",
|
||||
role: "assistant",
|
||||
text: "Rendered message",
|
||||
turnId: "turn",
|
||||
dialogueKind: "assistantResponse",
|
||||
dialogueState: "completed",
|
||||
},
|
||||
]);
|
||||
const parent = document.createElement("div");
|
||||
const { presenter, scrollPortBinding } = threadStreamPresenter(state);
|
||||
renderThreadStreamPresenter(parent, presenter, state);
|
||||
const oldMessages = threadStreamViewport(parent);
|
||||
installThreadStreamViewportMetrics(oldMessages, { clientHeight: 100, scrollHeight: 1000 });
|
||||
await settleThreadStreamRender(oldMessages);
|
||||
oldMessages.scrollTop = 125;
|
||||
|
||||
unmountUiRoot(parent);
|
||||
|
||||
renderThreadStreamPresenter(parent, presenter, state);
|
||||
const newMessages = threadStreamViewport(parent);
|
||||
installThreadStreamViewportMetrics(newMessages, { clientHeight: 100, scrollHeight: 1000 });
|
||||
await settleThreadStreamRender(newMessages);
|
||||
scrollPortBinding.showLatest();
|
||||
|
||||
expect(newMessages.scrollTop).toBe(900);
|
||||
expect(oldMessages.scrollTop).toBe(125);
|
||||
expect(viewport.scrollTop).toBe(140);
|
||||
});
|
||||
|
||||
it("completes bottom pinning after the thread stream viewport commits", async () => {
|
||||
|
|
@ -483,8 +400,6 @@ describe("ThreadStreamPresenter scroll pinning", () => {
|
|||
viewport.scrollTop = 100;
|
||||
viewport.dispatchEvent(new Event("scroll"));
|
||||
|
||||
const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
|
||||
scrollIntoView.mockClear();
|
||||
state = withChatStateThreadStreamItems(state, [
|
||||
{
|
||||
id: "message",
|
||||
|
|
@ -499,7 +414,7 @@ describe("ThreadStreamPresenter scroll pinning", () => {
|
|||
renderThreadStreamPresenter(viewport, presenter, state);
|
||||
await settleThreadStreamRender(viewport);
|
||||
|
||||
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||
expect(viewport.scrollTop).toBe(100);
|
||||
});
|
||||
|
||||
it("does not run a pending bottom pin after the user scrolls away", async () => {
|
||||
|
|
@ -525,70 +440,11 @@ describe("ThreadStreamPresenter scroll pinning", () => {
|
|||
viewport.scrollTop = 920;
|
||||
renderThreadStreamPresenter(viewport, presenter, state);
|
||||
|
||||
const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
|
||||
scrollIntoView.mockClear();
|
||||
viewport.scrollTop = 100;
|
||||
viewport.dispatchEvent(new Event("scroll"));
|
||||
await settleThreadStreamRender(viewport);
|
||||
|
||||
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the mounted thread stream content in place on dispose", async () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, { activeThread: { id: "thread" } });
|
||||
state = withChatStateThreadStreamItems(state, [
|
||||
{
|
||||
id: "message",
|
||||
kind: "dialogue",
|
||||
role: "assistant",
|
||||
text: "Rendered message",
|
||||
turnId: "turn",
|
||||
dialogueKind: "assistantResponse",
|
||||
dialogueState: "completed",
|
||||
},
|
||||
]);
|
||||
const parent = document.createElement("div");
|
||||
const { presenter } = threadStreamPresenter(state);
|
||||
|
||||
renderThreadStreamPresenter(parent, presenter, state);
|
||||
let viewport = threadStreamViewport(parent);
|
||||
installThreadStreamViewportMetrics(viewport);
|
||||
renderThreadStreamPresenter(parent, presenter, state);
|
||||
viewport = threadStreamViewport(parent);
|
||||
await settleThreadStreamRender(viewport);
|
||||
expect(parent.querySelector(".codex-panel__thread-stream")).not.toBeNull();
|
||||
|
||||
presenter.dispose();
|
||||
|
||||
expect(parent.querySelector(".codex-panel__thread-stream")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders large thread streams through the flow viewport", async () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, { activeThread: { id: "thread" } });
|
||||
state = withChatStateThreadStreamItems(
|
||||
state,
|
||||
Array.from({ length: 200 }, (_value, index) => ({
|
||||
id: `message-${String(index)}`,
|
||||
kind: "dialogue",
|
||||
role: "assistant",
|
||||
text: `Message ${String(index)}`,
|
||||
turnId: "turn",
|
||||
dialogueKind: "assistantResponse",
|
||||
dialogueState: "completed",
|
||||
})),
|
||||
);
|
||||
const parent = document.createElement("div");
|
||||
const { presenter } = threadStreamPresenter(state);
|
||||
|
||||
renderThreadStreamPresenter(parent, presenter, state);
|
||||
const viewport = threadStreamViewport(parent);
|
||||
installThreadStreamViewportMetrics(viewport, { clientHeight: 320, scrollHeight: 19_200 });
|
||||
await settleThreadStreamRender(viewport);
|
||||
|
||||
expect(parent.querySelector(".codex-panel__thread-stream-flow")).not.toBeNull();
|
||||
expect(parent.querySelectorAll("[data-codex-panel-block-key]").length).toBeGreaterThan(0);
|
||||
expect(viewport.scrollTop).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createChatThreadStreamScrollBinding } from "../../../../src/features/chat/panel/thread-stream-scroll-binding";
|
||||
import type { ThreadStreamScrollPort } from "../../../../src/features/chat/ui/thread-stream/flow-scroll.measure";
|
||||
|
||||
describe("chat thread stream scroll binding", () => {
|
||||
it("forwards panel and composer commands to the mounted scroll port", () => {
|
||||
const binding = createChatThreadStreamScrollBinding();
|
||||
const dispatchScrollCommand = vi.fn<ThreadStreamScrollPort["dispatchScrollCommand"]>();
|
||||
|
||||
binding.showLatest();
|
||||
binding.scrollFromComposer({ kind: "scroll-to", edge: "start" });
|
||||
expect(dispatchScrollCommand).not.toHaveBeenCalled();
|
||||
|
||||
binding.mountScrollPort({ dispatchScrollCommand });
|
||||
binding.showLatest();
|
||||
binding.scrollFromComposer({ kind: "scroll-by", direction: -1, amount: "page" });
|
||||
|
||||
expect(dispatchScrollCommand.mock.calls).toEqual([[{ kind: "show-latest" }], [{ kind: "scroll-by", direction: -1, amount: "page" }]]);
|
||||
});
|
||||
|
||||
it("keeps the newest port mounted and stops forwarding after unmount or disposal", () => {
|
||||
const binding = createChatThreadStreamScrollBinding();
|
||||
const first = vi.fn<ThreadStreamScrollPort["dispatchScrollCommand"]>();
|
||||
const second = vi.fn<ThreadStreamScrollPort["dispatchScrollCommand"]>();
|
||||
const unmountFirst = binding.mountScrollPort({ dispatchScrollCommand: first });
|
||||
const unmountSecond = binding.mountScrollPort({ dispatchScrollCommand: second });
|
||||
|
||||
unmountFirst();
|
||||
binding.showLatest();
|
||||
expect(first).not.toHaveBeenCalled();
|
||||
expect(second).toHaveBeenCalledWith({ kind: "show-latest" });
|
||||
|
||||
unmountSecond();
|
||||
binding.showLatest();
|
||||
expect(second).toHaveBeenCalledOnce();
|
||||
|
||||
binding.mountScrollPort({ dispatchScrollCommand: second });
|
||||
binding.dispose();
|
||||
binding.showLatest();
|
||||
expect(second).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items";
|
||||
import type { PendingRequestBlockSnapshot } from "../../../../../src/features/chat/presentation/pending-requests/view-model";
|
||||
import { threadStreamViewBlocks } from "../../../../../src/features/chat/presentation/thread-stream/view-model";
|
||||
|
||||
describe("thread stream presentation blocks", () => {
|
||||
|
|
@ -41,6 +42,19 @@ describe("thread stream presentation blocks", () => {
|
|||
expect(blocks.find((block) => block.kind === "liveAgentSummary")).toMatchObject({ key: "live-agents:turn" });
|
||||
});
|
||||
|
||||
it("orders active live blocks by insertion and appends pending requests", () => {
|
||||
const blocks = threadStreamViewBlocks({
|
||||
activeThreadId: "thread",
|
||||
activeTurnId: "turn",
|
||||
historyCursor: null,
|
||||
loadingHistory: false,
|
||||
items: [userDialogue("u1", "turn"), agentItem("agent", "turn"), taskProgressItem("task", "turn")],
|
||||
pendingRequests: { signature: "request:1", snapshot: emptyPendingRequestSnapshot() },
|
||||
});
|
||||
|
||||
expect(blocks.map((block) => block.key)).toEqual(["item:u1", "item:agent", "live-agents:turn", "live-task:task", "pending-requests"]);
|
||||
});
|
||||
|
||||
it("renders unknown item kinds as generic status updates", () => {
|
||||
const blocks = threadStreamViewBlocks({
|
||||
activeThreadId: "thread",
|
||||
|
|
@ -111,3 +125,14 @@ function agentItem(id: string, turnId: string): ThreadStreamItem {
|
|||
executionState: "running",
|
||||
};
|
||||
}
|
||||
|
||||
function emptyPendingRequestSnapshot(): PendingRequestBlockSnapshot {
|
||||
return {
|
||||
approvals: [],
|
||||
pendingUserInputs: [],
|
||||
pendingMcpElicitations: [],
|
||||
userInputDrafts: new Map(),
|
||||
mcpElicitationDrafts: new Map(),
|
||||
approvalDetails: new Set(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,16 +116,13 @@ describe("ComposerShell decisions", () => {
|
|||
],
|
||||
});
|
||||
|
||||
const meta = parent.querySelector<HTMLElement>(".codex-panel__composer-meta");
|
||||
const contextDots = Array.from(parent.querySelectorAll<HTMLElement>(".codex-panel__composer-meta-context-dot"));
|
||||
const modeIcons = Array.from(parent.querySelectorAll<HTMLElement>(".codex-panel__composer-meta-icon"));
|
||||
const statusSummary = parent.querySelector<HTMLElement>(".codex-panel__composer-meta-summary");
|
||||
const statusVisual = parent.querySelector<HTMLElement>(".codex-panel__composer-meta-status-visual");
|
||||
expect(meta?.getAttribute("aria-hidden")).toBeNull();
|
||||
expect(statusSummary?.textContent).toBe("Context 42%, plan on, auto-review off, fast on, model gpt-5.5, reasoning effort high");
|
||||
expect(statusVisual?.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(statusVisual?.textContent).toBe("|⣿⣶⣀⣀42%|gpt-5.5|high");
|
||||
expect(parent.querySelector(".codex-panel__composer-meta-status")?.getAttribute("aria-hidden")).toBeNull();
|
||||
expect(parent.querySelector(".codex-panel__composer-meta-context")?.textContent).toBe("⣿⣶⣀⣀42%");
|
||||
expect(contextDots.map((dot) => dot.textContent)).toEqual(["⣿", "⣶", "⣀", "⣀"]);
|
||||
expect(contextDots.map((dot) => dot.classList.contains("is-placeholder"))).toEqual([false, false, true, true]);
|
||||
|
|
@ -133,17 +130,7 @@ describe("ComposerShell decisions", () => {
|
|||
expect(parent.querySelector(".codex-panel__composer-meta-effort")?.textContent).toBe("high");
|
||||
expect(modeIcons.map((icon) => icon.dataset["icon"])).toEqual(["list-todo", "shield", "zap"]);
|
||||
expect(modeIcons.map((icon) => icon.classList.contains("is-active"))).toEqual([true, false, true]);
|
||||
expect(modeIcons.map((icon) => icon.tagName)).toEqual(["SPAN", "SPAN", "SPAN"]);
|
||||
expect(modeIcons.map((icon) => icon.getAttribute("role"))).toEqual([null, null, null]);
|
||||
expect(modeIcons.map((icon) => icon.getAttribute("tabindex"))).toEqual([null, null, null]);
|
||||
expect(modeIcons.map((icon) => icon.getAttribute("aria-label"))).toEqual([null, null, null]);
|
||||
expect(modeIcons.map((icon) => icon.getAttribute("aria-hidden"))).toEqual(["true", "true", "true"]);
|
||||
expect(modeIcons.every((icon) => icon.classList.contains("codex-panel__composer-meta-trigger"))).toBe(true);
|
||||
expect(modeIcons.every((icon) => !icon.className.includes("clickable-icon"))).toBe(true);
|
||||
expect(parent.querySelector<HTMLElement>(".codex-panel__composer-meta-model")?.tagName).toBe("SPAN");
|
||||
expect(parent.querySelector<HTMLElement>(".codex-panel__composer-meta-effort")?.tagName).toBe("SPAN");
|
||||
expect(parent.querySelector<HTMLElement>(".codex-panel__composer-meta-model")?.getAttribute("role")).toBeNull();
|
||||
expect(parent.querySelector<HTMLElement>(".codex-panel__composer-meta-effort")?.getAttribute("tabindex")).toBeNull();
|
||||
expect(parent.querySelector(".codex-panel__composer-action.codex-panel__send")).not.toBeNull();
|
||||
expect(parent.querySelector(".codex-panel__new-chat")).toBeNull();
|
||||
});
|
||||
|
|
@ -194,12 +181,7 @@ describe("ComposerShell decisions", () => {
|
|||
expect(parent.querySelector('[data-codex-panel-composer-meta-kind="model"]')?.textContent).toBe("gpt-5.5gpt-5.4");
|
||||
});
|
||||
const metaOptions = Array.from(parent.querySelectorAll<HTMLElement>(".codex-panel__composer-meta-option"));
|
||||
expect(metaOptions.map((option) => option.getAttribute("role"))).toEqual([null, null]);
|
||||
expect(metaOptions.map((option) => option.getAttribute("tabindex"))).toEqual([null, null]);
|
||||
expect(metaOptions.map((option) => option.getAttribute("aria-selected"))).toEqual([null, null]);
|
||||
expect(metaOptions.every((option) => !option.classList.contains("is-selected"))).toBe(true);
|
||||
expect(metaOptions.every((option) => !option.classList.contains("codex-panel-ui__nav-item"))).toBe(true);
|
||||
expect(metaOptions.every((option) => !option.classList.contains("codex-panel__toolbar-panel-item"))).toBe(true);
|
||||
expect(metaOptions.map((option) => option.textContent)).toEqual(["gpt-5.5", "gpt-5.4"]);
|
||||
|
||||
parent.querySelector<HTMLElement>(".codex-panel__composer-meta-effort")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
await waitForAsyncWork(() => {
|
||||
|
|
@ -484,7 +466,6 @@ describe("ComposerShell decisions", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
expect(parent.querySelector<HTMLTextAreaElement>(".codex-panel__composer-input")).toBe(composer);
|
||||
expect(composer.style.height).toBe("56px");
|
||||
expect(callbacks.onHeightChange).toHaveBeenCalled();
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -3,11 +3,8 @@
|
|||
import { MarkdownRenderer } from "obsidian";
|
||||
import { act } from "preact/test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { implementPlanTarget } from "../../../../../src/features/chat/application/turns/plan-implementation";
|
||||
import { pendingWebSubmissionItem } from "../../../../../src/features/chat/application/turns/web-submission";
|
||||
import { setCollaborationModeIntent } from "../../../../../src/features/chat/domain/runtime/intent";
|
||||
import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items";
|
||||
import { THREAD_STREAM_CONTENT_RENDERED_EVENT } from "../../../../../src/features/chat/ui/thread-stream/content-rendered-event.dom";
|
||||
import { ThreadStreamMarkdownRenderer } from "../../../../../src/features/chat/ui/thread-stream/markdown-renderer.obsidian";
|
||||
import { deferred } from "../../../../support/async";
|
||||
import { attributeValues, textContents, topLevelDetailsSummaries } from "../../../../support/dom";
|
||||
|
|
@ -83,90 +80,6 @@ describe("thread stream rendering and action menu", () => {
|
|||
unmountUiRootInAct(parent);
|
||||
});
|
||||
|
||||
it("keeps Work details in the flow when the activity group is collapsed", async () => {
|
||||
const parent = document.createElement("div");
|
||||
const blocks = threadStreamBlocks({
|
||||
items: [
|
||||
{ id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" },
|
||||
{
|
||||
id: "hook-1",
|
||||
kind: "hook",
|
||||
role: "tool",
|
||||
text: "userPromptSubmit: Saving jj baseline",
|
||||
toolName: "hook",
|
||||
turnId: "t1",
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
id: "a1",
|
||||
kind: "dialogue",
|
||||
role: "assistant",
|
||||
text: "done",
|
||||
turnId: "t1",
|
||||
dialogueKind: "assistantResponse",
|
||||
dialogueState: "completed",
|
||||
},
|
||||
],
|
||||
disclosures: testDisclosures({ activityGroups: ["t1"] }),
|
||||
});
|
||||
const activityBlock = expectPresent(blocks.find((block) => block.key === "activity:turn-t1-activity"));
|
||||
|
||||
renderThreadStreamBlocksInAct(parent, [activityBlock]);
|
||||
|
||||
const host = expectPresent(parent.querySelector<HTMLElement>(".codex-panel__thread-stream-block"));
|
||||
const threadStreamFlow = expectPresent(parent.querySelector<HTMLElement>(".codex-panel__thread-stream-flow"));
|
||||
const activityGroup = expectPresent(parent.querySelector<HTMLDetailsElement>(".codex-panel__activity-group"));
|
||||
|
||||
Object.defineProperty(host, "offsetHeight", { value: 520, configurable: true });
|
||||
await act(async () => {
|
||||
activityGroup.dispatchEvent(new Event(THREAD_STREAM_CONTENT_RENDERED_EVENT, { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(threadStreamFlow.style.height).toBe("");
|
||||
expect(host.style.transform).toBe("");
|
||||
|
||||
Object.defineProperty(host, "offsetHeight", { value: 120, configurable: true });
|
||||
await act(async () => {
|
||||
activityGroup.open = false;
|
||||
activityGroup.dispatchEvent(new Event("toggle"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(threadStreamFlow.style.height).toBe("");
|
||||
expect(host.style.transform).toBe("");
|
||||
unmountUiRootInAct(parent);
|
||||
});
|
||||
|
||||
it("keeps blocks in the flow after their rendered content shrinks on rerender", () => {
|
||||
const parent = document.createElement("div");
|
||||
const block = threadStreamBlocks({
|
||||
items: [{ id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "expanded", turnId: "t1" }],
|
||||
})[0];
|
||||
|
||||
renderThreadStreamBlocksInAct(parent, [block]);
|
||||
|
||||
const host = expectPresent(parent.querySelector<HTMLElement>(".codex-panel__thread-stream-block"));
|
||||
const threadStreamFlow = expectPresent(parent.querySelector<HTMLElement>(".codex-panel__thread-stream-flow"));
|
||||
|
||||
Object.defineProperty(host, "offsetHeight", { value: 520, configurable: true });
|
||||
void act(() => {
|
||||
host.dispatchEvent(new Event(THREAD_STREAM_CONTENT_RENDERED_EVENT, { bubbles: true }));
|
||||
});
|
||||
expect(threadStreamFlow.style.height).toBe("");
|
||||
expect(host.style.transform).toBe("");
|
||||
|
||||
Object.defineProperty(host, "offsetHeight", { value: 120, configurable: true });
|
||||
renderThreadStreamBlocksInAct(parent, [
|
||||
threadStreamBlocks({
|
||||
items: [{ id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "collapsed", turnId: "t1" }],
|
||||
})[0],
|
||||
]);
|
||||
|
||||
expect(threadStreamFlow.style.height).toBe("");
|
||||
expect(host.style.transform).toBe("");
|
||||
unmountUiRootInAct(parent);
|
||||
});
|
||||
|
||||
it("renders review result items as compact auto-review tool rows", () => {
|
||||
const block = threadStreamBlocks({
|
||||
items: [{ id: "review-1", kind: "reviewResult", role: "tool", text: "Auto-review denied this command." }],
|
||||
|
|
@ -248,7 +161,6 @@ describe("thread stream rendering and action menu", () => {
|
|||
|
||||
expect(element.classList.contains("codex-panel__stream-item--system")).toBe(true);
|
||||
expect(element.querySelector(".codex-panel__stream-item-content")?.textContent).toBe("Available slash commands");
|
||||
expect(element.querySelector(".codex-panel__stream-item-content")?.classList.contains("markdown-rendered")).toBe(false);
|
||||
expect(renderMarkdown).not.toHaveBeenCalled();
|
||||
expect(element.querySelector("details")).toBeNull();
|
||||
expect(element.querySelector(".codex-panel__output-title")).toBeNull();
|
||||
|
|
@ -596,7 +508,6 @@ describe("thread stream rendering and action menu", () => {
|
|||
}),
|
||||
);
|
||||
const contentAfterUpdate = expectPresent(parent.querySelector<HTMLElement>(".codex-panel__stream-item-content"));
|
||||
expect(contentAfterUpdate).toBe(content);
|
||||
expect(contentAfterUpdate.textContent).toBe("rendered:old");
|
||||
|
||||
secondRender.resolve(undefined);
|
||||
|
|
@ -779,53 +690,6 @@ describe("thread stream rendering and action menu", () => {
|
|||
expect(onImplementPlan).toHaveBeenCalledWith({ itemId: "p1" });
|
||||
});
|
||||
|
||||
it("selects only the latest proposed plan as an implement candidate", () => {
|
||||
const firstPlan = {
|
||||
id: "p1",
|
||||
kind: "dialogue",
|
||||
role: "assistant",
|
||||
text: "# First plan",
|
||||
turnId: "turn-1",
|
||||
dialogueKind: "proposedPlan",
|
||||
dialogueState: "completed",
|
||||
} as const;
|
||||
const secondPlan = {
|
||||
id: "p2",
|
||||
kind: "dialogue",
|
||||
role: "assistant",
|
||||
text: "# Second plan",
|
||||
turnId: "turn-2",
|
||||
dialogueKind: "proposedPlan",
|
||||
dialogueState: "completed",
|
||||
} as const;
|
||||
const baseState = {
|
||||
activeThread: { id: "thread", provenance: null },
|
||||
turn: { lifecycle: { kind: "idle" as const } },
|
||||
runtime: { pending: { collaborationMode: setCollaborationModeIntent("plan") } },
|
||||
threadStream: {
|
||||
stableItems: [
|
||||
firstPlan,
|
||||
{
|
||||
id: "a1",
|
||||
kind: "dialogue",
|
||||
role: "assistant",
|
||||
text: "answer",
|
||||
dialogueKind: "assistantResponse",
|
||||
dialogueState: "completed",
|
||||
} as const,
|
||||
secondPlan,
|
||||
],
|
||||
activeSegment: null,
|
||||
},
|
||||
};
|
||||
|
||||
expect(implementPlanTarget(baseState)).toEqual({ itemId: secondPlan.id });
|
||||
expect(
|
||||
implementPlanTarget({ ...baseState, runtime: { pending: { collaborationMode: setCollaborationModeIntent("default") } } }),
|
||||
).toBeNull();
|
||||
expect(implementPlanTarget({ ...baseState, turn: { lifecycle: { kind: "running", turnId: "turn-2" } } })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render copy actions for tool items", () => {
|
||||
const block = threadStreamBlocks({
|
||||
items: [
|
||||
|
|
|
|||
|
|
@ -107,7 +107,6 @@ describe("pending request renderer decisions", () => {
|
|||
};
|
||||
|
||||
render();
|
||||
expect(parent.querySelector<HTMLInputElement>(".codex-panel__user-input-other-text")?.getAttribute("aria-label")).toBeNull();
|
||||
actEvent(() => {
|
||||
changeInputValue(expectPresent(parent.querySelector<HTMLInputElement>(".codex-panel__user-input-other-text")), "Custom scope");
|
||||
});
|
||||
|
|
@ -268,7 +267,6 @@ describe("pending request renderer decisions", () => {
|
|||
);
|
||||
|
||||
const inputElement = parent.querySelector<HTMLInputElement>(".codex-panel__user-input-text");
|
||||
expect(inputElement?.getAttribute("aria-label")).toBeNull();
|
||||
expect(document.activeElement).toBe(inputElement);
|
||||
} finally {
|
||||
unmountUiRootInAct(parent);
|
||||
|
|
@ -413,7 +411,6 @@ describe("pending request renderer decisions", () => {
|
|||
|
||||
expect(element.querySelector(".codex-panel__stream-item-role")?.textContent).toBe("Input");
|
||||
expect(element.querySelector(".codex-panel__stream-item-content")?.textContent).toBe("Input submitted for 1 question.");
|
||||
expect(element.querySelector(".codex-panel__stream-item-content")?.classList.contains("markdown-rendered")).toBe(false);
|
||||
expect(renderMarkdown).not.toHaveBeenCalled();
|
||||
expect(element.textContent).not.toContain("Approval");
|
||||
expect(element.querySelector("details summary")?.textContent).toBe("Question: Scope");
|
||||
|
|
@ -532,7 +529,6 @@ describe("pending request renderer decisions", () => {
|
|||
const input = expectPresent(parent.querySelector<HTMLInputElement>(".codex-panel__mcp-elicitation-input"));
|
||||
const label = expectPresent(parent.querySelector<HTMLElement>(".codex-panel__mcp-elicitation-label"));
|
||||
expect(label.id).toContain("test-panel");
|
||||
expect(label.tagName).toBe("LABEL");
|
||||
expect(label.getAttribute("for")).toBe(input.id);
|
||||
changeInputValue(input, "Updated");
|
||||
actEvent(() => {
|
||||
|
|
|
|||
|
|
@ -300,172 +300,6 @@ describe("thread stream item renderer decisions", () => {
|
|||
expect(element.textContent).toContain("[>]Patch UI");
|
||||
});
|
||||
|
||||
it("renders active task progress with the shared bottom live blocks", () => {
|
||||
const blocks = threadStreamBlocks({
|
||||
turnLifecycle: runningTurnLifecycle("turn"),
|
||||
items: [
|
||||
{ id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "Do it", turnId: "turn" },
|
||||
{
|
||||
id: "plan-progress-turn",
|
||||
kind: "taskProgress",
|
||||
role: "tool",
|
||||
text: "[>] Patch UI",
|
||||
turnId: "turn",
|
||||
explanation: null,
|
||||
steps: [{ step: "Patch UI", status: "inProgress" }],
|
||||
status: "inProgress",
|
||||
},
|
||||
{
|
||||
id: "a1",
|
||||
kind: "dialogue",
|
||||
role: "assistant",
|
||||
text: "Working",
|
||||
turnId: "turn",
|
||||
dialogueKind: "assistantResponse",
|
||||
dialogueState: "completed",
|
||||
},
|
||||
{
|
||||
id: "agent-1",
|
||||
kind: "agent",
|
||||
role: "tool",
|
||||
text: "Wait for agent",
|
||||
turnId: "turn",
|
||||
tool: "wait",
|
||||
status: "running",
|
||||
senderThreadId: "parent",
|
||||
receiverThreadIds: ["running"],
|
||||
prompt: null,
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
agents: [{ threadId: "running", status: "running", executionState: "running", message: "Inspecting renderer" }],
|
||||
},
|
||||
],
|
||||
pendingRequests: {
|
||||
controlNamespace: "test-panel",
|
||||
signature: "request:1",
|
||||
snapshot: () => ({
|
||||
approvals: [],
|
||||
pendingUserInputs: [],
|
||||
pendingMcpElicitations: [],
|
||||
userInputDrafts: new Map(),
|
||||
mcpElicitationDrafts: new Map(),
|
||||
approvalDetails: new Set(),
|
||||
}),
|
||||
actions: () => ({
|
||||
resolveApproval: vi.fn(),
|
||||
resolveUserInput: vi.fn(),
|
||||
cancelUserInput: vi.fn(),
|
||||
resolveMcpElicitation: vi.fn(),
|
||||
setUserInputDraft: vi.fn(),
|
||||
setMcpElicitationDraft: vi.fn(),
|
||||
}),
|
||||
consumeAutoFocus: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(blocks.map((block) => block.key)).toEqual([
|
||||
"item:u1",
|
||||
"item:a1",
|
||||
"item:agent-1",
|
||||
"live-task:plan-progress-turn",
|
||||
"live-agents:turn",
|
||||
"pending-requests",
|
||||
]);
|
||||
});
|
||||
|
||||
it("orders shared bottom live blocks by insertion order", () => {
|
||||
const blocks = threadStreamBlocks({
|
||||
turnLifecycle: runningTurnLifecycle("turn"),
|
||||
items: [
|
||||
{ id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "Do it", turnId: "turn" },
|
||||
{
|
||||
id: "agent-1",
|
||||
kind: "agent",
|
||||
role: "tool",
|
||||
text: "Wait for agent",
|
||||
turnId: "turn",
|
||||
tool: "wait",
|
||||
status: "running",
|
||||
senderThreadId: "parent",
|
||||
receiverThreadIds: ["running"],
|
||||
prompt: null,
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
agents: [{ threadId: "running", status: "running", executionState: "running", message: null }],
|
||||
},
|
||||
{
|
||||
id: "plan-progress-turn",
|
||||
kind: "taskProgress",
|
||||
role: "tool",
|
||||
text: "[>] Patch UI",
|
||||
turnId: "turn",
|
||||
explanation: null,
|
||||
steps: [{ step: "Patch UI", status: "inProgress" }],
|
||||
status: "inProgress",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(blocks.map((block) => block.key)).toEqual(["item:u1", "item:agent-1", "live-agents:turn", "live-task:plan-progress-turn"]);
|
||||
});
|
||||
|
||||
it("anchors the live agent summary at the first agent activity", () => {
|
||||
const blocks = threadStreamBlocks({
|
||||
turnLifecycle: runningTurnLifecycle("turn"),
|
||||
items: [
|
||||
{ id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "Do it", turnId: "turn" },
|
||||
{
|
||||
id: "agent-spawn",
|
||||
kind: "agent",
|
||||
role: "tool",
|
||||
text: "Spawn agent",
|
||||
turnId: "turn",
|
||||
tool: "spawnAgent",
|
||||
status: "completed",
|
||||
senderThreadId: "parent",
|
||||
receiverThreadIds: ["child"],
|
||||
prompt: "Inspect the renderer.",
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
agents: [{ threadId: "child", status: "completed", executionState: "completed", message: null }],
|
||||
},
|
||||
{
|
||||
id: "plan-progress-turn",
|
||||
kind: "taskProgress",
|
||||
role: "tool",
|
||||
text: "[>] Patch UI",
|
||||
turnId: "turn",
|
||||
explanation: null,
|
||||
steps: [{ step: "Patch UI", status: "inProgress" }],
|
||||
status: "inProgress",
|
||||
},
|
||||
{
|
||||
id: "agent-wait",
|
||||
kind: "agent",
|
||||
role: "tool",
|
||||
text: "Wait for agent",
|
||||
turnId: "turn",
|
||||
tool: "wait",
|
||||
status: "running",
|
||||
senderThreadId: "parent",
|
||||
receiverThreadIds: ["child"],
|
||||
prompt: null,
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
agents: [{ threadId: "child", status: "running", executionState: "running", message: "Inspecting renderer" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(blocks.map((block) => block.key)).toEqual([
|
||||
"item:u1",
|
||||
"item:agent-spawn",
|
||||
"item:agent-wait",
|
||||
"live-agents:turn",
|
||||
"live-task:plan-progress-turn",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders agent activity as a one-line summary with consolidated details", () => {
|
||||
const block = threadStreamBlocks({
|
||||
turnLifecycle: runningTurnLifecycle("turn"),
|
||||
|
|
@ -529,8 +363,6 @@ describe("thread stream item renderer decisions", () => {
|
|||
const header = expectPresent(element.querySelector<HTMLElement>("details summary"));
|
||||
const open = expectPresent(header.querySelector<HTMLButtonElement>('[aria-label="Open agent thread"]'));
|
||||
|
||||
expect(open.classList.contains("codex-panel__hover-action")).toBe(true);
|
||||
expect(open.classList.contains("codex-panel-ui__nav-row-action")).toBe(false);
|
||||
expect(textContents(element, "details summary")).toEqual(["agent"]);
|
||||
open.click();
|
||||
|
||||
|
|
@ -704,8 +536,6 @@ describe("thread stream item renderer decisions", () => {
|
|||
expect(summary.textContent).not.toContain("donecompleted");
|
||||
expect(summary.querySelector<HTMLButtonElement>('[aria-label="Open agent thread"]')).toBeNull();
|
||||
const open = expectPresent(summary.querySelector<HTMLElement>(".codex-panel__agent-row--interactive"));
|
||||
expect(open.classList.contains("codex-panel-ui__nav-item")).toBe(true);
|
||||
expect(open.getAttribute("aria-label")).toBeNull();
|
||||
expect(open.textContent).toBe("runningrunning: Inspecting renderer");
|
||||
open.click();
|
||||
expect(openThreadInNewView).toHaveBeenCalledWith("running");
|
||||
|
|
|
|||
|
|
@ -36,30 +36,23 @@ describe("Toolbar decisions", () => {
|
|||
"Show chat actions",
|
||||
"Show status",
|
||||
]);
|
||||
expect([...expectPresent(navButtons).children].map((button) => button.tagName)).toEqual(["DIV", "DIV", "DIV"]);
|
||||
expect([...expectPresent(navButtons).children].map((button) => button.getAttribute("role"))).toEqual([null, null, null]);
|
||||
expect(parent.querySelector(".codex-panel__plan-toggle")).toBeNull();
|
||||
expect(parent.querySelector(".codex-panel__auto-review-toggle")).toBeNull();
|
||||
expect(parent.querySelector(".codex-panel__runtime-model")).toBeNull();
|
||||
const newChatButton = parent.querySelector<HTMLElement>(".codex-panel__new-chat");
|
||||
expect(newChatButton?.getAttribute("aria-label")).toBe("Show chat actions");
|
||||
expect(newChatButton?.getAttribute("aria-expanded")).toBeNull();
|
||||
expect(newChatButton?.classList.contains("is-disabled")).toBe(false);
|
||||
newChatButton?.click();
|
||||
expect(toggleChatActions).toHaveBeenCalled();
|
||||
expect(startNewThread).not.toHaveBeenCalled();
|
||||
const statusButton = parent.querySelector(".codex-panel__status-menu-toggle");
|
||||
expect(statusButton?.getAttribute("aria-label")).toBe("Show status");
|
||||
expect(statusButton?.getAttribute("aria-expanded")).toBeNull();
|
||||
const historyButton = parent.querySelector<HTMLElement>(".codex-panel__history-toggle");
|
||||
expect(historyButton?.getAttribute("aria-label")).toBe("Show thread list");
|
||||
expect(historyButton?.getAttribute("aria-pressed")).toBeNull();
|
||||
historyButton?.click();
|
||||
expect(toggleHistory).toHaveBeenCalled();
|
||||
parent.empty();
|
||||
mountToolbar(parent, toolbarModel({ newChatDisabled: true }), toolbarActions());
|
||||
expect(parent.querySelector<HTMLElement>(".codex-panel__new-chat")?.tagName).toBe("DIV");
|
||||
expect(parent.querySelector<HTMLElement>(".codex-panel__new-chat")?.classList.contains("is-disabled")).toBe(false);
|
||||
expect(parent.querySelector<HTMLElement>(".codex-panel__new-chat")?.getAttribute("aria-label")).toBe("Show chat actions");
|
||||
|
||||
parent.empty();
|
||||
mountToolbar(parent, toolbarModel({ chatActionsOpen: true, historyOpen: true, statusPanelOpen: true }), toolbarActions());
|
||||
|
|
@ -85,11 +78,7 @@ describe("Toolbar decisions", () => {
|
|||
);
|
||||
|
||||
const items = [...parent.querySelectorAll<HTMLElement>(".codex-panel__chat-actions-panel-item")];
|
||||
expect(parent.querySelector(".codex-panel__chat-actions-panel-items")?.tagName).toBe("DIV");
|
||||
expect(parent.querySelector(".codex-panel__chat-actions-panel-items")?.getAttribute("aria-label")).toBeNull();
|
||||
expect(items).toHaveLength(4);
|
||||
expect(items.map((item) => item.tagName)).toEqual(["DIV", "DIV", "DIV", "DIV"]);
|
||||
expect(items.map((item) => item.getAttribute("role"))).toEqual([null, null, null, null]);
|
||||
items[0]?.click();
|
||||
items[1]?.click();
|
||||
items[2]?.click();
|
||||
|
|
@ -98,6 +87,19 @@ describe("Toolbar decisions", () => {
|
|||
expect(startSideChat).toHaveBeenCalledOnce();
|
||||
expect(compactContext).toHaveBeenCalledOnce();
|
||||
expect(setGoal).toHaveBeenCalledOnce();
|
||||
|
||||
mountToolbar(
|
||||
parent,
|
||||
toolbarModel({ chatActionsOpen: true, openPanel: "chat-actions", newChatDisabled: true }),
|
||||
toolbarActions({ startNewThread, startSideChat, compactContext, setGoal }),
|
||||
);
|
||||
const disabledStart = expectPresent(
|
||||
[...parent.querySelectorAll<HTMLButtonElement>(".codex-panel__chat-actions-panel-item")].find(
|
||||
(item) => item.textContent === "Start new chat",
|
||||
),
|
||||
);
|
||||
disabledStart.click();
|
||||
expect(startNewThread).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps context out of the toolbar and Codex limits inside the status menu", () => {
|
||||
|
|
@ -145,12 +147,8 @@ describe("Toolbar decisions", () => {
|
|||
expect(parent.querySelector(".codex-panel__limit-panel")?.textContent).toContain("21%");
|
||||
expect(parent.querySelector(".codex-panel__limit-panel")?.textContent).toContain("reset in 3d 4h");
|
||||
const meters = [...parent.querySelectorAll<HTMLElement>(".codex-panel__limit-panel-meter")];
|
||||
expect(meters.map((meter) => meter.classList.contains("codex-panel__limit-panel-meter--divided"))).toEqual([true, true]);
|
||||
expect(meters[0]?.classList.contains("codex-panel__limit-panel-meter--5")).toBe(true);
|
||||
expect(meters[1]?.classList.contains("codex-panel__limit-panel-meter--7")).toBe(true);
|
||||
expect(meters.map((meter) => meter.getAttribute("role"))).toEqual(["progressbar", "progressbar"]);
|
||||
expect(meters.map((meter) => meter.getAttribute("aria-valuenow"))).toEqual(["42", "21"]);
|
||||
expect(meters.map((meter) => meter.getAttribute("aria-label"))).toEqual([null, null]);
|
||||
});
|
||||
|
||||
it("renders connection diagnostics in the status menu", () => {
|
||||
|
|
@ -184,11 +182,6 @@ describe("Toolbar decisions", () => {
|
|||
expect(parent.textContent).toContain("codex-cli/1.2.3");
|
||||
expect(parent.querySelector(".codex-panel__status-diagnostics-row--error")?.textContent).toContain("model/list failed");
|
||||
const statusItems = [...parent.querySelectorAll<HTMLElement>(".codex-panel__status-panel-item")];
|
||||
expect(parent.querySelector(".codex-panel__status-panel-items")?.tagName).toBe("DIV");
|
||||
expect(parent.querySelector(".codex-panel__status-panel-items")?.getAttribute("aria-label")).toBeNull();
|
||||
expect(statusItems.map((item) => item.tagName)).toEqual(["DIV", "DIV", "DIV"]);
|
||||
expect(statusItems.map((item) => item.getAttribute("role"))).toEqual([null, null, null]);
|
||||
expect(statusItems.every((item) => item.getAttribute("aria-selected") === null)).toBe(true);
|
||||
statusItems.find((item) => item.textContent.includes("Refresh"))?.click();
|
||||
expect(refreshStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -262,6 +255,7 @@ describe("Toolbar decisions", () => {
|
|||
|
||||
it("renders thread list rename actions and an inline rename editor", () => {
|
||||
const parent = document.createElement("div");
|
||||
document.body.append(parent);
|
||||
const startRenameThread = vi.fn();
|
||||
const updateRenameDraft = vi.fn();
|
||||
const saveRenameThread = vi.fn();
|
||||
|
|
@ -294,11 +288,12 @@ describe("Toolbar decisions", () => {
|
|||
expect(startRenameThread).toHaveBeenCalledWith("thread");
|
||||
const threadRow = parent.querySelector(".codex-panel__thread-row");
|
||||
expect(threadRow?.classList.contains("codex-panel__thread-row--selected")).toBe(true);
|
||||
expect(threadRow?.classList.contains("is-selected")).toBe(true);
|
||||
|
||||
const input = parent.querySelector<HTMLInputElement>(".codex-panel__thread-rename-input");
|
||||
if (!input) throw new Error("Missing thread rename input");
|
||||
expect(input.value).toBe("Draft title");
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect([input.selectionStart, input.selectionEnd]).toEqual([0, input.value.length]);
|
||||
changeInputValue(input, "New title");
|
||||
expect(updateRenameDraft).toHaveBeenCalledWith("editing", "New title");
|
||||
|
||||
|
|
@ -321,14 +316,19 @@ describe("Toolbar decisions", () => {
|
|||
}),
|
||||
actions,
|
||||
);
|
||||
expectPresent(parent.querySelector<HTMLInputElement>(".codex-panel__thread-rename-input")).dispatchEvent(new FocusEvent("blur"));
|
||||
const renamedInput = expectPresent(parent.querySelector<HTMLInputElement>(".codex-panel__thread-rename-input"));
|
||||
renamedInput.dispatchEvent(new FocusEvent("blur"));
|
||||
expect(saveRenameThread).toHaveBeenCalledWith("editing", "New title");
|
||||
expectPresent(parent.querySelector<HTMLInputElement>(".codex-panel__thread-rename-input")).dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
||||
);
|
||||
saveRenameThread.mockClear();
|
||||
renamedInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, isComposing: true }));
|
||||
expect(saveRenameThread).not.toHaveBeenCalled();
|
||||
renamedInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
expect(saveRenameThread).toHaveBeenCalledWith("editing", "New title");
|
||||
renamedInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
expect(cancelRenameThread).toHaveBeenCalledWith("editing");
|
||||
parent.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.click();
|
||||
expect(autoNameThread).toHaveBeenCalledWith("editing");
|
||||
parent.remove();
|
||||
});
|
||||
|
||||
it("renders auto-name loading without disabling the rename draft field", () => {
|
||||
|
|
@ -392,7 +392,6 @@ describe("Toolbar decisions", () => {
|
|||
"Archive thread without saving",
|
||||
"Save and archive thread",
|
||||
]);
|
||||
expect(archiveButtons.every((button) => !button.classList.contains("nav-action-button"))).toBe(true);
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')).toBeNull();
|
||||
archiveButtons[0]?.click();
|
||||
expect(archiveThread).toHaveBeenCalledWith("thread", false);
|
||||
|
|
|
|||
|
|
@ -114,25 +114,16 @@ describe("threads view renderer decisions", () => {
|
|||
renderThreadsViewShell(parent, { status: "2 threads", loading: false, rows }, actions);
|
||||
|
||||
const main = expectPresent(parent.querySelector<HTMLElement>(".codex-panel-threads__row--pending"));
|
||||
const row = expectPresent(main.closest<HTMLElement>(".codex-panel-threads__row"));
|
||||
expect(row.classList.contains("codex-panel-ui__nav-row")).toBe(true);
|
||||
expect(row.classList.contains("is-selected")).toBe(true);
|
||||
expect(row.classList.contains("codex-panel-threads__row--selected")).toBe(true);
|
||||
expect(main.classList.contains("codex-panel-ui__nav-item")).toBe(true);
|
||||
expect(main.textContent).toContain("Open thread");
|
||||
const toolbarButtons = [...parent.querySelectorAll<HTMLElement>(".codex-panel-threads__toolbar-button")];
|
||||
expect(toolbarButtons.map((button) => button.getAttribute("aria-label"))).toEqual(["Open new panel", "Refresh threads"]);
|
||||
const refresh = expectPresent(parent.querySelector<HTMLElement>('[aria-label="Refresh threads"]'));
|
||||
expect(refresh.classList.contains("nav-action-button")).toBe(true);
|
||||
expect(refresh.classList.contains("codex-panel-ui__toolbar-action")).toBe(true);
|
||||
expect(refresh.classList.contains("codex-panel-ui__nav-row-action")).toBe(false);
|
||||
refresh.click();
|
||||
expect(actions.refresh).toHaveBeenCalledOnce();
|
||||
const openNewPanel = expectPresent(parent.querySelector<HTMLElement>('[aria-label="Open new panel"]'));
|
||||
openNewPanel.click();
|
||||
expect(actions.openNewPanel).toHaveBeenCalledOnce();
|
||||
const rename = expectPresent(parent.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]'));
|
||||
expect(rename.classList.contains("nav-action-button")).toBe(false);
|
||||
expect(rename.classList.contains("codex-panel-ui__nav-row-action")).toBe(true);
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')).not.toBeNull();
|
||||
main.click();
|
||||
expect(actions.openThread).toHaveBeenCalledWith("open");
|
||||
});
|
||||
|
|
@ -175,6 +166,7 @@ describe("threads view renderer decisions", () => {
|
|||
|
||||
it("renders rename rows and saves entered values", () => {
|
||||
const parent = document.createElement("div");
|
||||
document.body.append(parent);
|
||||
const actions = threadsViewActions();
|
||||
const row = rowFixture({
|
||||
title: "Old name",
|
||||
|
|
@ -184,6 +176,8 @@ describe("threads view renderer decisions", () => {
|
|||
renderThreadsViewShell(parent, { status: "1 thread", loading: false, rows: [row] }, actions);
|
||||
|
||||
const input = expectPresent(parent.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input"));
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect([input.selectionStart, input.selectionEnd]).toEqual([0, input.value.length]);
|
||||
changeInputValue(input, "New name");
|
||||
expect(actions.updateRename).toHaveBeenCalledWith("thread", "New name");
|
||||
|
||||
|
|
@ -192,11 +186,20 @@ describe("threads view renderer decisions", () => {
|
|||
{ status: "1 thread", loading: false, rows: [{ ...row, rename: { active: true, draft: "New name", generating: false } }] },
|
||||
actions,
|
||||
);
|
||||
expectPresent(parent.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")).dispatchEvent(new FocusEvent("blur"));
|
||||
const renamedInput = expectPresent(parent.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input"));
|
||||
renamedInput.dispatchEvent(new FocusEvent("blur"));
|
||||
expect(actions.saveRename).toHaveBeenCalledWith("thread", "New name");
|
||||
actions.saveRename.mockClear();
|
||||
renamedInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, isComposing: true }));
|
||||
expect(actions.saveRename).not.toHaveBeenCalled();
|
||||
renamedInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
expect(actions.saveRename).toHaveBeenCalledWith("thread", "New name");
|
||||
renamedInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
expect(actions.cancelRename).toHaveBeenCalledWith("thread");
|
||||
|
||||
expectPresent(parent.querySelector<HTMLElement>(".codex-panel-threads__row-main")).click();
|
||||
expect(actions.openThread).not.toHaveBeenCalled();
|
||||
parent.remove();
|
||||
});
|
||||
|
||||
it("renders threads view rename actions inline with auto-name", () => {
|
||||
|
|
|
|||
328
tests/settings/dynamic-sections-controller.test.ts
Normal file
328
tests/settings/dynamic-sections-controller.test.ts
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CatalogHookMetadata } from "../../src/app-server/protocol/catalog";
|
||||
import { modelMetadataFromCatalogModels } from "../../src/app-server/protocol/catalog";
|
||||
import type { ThreadRecord } from "../../src/app-server/protocol/thread";
|
||||
import type { ObservedResult } from "../../src/app-server/query/observed-result";
|
||||
import type { ModelMetadata } from "../../src/domain/catalog/metadata";
|
||||
import type { Thread } from "../../src/domain/threads/model";
|
||||
import { SettingsDynamicSectionsController, type SettingsDynamicSectionsSnapshot } from "../../src/settings/dynamic-sections-controller";
|
||||
import { deferred } from "../support/async";
|
||||
import {
|
||||
appServerThread,
|
||||
flushPromises,
|
||||
hook,
|
||||
model,
|
||||
panelThread,
|
||||
setSettingsShortLivedClientMock,
|
||||
settingsClient,
|
||||
settingsRequestClient,
|
||||
settingsTabHost,
|
||||
useShortLivedClients,
|
||||
} from "./test-support";
|
||||
|
||||
const { withShortLivedAppServerClientMock } = vi.hoisted(() => ({
|
||||
withShortLivedAppServerClientMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/app-server/connection/short-lived-client", () => ({
|
||||
withShortLivedAppServerClient: withShortLivedAppServerClientMock,
|
||||
}));
|
||||
|
||||
setSettingsShortLivedClientMock(withShortLivedAppServerClientMock);
|
||||
|
||||
describe("SettingsDynamicSectionsController", () => {
|
||||
beforeEach(() => {
|
||||
withShortLivedAppServerClientMock.mockReset();
|
||||
});
|
||||
|
||||
it("publishes archived thread catalog updates", () => {
|
||||
let emitArchived = (_threads: readonly Thread[]): void => {
|
||||
throw new Error("Expected archived thread observer");
|
||||
};
|
||||
const display = vi.fn();
|
||||
const controller = new SettingsDynamicSectionsController(
|
||||
settingsTabHost({
|
||||
observeArchived: (listener) => {
|
||||
emitArchived = (threads) => {
|
||||
listener({ value: threads, error: null, isFetching: false } satisfies ObservedResult<readonly Thread[]>);
|
||||
};
|
||||
return () => undefined;
|
||||
},
|
||||
}),
|
||||
{ display, notify: vi.fn() },
|
||||
);
|
||||
|
||||
controller.activate();
|
||||
emitArchived([panelThread({ id: "thread-archived", preview: "Archived elsewhere", archived: true })]);
|
||||
|
||||
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["Archived elsewhere"]);
|
||||
expect(controller.snapshot().archivedThreadsLifecycle.kind).toBe("loaded");
|
||||
expect(display).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("ignores stale dynamic sections refresh results after a newer refresh completes", async () => {
|
||||
const firstModels = deferred<ModelMetadata[]>();
|
||||
const firstClient = settingsClient();
|
||||
const secondClient = settingsClient({ models: [model("gpt-new")] });
|
||||
useShortLivedClients(firstClient, secondClient);
|
||||
const refreshModels = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstModels.promise)
|
||||
.mockResolvedValueOnce(modelMetadataFromCatalogModels([model("gpt-new")]));
|
||||
const refreshArchived = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([panelThread({ id: "thread-old", preview: "Old", archived: true })])
|
||||
.mockResolvedValueOnce([panelThread({ id: "thread-new", preview: "New", archived: true })]);
|
||||
const controller = new SettingsDynamicSectionsController(settingsTabHost({ refreshModels, refreshArchived }), {
|
||||
display: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
});
|
||||
|
||||
const firstRefresh = controller.refreshDynamicSections();
|
||||
await flushPromises();
|
||||
await controller.refreshDynamicSections();
|
||||
|
||||
expect(controller.snapshot().models.map((item) => item.model)).toEqual(["gpt-new"]);
|
||||
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New"]);
|
||||
|
||||
firstModels.resolve(modelMetadataFromCatalogModels([model("gpt-old")]));
|
||||
await firstRefresh;
|
||||
|
||||
expect(controller.snapshot().models.map((item) => item.model)).toEqual(["gpt-new"]);
|
||||
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New"]);
|
||||
});
|
||||
|
||||
it("does not display dynamic refresh results after disposal", async () => {
|
||||
const models = deferred<ModelMetadata[]>();
|
||||
const display = vi.fn();
|
||||
const controller = new SettingsDynamicSectionsController(
|
||||
settingsTabHost({
|
||||
refreshModels: vi.fn(() => models.promise),
|
||||
refreshArchived: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
{ display, notify: vi.fn() },
|
||||
);
|
||||
|
||||
const refresh = controller.refreshDynamicSections();
|
||||
await flushPromises();
|
||||
display.mockClear();
|
||||
controller.dispose();
|
||||
models.resolve([]);
|
||||
await refresh;
|
||||
|
||||
expect(display).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores stale hook reload results after a newer dynamic operation completes", async () => {
|
||||
const staleHooks = deferred<{
|
||||
data: { cwd: string; hooks: CatalogHookMetadata[]; warnings: string[]; errors: unknown[] }[];
|
||||
}>();
|
||||
const initialClient = settingsClient({
|
||||
hooks: [hook({ key: "hook-initial", command: "initial hook", currentHash: "initialhash" })],
|
||||
});
|
||||
const trustClient = settingsRequestClient({
|
||||
"config/batchWrite": vi.fn().mockResolvedValue({}),
|
||||
});
|
||||
const staleClient = settingsClient();
|
||||
staleClient.requestHandlers["hooks/list"]?.mockReturnValue(staleHooks.promise);
|
||||
const newerClient = settingsClient({
|
||||
hooks: [hook({ key: "hook-new", command: "new hook", currentHash: "newhash" })],
|
||||
});
|
||||
useShortLivedClients(initialClient, trustClient, staleClient, newerClient);
|
||||
const controller = new SettingsDynamicSectionsController(settingsTabHost(), { display: vi.fn(), notify: vi.fn() });
|
||||
|
||||
await controller.refreshDynamicSections();
|
||||
const initialHook = controller.snapshot().hooks[0];
|
||||
if (!initialHook) throw new Error("Expected initial hook to load");
|
||||
const staleReload = controller.trustHook(initialHook);
|
||||
await flushPromises();
|
||||
await controller.refreshDynamicSections();
|
||||
|
||||
expect(controller.snapshot().hooks.map((hook) => hook.currentHash)).toEqual(["newhash"]);
|
||||
|
||||
staleHooks.resolve({
|
||||
data: [{ cwd: "/vault", hooks: [hook({ key: "hook-old", command: "old hook", currentHash: "oldhash" })], warnings: [], errors: [] }],
|
||||
});
|
||||
await staleReload;
|
||||
|
||||
expect(controller.snapshot().hooks.map((item) => item.currentHash)).toEqual(["newhash"]);
|
||||
});
|
||||
|
||||
it("keeps full refresh models and archived threads current when hook operations overlap", async () => {
|
||||
const models = deferred<readonly ModelMetadata[]>();
|
||||
const fullRefreshClient = settingsClient({
|
||||
hooks: [hook({ key: "hook-full", command: "full refresh hook", currentHash: "fullhash" })],
|
||||
});
|
||||
const trustClient = settingsRequestClient({
|
||||
"config/batchWrite": vi.fn().mockResolvedValue({}),
|
||||
});
|
||||
const hookReloadClient = settingsClient({
|
||||
hooks: [hook({ key: "hook-new", command: "new hook", currentHash: "newhash" })],
|
||||
});
|
||||
useShortLivedClients(fullRefreshClient, trustClient, hookReloadClient);
|
||||
const refreshModels = vi.fn().mockReturnValue(models.promise);
|
||||
const refreshArchived = vi.fn().mockResolvedValue([panelThread({ id: "thread-full", preview: "Full archived", archived: true })]);
|
||||
const controller = new SettingsDynamicSectionsController(settingsTabHost({ refreshModels, refreshArchived }), {
|
||||
display: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
});
|
||||
|
||||
const fullRefresh = controller.refreshDynamicSections();
|
||||
await flushPromises();
|
||||
await controller.trustHook(hook({ key: "hook-initial", command: "initial hook", currentHash: "initialhash" }));
|
||||
|
||||
models.resolve(modelMetadataFromCatalogModels([model("gpt-refreshed")]));
|
||||
await fullRefresh;
|
||||
|
||||
const snapshot = controller.snapshot();
|
||||
expect(snapshot.models.map((item) => item.model)).toEqual(["gpt-refreshed"]);
|
||||
expect(snapshot.modelsLifecycle.kind).toBe("loaded");
|
||||
expect(snapshot.archivedThreads.map((thread) => thread.preview)).toEqual(["Full archived"]);
|
||||
expect(snapshot.archivedThreadsLifecycle.kind).toBe("loaded");
|
||||
expect(snapshot.hooks.map((item) => item.currentHash)).toEqual(["newhash"]);
|
||||
});
|
||||
|
||||
it("ignores stale archived restore results after a newer dynamic operation completes", async () => {
|
||||
const staleRestore = deferred<{ thread: ThreadRecord }>();
|
||||
const applyThreadCatalogEvent = vi.fn();
|
||||
const initialClient = settingsClient();
|
||||
const restoreClient = settingsRequestClient({
|
||||
"thread/unarchive": vi.fn(() => staleRestore.promise),
|
||||
});
|
||||
const newerClient = settingsClient();
|
||||
useShortLivedClients(initialClient, restoreClient, newerClient);
|
||||
const refreshArchived = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([panelThread({ id: "thread-old", preview: "Old archived", archived: true })])
|
||||
.mockResolvedValueOnce([panelThread({ id: "thread-new", preview: "New archived", archived: true })]);
|
||||
const controller = new SettingsDynamicSectionsController(settingsTabHost({ applyThreadCatalogEvent, refreshArchived }), {
|
||||
display: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
});
|
||||
|
||||
await controller.refreshDynamicSections();
|
||||
const restore = controller.restoreArchivedThread("thread-old");
|
||||
await flushPromises();
|
||||
await controller.refreshDynamicSections();
|
||||
|
||||
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New archived"]);
|
||||
|
||||
staleRestore.resolve({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) });
|
||||
await restore;
|
||||
|
||||
expect(applyThreadCatalogEvent).not.toHaveBeenCalled();
|
||||
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New archived"]);
|
||||
});
|
||||
|
||||
it("records restored archived threads in the active catalog", async () => {
|
||||
const notify = vi.fn();
|
||||
const applyThreadCatalogEvent = vi.fn();
|
||||
const initialClient = settingsClient();
|
||||
const restoreClient = settingsRequestClient({
|
||||
"thread/unarchive": vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }),
|
||||
});
|
||||
useShortLivedClients(initialClient, restoreClient);
|
||||
const controller = new SettingsDynamicSectionsController(
|
||||
settingsTabHost({
|
||||
archivedThreads: [panelThread({ id: "thread-old", preview: "Old archived", archived: true })],
|
||||
applyThreadCatalogEvent,
|
||||
}),
|
||||
{ display: vi.fn(), notify },
|
||||
);
|
||||
|
||||
await controller.refreshDynamicSections();
|
||||
await controller.restoreArchivedThread("thread-old");
|
||||
|
||||
const snapshot = controller.snapshot();
|
||||
expect(snapshot.archivedThreads).toEqual([]);
|
||||
expect(snapshot.archivedThreadsLifecycle.kind).toBe("loaded");
|
||||
expect(applyThreadCatalogEvent).toHaveBeenCalledWith({
|
||||
type: "thread-restored",
|
||||
thread: expect.objectContaining({ id: "thread-old", preview: "Restored old", archived: false }),
|
||||
});
|
||||
expect(notify).not.toHaveBeenCalledWith("Could not restore archived Codex thread.");
|
||||
});
|
||||
|
||||
it("displays restored archived thread state after recording the active catalog event", async () => {
|
||||
const snapshots: SettingsDynamicSectionsSnapshot[] = [];
|
||||
const initialClient = settingsClient();
|
||||
const restoreClient = settingsRequestClient({
|
||||
"thread/unarchive": vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }),
|
||||
});
|
||||
useShortLivedClients(initialClient, restoreClient);
|
||||
const controllerRef: { current: SettingsDynamicSectionsController | null } = { current: null };
|
||||
let emitArchived = (_threads: readonly Thread[]): void => undefined;
|
||||
const controller = new SettingsDynamicSectionsController(
|
||||
settingsTabHost({
|
||||
archivedThreads: [panelThread({ id: "thread-old", preview: "Old archived", archived: true })],
|
||||
observeArchived: (listener) => {
|
||||
emitArchived = (threads) => {
|
||||
listener({ value: threads, error: null, isFetching: false } satisfies ObservedResult<readonly Thread[]>);
|
||||
};
|
||||
return () => undefined;
|
||||
},
|
||||
applyThreadCatalogEvent: () => {
|
||||
emitArchived([]);
|
||||
},
|
||||
}),
|
||||
{
|
||||
display: () => {
|
||||
const snapshot = controllerRef.current?.snapshot();
|
||||
if (snapshot) snapshots.push(snapshot);
|
||||
},
|
||||
notify: vi.fn(),
|
||||
},
|
||||
);
|
||||
controllerRef.current = controller;
|
||||
|
||||
await controller.refreshDynamicSections();
|
||||
snapshots.length = 0;
|
||||
await controller.restoreArchivedThread("thread-old");
|
||||
|
||||
expect(snapshots.at(-1)?.archivedThreads).toEqual([]);
|
||||
expect(snapshots.at(-1)?.archivedThreadsLifecycle.kind).toBe("loaded");
|
||||
expect(snapshots.at(-1)?.archivedThreadsLifecycle.status).toBe('Restored "Restored old".');
|
||||
});
|
||||
|
||||
it("displays deleted archived thread status after recording the catalog event", async () => {
|
||||
const snapshots: SettingsDynamicSectionsSnapshot[] = [];
|
||||
const initialClient = settingsClient();
|
||||
const deleteClient = settingsRequestClient({
|
||||
"thread/delete": vi.fn().mockResolvedValue({}),
|
||||
});
|
||||
useShortLivedClients(initialClient, deleteClient);
|
||||
const controllerRef: { current: SettingsDynamicSectionsController | null } = { current: null };
|
||||
let emitArchived = (_threads: readonly Thread[]): void => undefined;
|
||||
const controller = new SettingsDynamicSectionsController(
|
||||
settingsTabHost({
|
||||
archivedThreads: [panelThread({ id: "thread-old", preview: "Old archived", archived: true })],
|
||||
observeArchived: (listener) => {
|
||||
emitArchived = (threads) => {
|
||||
listener({ value: threads, error: null, isFetching: false } satisfies ObservedResult<readonly Thread[]>);
|
||||
};
|
||||
return () => undefined;
|
||||
},
|
||||
applyThreadCatalogEvent: () => {
|
||||
emitArchived([]);
|
||||
},
|
||||
}),
|
||||
{
|
||||
display: () => {
|
||||
const snapshot = controllerRef.current?.snapshot();
|
||||
if (snapshot) snapshots.push(snapshot);
|
||||
},
|
||||
notify: vi.fn(),
|
||||
},
|
||||
);
|
||||
controllerRef.current = controller;
|
||||
|
||||
await controller.refreshDynamicSections();
|
||||
snapshots.length = 0;
|
||||
await controller.deleteArchivedThread("thread-old");
|
||||
|
||||
expect(snapshots.at(-1)?.archivedThreads).toEqual([]);
|
||||
expect(snapshots.at(-1)?.archivedThreadsLifecycle.kind).toBe("loaded");
|
||||
expect(snapshots.at(-1)?.archivedThreadsLifecycle.status).toBe('Deleted "Old archived".');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,24 +1,26 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AppServerClient } from "../../src/app-server/connection/client";
|
||||
import type { AppServerClientAccessOptions } from "../../src/app-server/connection/client-access";
|
||||
import type { CatalogHookMetadata, CatalogModel } from "../../src/app-server/protocol/catalog";
|
||||
import { modelMetadataFromCatalogModels } from "../../src/app-server/protocol/catalog";
|
||||
import type { ThreadRecord } from "../../src/app-server/protocol/thread";
|
||||
import type { ObservedResult } from "../../src/app-server/query/observed-result";
|
||||
import type { ModelMetadata, ReasoningEffort } from "../../src/domain/catalog/metadata";
|
||||
import type { Thread } from "../../src/domain/threads/model";
|
||||
import type { ThreadCatalogEvent } from "../../src/features/threads/catalog/thread-catalog";
|
||||
import { createSettingsAppServerDynamicData } from "../../src/settings/app-server-dynamic-data";
|
||||
import type { SettingsDynamicDataAccess } from "../../src/settings/dynamic-data";
|
||||
import { SettingsDynamicSectionsController, type SettingsDynamicSectionsSnapshot } from "../../src/settings/dynamic-sections-controller";
|
||||
import type { CodexPanelSettingTabHost } from "../../src/settings/host";
|
||||
import { type CodexPanelSettings, DEFAULT_SETTINGS } from "../../src/settings/model";
|
||||
import { DEFAULT_SETTINGS } from "../../src/settings/model";
|
||||
import { CodexPanelSettingTab } from "../../src/settings/tab.obsidian";
|
||||
import { notices } from "../mocks/obsidian";
|
||||
import { deferred } from "../support/async";
|
||||
import { installObsidianDomShims } from "../support/dom";
|
||||
import {
|
||||
expectRequestTimes,
|
||||
flushPromises,
|
||||
hook,
|
||||
model,
|
||||
panelThread,
|
||||
requestMethods,
|
||||
type SettingsTabHostOptions,
|
||||
setSettingsShortLivedClientMock,
|
||||
settingsClient,
|
||||
settingsTabHost,
|
||||
useShortLivedClients,
|
||||
} from "./test-support";
|
||||
|
||||
installObsidianDomShims();
|
||||
|
||||
|
|
@ -30,6 +32,8 @@ vi.mock("../../src/app-server/connection/short-lived-client", () => ({
|
|||
withShortLivedAppServerClient: withShortLivedAppServerClientMock,
|
||||
}));
|
||||
|
||||
setSettingsShortLivedClientMock(withShortLivedAppServerClientMock);
|
||||
|
||||
describe("settings tab", () => {
|
||||
beforeEach(() => {
|
||||
withShortLivedAppServerClientMock.mockReset();
|
||||
|
|
@ -85,12 +89,8 @@ describe("settings tab", () => {
|
|||
const tab = newSettingsTab({ saveSettings });
|
||||
|
||||
tab.display();
|
||||
const codexPath = inputForSetting(tab, "Codex executable");
|
||||
expect(codexPath?.getAttribute("aria-label")).toBeNull();
|
||||
expect(codexPath?.type).toBe("text");
|
||||
const shortcut = selectForSetting(tab, "Send shortcut");
|
||||
if (!shortcut) throw new Error("Missing send shortcut dropdown");
|
||||
expect(shortcut.classList.contains("dropdown")).toBe(true);
|
||||
|
||||
shortcut.value = "mod-enter";
|
||||
shortcut.dispatchEvent(new Event("change"));
|
||||
|
|
@ -109,8 +109,6 @@ describe("settings tab", () => {
|
|||
tab.display();
|
||||
const toggle = inputForSetting(tab, "Show chat toolbar");
|
||||
if (!toggle) throw new Error("Missing toolbar visibility toggle");
|
||||
expect(toggle.parentElement?.classList.contains("checkbox-container")).toBe(true);
|
||||
|
||||
toggle.checked = false;
|
||||
toggle.dispatchEvent(new Event("change"));
|
||||
await flushPromises();
|
||||
|
|
@ -220,8 +218,6 @@ describe("settings tab", () => {
|
|||
tab.display();
|
||||
const toggle = inputForSetting(tab, "Scroll conversation from composer line edges");
|
||||
if (!toggle) throw new Error("Missing composer line edge scroll toggle");
|
||||
expect(toggle.parentElement?.classList.contains("checkbox-container")).toBe(true);
|
||||
|
||||
toggle.checked = true;
|
||||
toggle.dispatchEvent(new Event("change"));
|
||||
await flushPromises();
|
||||
|
|
@ -237,8 +233,6 @@ describe("settings tab", () => {
|
|||
tab.display();
|
||||
const toggle = inputForSetting(tab, "Reference active file on send");
|
||||
if (!toggle) throw new Error("Missing active file reference toggle");
|
||||
expect(toggle.parentElement?.classList.contains("checkbox-container")).toBe(true);
|
||||
|
||||
toggle.checked = true;
|
||||
toggle.dispatchEvent(new Event("change"));
|
||||
await flushPromises();
|
||||
|
|
@ -254,8 +248,6 @@ describe("settings tab", () => {
|
|||
tab.display();
|
||||
const folder = inputForSetting(tab, "Attachment folder");
|
||||
if (!folder) throw new Error("Missing attachment folder input");
|
||||
expect(folder.type).toBe("text");
|
||||
|
||||
folder.value = "Files/Codex";
|
||||
folder.dispatchEvent(new Event("blur"));
|
||||
await flushPromises();
|
||||
|
|
@ -271,32 +263,24 @@ describe("settings tab", () => {
|
|||
tab.display();
|
||||
const toggle = inputForSetting(tab, "Save note by default");
|
||||
if (!toggle) throw new Error("Missing archive export toggle");
|
||||
expect(toggle.parentElement?.classList.contains("checkbox-container")).toBe(true);
|
||||
|
||||
toggle.checked = true;
|
||||
toggle.dispatchEvent(new Event("change"));
|
||||
await flushPromises();
|
||||
|
||||
const folder = inputForSetting(tab, "Saved note folder");
|
||||
if (!folder) throw new Error("Missing archive export folder input");
|
||||
expect(folder.getAttribute("aria-label")).toBeNull();
|
||||
expect(folder.type).toBe("text");
|
||||
folder.value = "Saved Threads";
|
||||
folder.dispatchEvent(new Event("blur"));
|
||||
await flushPromises();
|
||||
|
||||
const filename = inputForSetting(tab, "Saved note filename");
|
||||
if (!filename) throw new Error("Missing archive export filename input");
|
||||
expect(filename.getAttribute("aria-label")).toBeNull();
|
||||
expect(filename.type).toBe("text");
|
||||
filename.value = "{{date}} {{title}}.md";
|
||||
filename.dispatchEvent(new Event("blur"));
|
||||
await flushPromises();
|
||||
|
||||
const tags = inputForSetting(tab, "Saved note tags");
|
||||
if (!tags) throw new Error("Missing archive export tags input");
|
||||
expect(tags.getAttribute("aria-label")).toBeNull();
|
||||
expect(tags.type).toBe("text");
|
||||
tags.value = "codex, archive";
|
||||
tags.dispatchEvent(new Event("blur"));
|
||||
await flushPromises();
|
||||
|
|
@ -469,296 +453,6 @@ describe("settings tab", () => {
|
|||
expect(unsubscribe).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("publishes archived thread catalog updates to the archived settings section", () => {
|
||||
let emitArchived = (_threads: readonly Thread[]): void => {
|
||||
throw new Error("Expected archived thread observer");
|
||||
};
|
||||
const display = vi.fn();
|
||||
const controller = new SettingsDynamicSectionsController(
|
||||
settingsTabHost({
|
||||
observeArchived: (listener) => {
|
||||
emitArchived = (threads) => {
|
||||
listener({ value: threads, error: null, isFetching: false } satisfies ObservedResult<readonly Thread[]>);
|
||||
};
|
||||
return () => undefined;
|
||||
},
|
||||
}),
|
||||
{ display, notify: vi.fn() },
|
||||
);
|
||||
|
||||
controller.activate();
|
||||
emitArchived([panelThread({ id: "thread-archived", preview: "Archived elsewhere", archived: true })]);
|
||||
|
||||
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["Archived elsewhere"]);
|
||||
expect(controller.snapshot().archivedThreadsLifecycle.kind).toBe("loaded");
|
||||
expect(display).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("ignores stale dynamic sections refresh results after a newer refresh completes", async () => {
|
||||
const firstModels = deferred<ModelMetadata[]>();
|
||||
const firstClient = settingsClient();
|
||||
const secondClient = settingsClient({ models: [model("gpt-new")] });
|
||||
useShortLivedClients(firstClient, secondClient);
|
||||
const refreshModels = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstModels.promise)
|
||||
.mockResolvedValueOnce(modelMetadataFromCatalogModels([model("gpt-new")]));
|
||||
const refreshArchived = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([panelThread({ id: "thread-old", preview: "Old", archived: true })])
|
||||
.mockResolvedValueOnce([panelThread({ id: "thread-new", preview: "New", archived: true })]);
|
||||
const controller = new SettingsDynamicSectionsController(settingsTabHost({ refreshModels, refreshArchived }), {
|
||||
display: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
});
|
||||
|
||||
const firstRefresh = controller.refreshDynamicSections();
|
||||
await flushPromises();
|
||||
await controller.refreshDynamicSections();
|
||||
|
||||
expect(controller.snapshot().models.map((item) => item.model)).toEqual(["gpt-new"]);
|
||||
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New"]);
|
||||
|
||||
firstModels.resolve(modelMetadataFromCatalogModels([model("gpt-old")]));
|
||||
await firstRefresh;
|
||||
|
||||
expect(controller.snapshot().models.map((item) => item.model)).toEqual(["gpt-new"]);
|
||||
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New"]);
|
||||
});
|
||||
|
||||
it("does not display dynamic refresh results after disposal", async () => {
|
||||
const models = deferred<ModelMetadata[]>();
|
||||
const display = vi.fn();
|
||||
const controller = new SettingsDynamicSectionsController(
|
||||
settingsTabHost({
|
||||
refreshModels: vi.fn(() => models.promise),
|
||||
refreshArchived: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
{ display, notify: vi.fn() },
|
||||
);
|
||||
|
||||
const refresh = controller.refreshDynamicSections();
|
||||
await flushPromises();
|
||||
display.mockClear();
|
||||
controller.dispose();
|
||||
models.resolve([]);
|
||||
await refresh;
|
||||
|
||||
expect(display).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores stale hook reload results after a newer dynamic operation completes", async () => {
|
||||
const staleHooks = deferred<{
|
||||
data: { cwd: string; hooks: CatalogHookMetadata[]; warnings: string[]; errors: unknown[] }[];
|
||||
}>();
|
||||
const initialClient = settingsClient({
|
||||
hooks: [hook({ key: "hook-initial", command: "initial hook", currentHash: "initialhash" })],
|
||||
});
|
||||
const trustClient = settingsRequestClient({
|
||||
"config/batchWrite": vi.fn().mockResolvedValue({}),
|
||||
});
|
||||
const staleClient = settingsClient();
|
||||
staleClient.requestHandlers["hooks/list"]?.mockReturnValue(staleHooks.promise);
|
||||
const newerClient = settingsClient({
|
||||
hooks: [hook({ key: "hook-new", command: "new hook", currentHash: "newhash" })],
|
||||
});
|
||||
useShortLivedClients(initialClient, trustClient, staleClient, newerClient);
|
||||
const controller = new SettingsDynamicSectionsController(settingsTabHost(), { display: vi.fn(), notify: vi.fn() });
|
||||
|
||||
await controller.refreshDynamicSections();
|
||||
const initialHook = controller.snapshot().hooks[0];
|
||||
if (!initialHook) throw new Error("Expected initial hook to load");
|
||||
const staleReload = controller.trustHook(initialHook);
|
||||
await flushPromises();
|
||||
await controller.refreshDynamicSections();
|
||||
|
||||
expect(controller.snapshot().hooks.map((hook) => hook.currentHash)).toEqual(["newhash"]);
|
||||
|
||||
staleHooks.resolve({
|
||||
data: [{ cwd: "/vault", hooks: [hook({ key: "hook-old", command: "old hook", currentHash: "oldhash" })], warnings: [], errors: [] }],
|
||||
});
|
||||
await staleReload;
|
||||
|
||||
expect(controller.snapshot().hooks.map((item) => item.currentHash)).toEqual(["newhash"]);
|
||||
});
|
||||
|
||||
it("keeps full refresh models and archived threads current when hook operations overlap", async () => {
|
||||
const models = deferred<readonly ModelMetadata[]>();
|
||||
const fullRefreshClient = settingsClient({
|
||||
hooks: [hook({ key: "hook-full", command: "full refresh hook", currentHash: "fullhash" })],
|
||||
});
|
||||
const trustClient = settingsRequestClient({
|
||||
"config/batchWrite": vi.fn().mockResolvedValue({}),
|
||||
});
|
||||
const hookReloadClient = settingsClient({
|
||||
hooks: [hook({ key: "hook-new", command: "new hook", currentHash: "newhash" })],
|
||||
});
|
||||
useShortLivedClients(fullRefreshClient, trustClient, hookReloadClient);
|
||||
const refreshModels = vi.fn().mockReturnValue(models.promise);
|
||||
const refreshArchived = vi.fn().mockResolvedValue([panelThread({ id: "thread-full", preview: "Full archived", archived: true })]);
|
||||
const controller = new SettingsDynamicSectionsController(settingsTabHost({ refreshModels, refreshArchived }), {
|
||||
display: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
});
|
||||
|
||||
const fullRefresh = controller.refreshDynamicSections();
|
||||
await flushPromises();
|
||||
await controller.trustHook(hook({ key: "hook-initial", command: "initial hook", currentHash: "initialhash" }));
|
||||
|
||||
models.resolve(modelMetadataFromCatalogModels([model("gpt-refreshed")]));
|
||||
await fullRefresh;
|
||||
|
||||
const snapshot = controller.snapshot();
|
||||
expect(snapshot.models.map((item) => item.model)).toEqual(["gpt-refreshed"]);
|
||||
expect(snapshot.modelsLifecycle.kind).toBe("loaded");
|
||||
expect(snapshot.archivedThreads.map((thread) => thread.preview)).toEqual(["Full archived"]);
|
||||
expect(snapshot.archivedThreadsLifecycle.kind).toBe("loaded");
|
||||
expect(snapshot.hooks.map((item) => item.currentHash)).toEqual(["newhash"]);
|
||||
});
|
||||
|
||||
it("ignores stale archived restore results after a newer dynamic operation completes", async () => {
|
||||
const staleRestore = deferred<{ thread: ThreadRecord }>();
|
||||
const applyThreadCatalogEvent = vi.fn();
|
||||
const initialClient = settingsClient();
|
||||
const restoreClient = settingsRequestClient({
|
||||
"thread/unarchive": vi.fn(() => staleRestore.promise),
|
||||
});
|
||||
const newerClient = settingsClient();
|
||||
useShortLivedClients(initialClient, restoreClient, newerClient);
|
||||
const refreshArchived = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([panelThread({ id: "thread-old", preview: "Old archived", archived: true })])
|
||||
.mockResolvedValueOnce([panelThread({ id: "thread-new", preview: "New archived", archived: true })]);
|
||||
const controller = new SettingsDynamicSectionsController(settingsTabHost({ applyThreadCatalogEvent, refreshArchived }), {
|
||||
display: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
});
|
||||
|
||||
await controller.refreshDynamicSections();
|
||||
const restore = controller.restoreArchivedThread("thread-old");
|
||||
await flushPromises();
|
||||
await controller.refreshDynamicSections();
|
||||
|
||||
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New archived"]);
|
||||
|
||||
staleRestore.resolve({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) });
|
||||
await restore;
|
||||
|
||||
expect(applyThreadCatalogEvent).not.toHaveBeenCalled();
|
||||
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New archived"]);
|
||||
});
|
||||
|
||||
it("records restored archived threads in the active catalog", async () => {
|
||||
const notify = vi.fn();
|
||||
const applyThreadCatalogEvent = vi.fn();
|
||||
const initialClient = settingsClient();
|
||||
const restoreClient = settingsRequestClient({
|
||||
"thread/unarchive": vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }),
|
||||
});
|
||||
useShortLivedClients(initialClient, restoreClient);
|
||||
const controller = new SettingsDynamicSectionsController(
|
||||
settingsTabHost({
|
||||
archivedThreads: [panelThread({ id: "thread-old", preview: "Old archived", archived: true })],
|
||||
applyThreadCatalogEvent,
|
||||
}),
|
||||
{ display: vi.fn(), notify },
|
||||
);
|
||||
|
||||
await controller.refreshDynamicSections();
|
||||
await controller.restoreArchivedThread("thread-old");
|
||||
|
||||
const snapshot = controller.snapshot();
|
||||
expect(snapshot.archivedThreads).toEqual([]);
|
||||
expect(snapshot.archivedThreadsLifecycle.kind).toBe("loaded");
|
||||
expect(applyThreadCatalogEvent).toHaveBeenCalledWith({
|
||||
type: "thread-restored",
|
||||
thread: expect.objectContaining({ id: "thread-old", preview: "Restored old", archived: false }),
|
||||
});
|
||||
expect(notify).not.toHaveBeenCalledWith("Could not restore archived Codex thread.");
|
||||
});
|
||||
|
||||
it("displays restored archived thread state after recording the active catalog event", async () => {
|
||||
const snapshots: SettingsDynamicSectionsSnapshot[] = [];
|
||||
const initialClient = settingsClient();
|
||||
const restoreClient = settingsRequestClient({
|
||||
"thread/unarchive": vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }),
|
||||
});
|
||||
useShortLivedClients(initialClient, restoreClient);
|
||||
const controllerRef: { current: SettingsDynamicSectionsController | null } = { current: null };
|
||||
let emitArchived = (_threads: readonly Thread[]): void => undefined;
|
||||
const controller = new SettingsDynamicSectionsController(
|
||||
settingsTabHost({
|
||||
archivedThreads: [panelThread({ id: "thread-old", preview: "Old archived", archived: true })],
|
||||
observeArchived: (listener) => {
|
||||
emitArchived = (threads) => {
|
||||
listener({ value: threads, error: null, isFetching: false } satisfies ObservedResult<readonly Thread[]>);
|
||||
};
|
||||
return () => undefined;
|
||||
},
|
||||
applyThreadCatalogEvent: () => {
|
||||
emitArchived([]);
|
||||
},
|
||||
}),
|
||||
{
|
||||
display: () => {
|
||||
const snapshot = controllerRef.current?.snapshot();
|
||||
if (snapshot) snapshots.push(snapshot);
|
||||
},
|
||||
notify: vi.fn(),
|
||||
},
|
||||
);
|
||||
controllerRef.current = controller;
|
||||
|
||||
await controller.refreshDynamicSections();
|
||||
snapshots.length = 0;
|
||||
await controller.restoreArchivedThread("thread-old");
|
||||
|
||||
expect(snapshots.at(-1)?.archivedThreads).toEqual([]);
|
||||
expect(snapshots.at(-1)?.archivedThreadsLifecycle.kind).toBe("loaded");
|
||||
expect(snapshots.at(-1)?.archivedThreadsLifecycle.status).toBe('Restored "Restored old".');
|
||||
});
|
||||
|
||||
it("displays deleted archived thread status after recording the catalog event", async () => {
|
||||
const snapshots: SettingsDynamicSectionsSnapshot[] = [];
|
||||
const initialClient = settingsClient();
|
||||
const deleteClient = settingsRequestClient({
|
||||
"thread/delete": vi.fn().mockResolvedValue({}),
|
||||
});
|
||||
useShortLivedClients(initialClient, deleteClient);
|
||||
const controllerRef: { current: SettingsDynamicSectionsController | null } = { current: null };
|
||||
let emitArchived = (_threads: readonly Thread[]): void => undefined;
|
||||
const controller = new SettingsDynamicSectionsController(
|
||||
settingsTabHost({
|
||||
archivedThreads: [panelThread({ id: "thread-old", preview: "Old archived", archived: true })],
|
||||
observeArchived: (listener) => {
|
||||
emitArchived = (threads) => {
|
||||
listener({ value: threads, error: null, isFetching: false } satisfies ObservedResult<readonly Thread[]>);
|
||||
};
|
||||
return () => undefined;
|
||||
},
|
||||
applyThreadCatalogEvent: () => {
|
||||
emitArchived([]);
|
||||
},
|
||||
}),
|
||||
{
|
||||
display: () => {
|
||||
const snapshot = controllerRef.current?.snapshot();
|
||||
if (snapshot) snapshots.push(snapshot);
|
||||
},
|
||||
notify: vi.fn(),
|
||||
},
|
||||
);
|
||||
controllerRef.current = controller;
|
||||
|
||||
await controller.refreshDynamicSections();
|
||||
snapshots.length = 0;
|
||||
await controller.deleteArchivedThread("thread-old");
|
||||
|
||||
expect(snapshots.at(-1)?.archivedThreads).toEqual([]);
|
||||
expect(snapshots.at(-1)?.archivedThreadsLifecycle.kind).toBe("loaded");
|
||||
expect(snapshots.at(-1)?.archivedThreadsLifecycle.status).toBe('Deleted "Old archived".');
|
||||
});
|
||||
|
||||
it("uses cached models initially and publishes refreshed models", async () => {
|
||||
const fetchModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-5.5")]));
|
||||
const client = settingsClient({ models: [model("gpt-5.5")] });
|
||||
|
|
@ -815,8 +509,6 @@ describe("settings tab", () => {
|
|||
|
||||
expect(selectOptions(tab, "Automatic thread naming", 1)).toEqual(["Codex default", "saved-custom-effort (saved)", "extreme"]);
|
||||
expect(selectOptions(tab, "Selection rewrite", 1)).toEqual(["Codex default", "extreme"]);
|
||||
expect(selectForSetting(tab, "Automatic thread naming")?.classList.contains("dropdown")).toBe(true);
|
||||
expect(selectForSetting(tab, "Selection rewrite")?.classList.contains("dropdown")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps successful sections when one dynamic sections request fails", async () => {
|
||||
|
|
@ -892,7 +584,7 @@ describe("settings tab", () => {
|
|||
expect(requestMethods(client)).not.toContain("thread/delete");
|
||||
});
|
||||
|
||||
it("keeps the settings shell mounted while dynamic sections update", async () => {
|
||||
it("preserves an in-progress Codex executable edit while dynamic sections update", async () => {
|
||||
const saveSettings = vi.fn().mockResolvedValue(undefined);
|
||||
const client = settingsClient();
|
||||
useShortLivedClients(client);
|
||||
|
|
@ -902,69 +594,35 @@ describe("settings tab", () => {
|
|||
});
|
||||
|
||||
tab.display();
|
||||
document.body.appendChild(tab.containerEl);
|
||||
await flushPromises();
|
||||
|
||||
const codexInput = inputForSetting(tab, "Codex executable");
|
||||
const shortcut = selectForSetting(tab, "Send shortcut");
|
||||
const archiveToggle = inputForSetting(tab, "Save note by default");
|
||||
if (!codexInput || !shortcut || !archiveToggle) throw new Error("Missing settings controls");
|
||||
if (!codexInput || !archiveToggle) throw new Error("Missing settings controls");
|
||||
|
||||
codexInput.focus();
|
||||
codexInput.value = "/opt/codex-draft";
|
||||
codexInput.setSelectionRange(5, 10);
|
||||
|
||||
archiveToggle.checked = true;
|
||||
archiveToggle.dispatchEvent(new Event("change"));
|
||||
await flushPromises();
|
||||
|
||||
expect(saveSettings).toHaveBeenCalledOnce();
|
||||
expect(inputForSetting(tab, "Codex executable")).toBe(codexInput);
|
||||
expect(selectForSetting(tab, "Send shortcut")).toBe(shortcut);
|
||||
expect(inputForSetting(tab, "Codex executable")?.value).toBe("/opt/codex-draft");
|
||||
expect(document.activeElement).toBe(inputForSetting(tab, "Codex executable"));
|
||||
expect(inputForSetting(tab, "Codex executable")?.selectionStart).toBe(5);
|
||||
expect(inputForSetting(tab, "Codex executable")?.selectionEnd).toBe(10);
|
||||
|
||||
clickButtonByLabel(tab, "Delete thread");
|
||||
|
||||
expect(inputForSetting(tab, "Codex executable")).toBe(codexInput);
|
||||
expect(selectForSetting(tab, "Send shortcut")).toBe(shortcut);
|
||||
expect(inputForSetting(tab, "Codex executable")?.value).toBe("/opt/codex-draft");
|
||||
expect(document.activeElement).toBe(inputForSetting(tab, "Codex executable"));
|
||||
expect(inputForSetting(tab, "Codex executable")?.selectionStart).toBe(5);
|
||||
expect(inputForSetting(tab, "Codex executable")?.selectionEnd).toBe(10);
|
||||
expect(tab.containerEl.querySelector(".codex-panel-settings__archived-row--delete-confirming")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rerenders only the changed dynamic section for archive and hook actions", async () => {
|
||||
const client = settingsClient({
|
||||
hooks: [hook({ key: "hook-1", command: "node hook.js", currentHash: "abc123", enabled: true })],
|
||||
});
|
||||
useShortLivedClients(client);
|
||||
const hookUpdate = deferred<undefined>();
|
||||
client.requestHandlers["config/batchWrite"]?.mockReturnValue(hookUpdate.promise);
|
||||
const tab = newSettingsTab({ archivedThreads: [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })] });
|
||||
|
||||
tab.display();
|
||||
await flushPromises();
|
||||
|
||||
const helperSection = dynamicSection(tab, "codex-panel-settings__helper-section");
|
||||
const archivedSection = dynamicSection(tab, "codex-panel-settings__archived-section");
|
||||
const hookSection = dynamicSection(tab, "codex-panel-settings__hook-section");
|
||||
const archiveToggle = inputForSetting(tab, "Save note by default");
|
||||
if (!archiveToggle) throw new Error("Missing archive toggle");
|
||||
|
||||
archiveToggle.checked = true;
|
||||
archiveToggle.dispatchEvent(new Event("change"));
|
||||
await flushPromises();
|
||||
|
||||
expect(dynamicSection(tab, "codex-panel-settings__helper-section")).toBe(helperSection);
|
||||
expect(dynamicSection(tab, "codex-panel-settings__hook-section")).toBe(hookSection);
|
||||
|
||||
clickButtonByLabel(tab, "Delete thread");
|
||||
|
||||
expect(dynamicSection(tab, "codex-panel-settings__helper-section")).toBe(helperSection);
|
||||
expect(dynamicSection(tab, "codex-panel-settings__hook-section")).toBe(hookSection);
|
||||
|
||||
clickButtonByText(tab, "Disable");
|
||||
await flushPromises();
|
||||
|
||||
expectRequestTimes(client, "config/batchWrite", 1);
|
||||
expect(dynamicSection(tab, "codex-panel-settings__hook-section")).not.toBeNull();
|
||||
expect(tab.containerEl.querySelector(".codex-panel-settings__hook-list")).not.toBeNull();
|
||||
expect(dynamicSection(tab, "codex-panel-settings__helper-section")).toBe(helperSection);
|
||||
expect(dynamicSection(tab, "codex-panel-settings__archived-section")).toBe(archivedSection);
|
||||
|
||||
hookUpdate.resolve(undefined);
|
||||
await flushPromises();
|
||||
tab.containerEl.remove();
|
||||
});
|
||||
|
||||
it("permanently deletes an archived thread from the confirmed settings row", async () => {
|
||||
|
|
@ -996,256 +654,10 @@ describe("settings tab", () => {
|
|||
});
|
||||
});
|
||||
|
||||
function panelThread(overrides: Partial<Thread> = {}): Thread {
|
||||
return {
|
||||
id: "019e0182-cb70-7a72-ab48-8bc9d0b0d781",
|
||||
preview: "Preview",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function appServerThread(overrides: Partial<ThreadRecord> = {}): ThreadRecord {
|
||||
return {
|
||||
id: "019e0182-cb70-7a72-ab48-8bc9d0b0d781",
|
||||
sessionId: "019e0182-cb70-7a72-ab48-8bc9d0b0d781",
|
||||
forkedFromId: null,
|
||||
parentThreadId: null,
|
||||
preview: "Preview",
|
||||
ephemeral: false,
|
||||
modelProvider: "openai",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: "/tmp",
|
||||
cliVersion: "codex-cli 0.0.0",
|
||||
source: "unknown",
|
||||
threadSource: null,
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
gitInfo: null,
|
||||
name: null,
|
||||
turns: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function model(modelId: string, isDefault = false, hidden = false, efforts: ReasoningEffort[] = ["medium"]): CatalogModel {
|
||||
return {
|
||||
id: `${modelId}-id`,
|
||||
model: modelId,
|
||||
upgrade: null,
|
||||
upgradeInfo: null,
|
||||
availabilityNux: null,
|
||||
displayName: `Display ${modelId}`,
|
||||
description: "",
|
||||
isDefault,
|
||||
hidden,
|
||||
supportedReasoningEfforts: efforts.map((reasoningEffort) => ({ reasoningEffort, description: reasoningEffort })),
|
||||
defaultReasoningEffort: "medium",
|
||||
inputModalities: ["text"],
|
||||
supportsPersonality: false,
|
||||
serviceTiers: [],
|
||||
defaultServiceTier: null,
|
||||
} satisfies CatalogModel;
|
||||
}
|
||||
|
||||
function hook(overrides: Partial<CatalogHookMetadata> = {}): CatalogHookMetadata {
|
||||
return {
|
||||
key: "hook-key",
|
||||
eventName: "postToolUse",
|
||||
handlerType: "command",
|
||||
matcher: "apply_patch",
|
||||
command: "node hook.js",
|
||||
timeoutSec: 10n,
|
||||
statusMessage: null,
|
||||
sourcePath: "/vault/.codex/hooks.json",
|
||||
source: "project",
|
||||
pluginId: null,
|
||||
displayOrder: 0n,
|
||||
enabled: true,
|
||||
isManaged: false,
|
||||
currentHash: "hash",
|
||||
trustStatus: "trusted",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function settingsClient(
|
||||
options: { models?: CatalogModel[]; hooks?: CatalogHookMetadata[]; hooksError?: Error; threads?: ThreadRecord[] } = {},
|
||||
) {
|
||||
return settingsRequestClient({
|
||||
"model/list": vi.fn().mockResolvedValue({ data: options.models ?? [model("gpt-5.4")] }),
|
||||
"hooks/list": vi.fn().mockImplementation(() => {
|
||||
if (options.hooksError) return Promise.reject(options.hooksError);
|
||||
return Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
cwd: "/vault",
|
||||
hooks: options.hooks ?? [],
|
||||
warnings: [],
|
||||
errors: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
}),
|
||||
"thread/list": vi.fn().mockResolvedValue({ data: options.threads ?? [appServerThread({ preview: "Archived" })] }),
|
||||
"config/batchWrite": vi.fn().mockResolvedValue({}),
|
||||
"thread/unarchive": vi.fn().mockResolvedValue({ thread: appServerThread({ preview: "Restored" }) }),
|
||||
"thread/delete": vi.fn().mockResolvedValue({}),
|
||||
});
|
||||
}
|
||||
|
||||
type SettingsRequestClient = AppServerClient & {
|
||||
request: ReturnType<typeof vi.fn<(method: string, params?: unknown, options?: unknown) => unknown>>;
|
||||
requestHandlers: Record<string, ReturnType<typeof vi.fn<(params?: unknown, options?: unknown) => unknown>>>;
|
||||
};
|
||||
|
||||
function useShortLivedClients(...clients: SettingsRequestClient[]): void {
|
||||
const runWithClient = (client: SettingsRequestClient, operation: (client: AppServerClient) => Promise<unknown>) => operation(client);
|
||||
if (clients.length === 1) {
|
||||
const [client] = clients;
|
||||
if (!client) throw new Error("Expected a short-lived client.");
|
||||
withShortLivedAppServerClientMock.mockImplementation(
|
||||
(_codexPath: string, _cwd: string, operation: (client: AppServerClient) => Promise<unknown>) => runWithClient(client, operation),
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const client of clients) {
|
||||
withShortLivedAppServerClientMock.mockImplementationOnce(
|
||||
(_codexPath: string, _cwd: string, operation: (client: AppServerClient) => Promise<unknown>) => runWithClient(client, operation),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function settingsRequestClient(
|
||||
handlers: Record<string, ReturnType<typeof vi.fn<(params?: unknown, options?: unknown) => unknown>>>,
|
||||
): SettingsRequestClient {
|
||||
return {
|
||||
requestHandlers: handlers,
|
||||
request: vi.fn((method: string, params: unknown) => {
|
||||
const handler = handlers[method];
|
||||
if (!handler) throw new Error(`Unexpected app-server request: ${method}`);
|
||||
return handler(params);
|
||||
}),
|
||||
} as unknown as SettingsRequestClient;
|
||||
}
|
||||
|
||||
function requestMethods(client: SettingsRequestClient): string[] {
|
||||
return client.request.mock.calls.map(([method]) => method);
|
||||
}
|
||||
|
||||
function expectRequestTimes(client: SettingsRequestClient, method: string, times: number): void {
|
||||
expect(requestMethods(client).filter((calledMethod) => calledMethod === method)).toHaveLength(times);
|
||||
}
|
||||
|
||||
function newSettingsTab(
|
||||
options: {
|
||||
saveSettings?: (settings: CodexPanelSettings) => Promise<void>;
|
||||
prepareAppServerContextChange?: () => void;
|
||||
sendShortcut?: "enter" | "mod-enter";
|
||||
modelsSnapshot?: ModelMetadata[];
|
||||
fetchModels?: () => Promise<readonly ModelMetadata[]>;
|
||||
refreshModels?: () => Promise<readonly ModelMetadata[]>;
|
||||
observeModels?: SettingsDynamicDataAccess["observeModelsResult"];
|
||||
refreshOpenViews?: () => void;
|
||||
archivedThreads?: Thread[];
|
||||
archivedSnapshot?: Thread[] | null;
|
||||
refreshArchived?: () => Promise<readonly Thread[]>;
|
||||
observeArchived?: SettingsDynamicDataAccess["observeArchivedThreadsResult"];
|
||||
applyThreadCatalogEvent?: (event: ThreadCatalogEvent) => void;
|
||||
dynamicData?: SettingsDynamicDataAccess;
|
||||
settings?: Partial<{
|
||||
threadNamingModel: string | null;
|
||||
threadNamingEffort: string | null;
|
||||
rewriteSelectionModel: string | null;
|
||||
rewriteSelectionEffort: string | null;
|
||||
}>;
|
||||
} = {},
|
||||
): CodexPanelSettingTab {
|
||||
function newSettingsTab(options: SettingsTabHostOptions = {}): CodexPanelSettingTab {
|
||||
return new CodexPanelSettingTab({} as never, {} as never, settingsTabHost(options));
|
||||
}
|
||||
|
||||
function settingsTabHost(
|
||||
options: {
|
||||
saveSettings?: (settings: CodexPanelSettings) => Promise<void>;
|
||||
prepareAppServerContextChange?: () => void;
|
||||
sendShortcut?: "enter" | "mod-enter";
|
||||
modelsSnapshot?: ModelMetadata[];
|
||||
fetchModels?: () => Promise<readonly ModelMetadata[]>;
|
||||
refreshModels?: () => Promise<readonly ModelMetadata[]>;
|
||||
observeModels?: SettingsDynamicDataAccess["observeModelsResult"];
|
||||
refreshOpenViews?: () => void;
|
||||
archivedThreads?: Thread[];
|
||||
archivedSnapshot?: Thread[] | null;
|
||||
refreshArchived?: () => Promise<readonly Thread[]>;
|
||||
observeArchived?: SettingsDynamicDataAccess["observeArchivedThreadsResult"];
|
||||
applyThreadCatalogEvent?: (event: ThreadCatalogEvent) => void;
|
||||
dynamicData?: SettingsDynamicDataAccess;
|
||||
settings?: Partial<{
|
||||
threadNamingModel: string | null;
|
||||
threadNamingEffort: string | null;
|
||||
rewriteSelectionModel: string | null;
|
||||
rewriteSelectionEffort: string | null;
|
||||
}>;
|
||||
} = {},
|
||||
): CodexPanelSettingTabHost {
|
||||
const defaultArchivedThreads = [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })];
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
threadNamingModel: options.settings?.threadNamingModel ?? null,
|
||||
threadNamingEffort: options.settings?.threadNamingEffort ?? null,
|
||||
rewriteSelectionModel: options.settings?.rewriteSelectionModel ?? null,
|
||||
rewriteSelectionEffort: options.settings?.rewriteSelectionEffort ?? null,
|
||||
sendShortcut: options.sendShortcut ?? "enter",
|
||||
};
|
||||
const appServerQueries = {
|
||||
modelsSnapshot: vi.fn(() => options.modelsSnapshot ?? []),
|
||||
fetchModels: options.fetchModels ?? vi.fn().mockResolvedValue(options.modelsSnapshot ?? []),
|
||||
refreshModels: options.refreshModels ?? vi.fn().mockResolvedValue(options.modelsSnapshot ?? []),
|
||||
observeModelsResult: options.observeModels ?? vi.fn(() => () => undefined),
|
||||
};
|
||||
const threadCatalog = {
|
||||
archivedSnapshot: vi.fn(() => options.archivedSnapshot ?? null),
|
||||
refreshArchived: options.refreshArchived ?? vi.fn().mockResolvedValue(options.archivedThreads ?? defaultArchivedThreads),
|
||||
observeArchived: options.observeArchived ?? vi.fn(() => () => undefined),
|
||||
apply: options.applyThreadCatalogEvent ?? vi.fn(),
|
||||
};
|
||||
return {
|
||||
settings,
|
||||
dynamicData:
|
||||
options.dynamicData ??
|
||||
createSettingsAppServerDynamicData({
|
||||
vaultPath: "/vault",
|
||||
clientAccess: {
|
||||
withClient: <T>(operation: (client: AppServerClient) => Promise<T>, clientOptions?: AppServerClientAccessOptions) =>
|
||||
withShortLivedAppServerClientMock(settings.codexPath, "/vault", operation, clientOptions) as Promise<T>,
|
||||
},
|
||||
appServerQueries,
|
||||
threadCatalog,
|
||||
}),
|
||||
publishSettings: async (nextSettings) => {
|
||||
const previousSettings = { ...settings };
|
||||
await (options.saveSettings ?? vi.fn().mockResolvedValue(undefined))(nextSettings);
|
||||
const appServerContextReplaced = previousSettings.codexPath !== nextSettings.codexPath;
|
||||
if (appServerContextReplaced) options.prepareAppServerContextChange?.();
|
||||
Object.assign(settings, nextSettings);
|
||||
if (appServerContextReplaced || previousSettings.showToolbar !== nextSettings.showToolbar) options.refreshOpenViews?.();
|
||||
return { appServerContextReplaced };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
function settingNames(tab: CodexPanelSettingTab): string[] {
|
||||
return Array.from(settingsSectionRoots(tab)).flatMap((element) => {
|
||||
if (element.classList.contains("codex-panel-settings__header")) return [];
|
||||
|
|
@ -1299,12 +711,6 @@ function clickButtonByLabel(tab: CodexPanelSettingTab, label: string): void {
|
|||
buttonByLabel(tab, label).click();
|
||||
}
|
||||
|
||||
function clickButtonByText(tab: CodexPanelSettingTab, text: string): void {
|
||||
const button = Array.from(tab.containerEl.querySelectorAll("button")).find((element) => element.textContent === text);
|
||||
if (!button) throw new Error(`Could not find button text: ${text}`);
|
||||
button.click();
|
||||
}
|
||||
|
||||
function pointerDownButtonByLabel(tab: CodexPanelSettingTab, label: string): void {
|
||||
buttonByLabel(tab, label).dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
||||
}
|
||||
|
|
@ -1321,12 +727,6 @@ function inputForSetting(tab: CodexPanelSettingTab, name: string): HTMLInputElem
|
|||
return settingElement(tab, name)?.querySelector("input") ?? null;
|
||||
}
|
||||
|
||||
function dynamicSection(tab: CodexPanelSettingTab, className: string): Element {
|
||||
const section = tab.containerEl.querySelector(`.${className}`);
|
||||
if (!section) throw new Error(`Missing dynamic section: ${className}`);
|
||||
return section;
|
||||
}
|
||||
|
||||
function settingElement(tab: CodexPanelSettingTab, name: string): Element | null {
|
||||
return (
|
||||
Array.from(tab.containerEl.querySelectorAll(".setting-item")).find(
|
||||
|
|
|
|||
256
tests/settings/test-support.ts
Normal file
256
tests/settings/test-support.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { expect, vi } from "vitest";
|
||||
|
||||
import type { AppServerClient } from "../../src/app-server/connection/client";
|
||||
import type { AppServerClientAccessOptions } from "../../src/app-server/connection/client-access";
|
||||
import type { CatalogHookMetadata, CatalogModel } from "../../src/app-server/protocol/catalog";
|
||||
import type { ThreadRecord } from "../../src/app-server/protocol/thread";
|
||||
import type { ModelMetadata, ReasoningEffort } from "../../src/domain/catalog/metadata";
|
||||
import type { Thread } from "../../src/domain/threads/model";
|
||||
import type { ThreadCatalogEvent } from "../../src/features/threads/catalog/thread-catalog";
|
||||
import { createSettingsAppServerDynamicData } from "../../src/settings/app-server-dynamic-data";
|
||||
import type { SettingsDynamicDataAccess } from "../../src/settings/dynamic-data";
|
||||
import type { CodexPanelSettingTabHost } from "../../src/settings/host";
|
||||
import { type CodexPanelSettings, DEFAULT_SETTINGS } from "../../src/settings/model";
|
||||
|
||||
type ShortLivedClientOperation = (
|
||||
codexPath: string,
|
||||
cwd: string,
|
||||
operation: (client: AppServerClient) => Promise<unknown>,
|
||||
options?: AppServerClientAccessOptions,
|
||||
) => Promise<unknown>;
|
||||
type ShortLivedClientMock = ReturnType<typeof vi.fn<ShortLivedClientOperation>>;
|
||||
|
||||
let shortLivedClientMock: ShortLivedClientMock | null = null;
|
||||
|
||||
export function setSettingsShortLivedClientMock(mock: unknown): void {
|
||||
shortLivedClientMock = mock as ShortLivedClientMock;
|
||||
}
|
||||
|
||||
function currentShortLivedClientMock(): ShortLivedClientMock {
|
||||
if (!shortLivedClientMock) throw new Error("Expected settings short-lived client mock");
|
||||
return shortLivedClientMock;
|
||||
}
|
||||
|
||||
export function panelThread(overrides: Partial<Thread> = {}): Thread {
|
||||
return {
|
||||
id: "019e0182-cb70-7a72-ab48-8bc9d0b0d781",
|
||||
preview: "Preview",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function appServerThread(overrides: Partial<ThreadRecord> = {}): ThreadRecord {
|
||||
return {
|
||||
id: "019e0182-cb70-7a72-ab48-8bc9d0b0d781",
|
||||
sessionId: "019e0182-cb70-7a72-ab48-8bc9d0b0d781",
|
||||
forkedFromId: null,
|
||||
parentThreadId: null,
|
||||
preview: "Preview",
|
||||
ephemeral: false,
|
||||
modelProvider: "openai",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: "/tmp",
|
||||
cliVersion: "codex-cli 0.0.0",
|
||||
source: "unknown",
|
||||
threadSource: null,
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
gitInfo: null,
|
||||
name: null,
|
||||
turns: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function model(modelId: string, isDefault = false, hidden = false, efforts: ReasoningEffort[] = ["medium"]): CatalogModel {
|
||||
return {
|
||||
id: `${modelId}-id`,
|
||||
model: modelId,
|
||||
upgrade: null,
|
||||
upgradeInfo: null,
|
||||
availabilityNux: null,
|
||||
displayName: `Display ${modelId}`,
|
||||
description: "",
|
||||
isDefault,
|
||||
hidden,
|
||||
supportedReasoningEfforts: efforts.map((reasoningEffort) => ({ reasoningEffort, description: reasoningEffort })),
|
||||
defaultReasoningEffort: "medium",
|
||||
inputModalities: ["text"],
|
||||
supportsPersonality: false,
|
||||
serviceTiers: [],
|
||||
defaultServiceTier: null,
|
||||
} satisfies CatalogModel;
|
||||
}
|
||||
|
||||
export function hook(overrides: Partial<CatalogHookMetadata> = {}): CatalogHookMetadata {
|
||||
return {
|
||||
key: "hook-key",
|
||||
eventName: "postToolUse",
|
||||
handlerType: "command",
|
||||
matcher: "apply_patch",
|
||||
command: "node hook.js",
|
||||
timeoutSec: 10n,
|
||||
statusMessage: null,
|
||||
sourcePath: "/vault/.codex/hooks.json",
|
||||
source: "project",
|
||||
pluginId: null,
|
||||
displayOrder: 0n,
|
||||
enabled: true,
|
||||
isManaged: false,
|
||||
currentHash: "hash",
|
||||
trustStatus: "trusted",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function settingsClient(
|
||||
options: { models?: CatalogModel[]; hooks?: CatalogHookMetadata[]; hooksError?: Error; threads?: ThreadRecord[] } = {},
|
||||
): SettingsRequestClient {
|
||||
return settingsRequestClient({
|
||||
"model/list": vi.fn().mockResolvedValue({ data: options.models ?? [model("gpt-5.4")] }),
|
||||
"hooks/list": vi.fn().mockImplementation(() => {
|
||||
if (options.hooksError) return Promise.reject(options.hooksError);
|
||||
return Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
cwd: "/vault",
|
||||
hooks: options.hooks ?? [],
|
||||
warnings: [],
|
||||
errors: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
}),
|
||||
"thread/list": vi.fn().mockResolvedValue({ data: options.threads ?? [appServerThread({ preview: "Archived" })] }),
|
||||
"config/batchWrite": vi.fn().mockResolvedValue({}),
|
||||
"thread/unarchive": vi.fn().mockResolvedValue({ thread: appServerThread({ preview: "Restored" }) }),
|
||||
"thread/delete": vi.fn().mockResolvedValue({}),
|
||||
});
|
||||
}
|
||||
|
||||
export type SettingsRequestClient = AppServerClient & {
|
||||
request: ReturnType<typeof vi.fn<(method: string, params?: unknown, options?: unknown) => unknown>>;
|
||||
requestHandlers: Record<string, ReturnType<typeof vi.fn<(params?: unknown, options?: unknown) => unknown>>>;
|
||||
};
|
||||
|
||||
export function useShortLivedClients(...clients: SettingsRequestClient[]): void {
|
||||
const mock = currentShortLivedClientMock();
|
||||
const runWithClient = (client: SettingsRequestClient, operation: (client: AppServerClient) => Promise<unknown>) => operation(client);
|
||||
if (clients.length === 1) {
|
||||
const [client] = clients;
|
||||
if (!client) throw new Error("Expected a short-lived client.");
|
||||
mock.mockImplementation((_codexPath: string, _cwd: string, operation: (client: AppServerClient) => Promise<unknown>) =>
|
||||
runWithClient(client, operation),
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const client of clients) {
|
||||
mock.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: AppServerClient) => Promise<unknown>) =>
|
||||
runWithClient(client, operation),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function settingsRequestClient(
|
||||
handlers: Record<string, ReturnType<typeof vi.fn<(params?: unknown, options?: unknown) => unknown>>>,
|
||||
): SettingsRequestClient {
|
||||
return {
|
||||
requestHandlers: handlers,
|
||||
request: vi.fn((method: string, params: unknown) => {
|
||||
const handler = handlers[method];
|
||||
if (!handler) throw new Error(`Unexpected app-server request: ${method}`);
|
||||
return handler(params);
|
||||
}),
|
||||
} as unknown as SettingsRequestClient;
|
||||
}
|
||||
|
||||
export function requestMethods(client: SettingsRequestClient): string[] {
|
||||
return client.request.mock.calls.map(([method]) => method);
|
||||
}
|
||||
|
||||
export function expectRequestTimes(client: SettingsRequestClient, method: string, times: number): void {
|
||||
expect(requestMethods(client).filter((calledMethod) => calledMethod === method)).toHaveLength(times);
|
||||
}
|
||||
|
||||
export interface SettingsTabHostOptions {
|
||||
saveSettings?: (settings: CodexPanelSettings) => Promise<void>;
|
||||
prepareAppServerContextChange?: () => void;
|
||||
sendShortcut?: "enter" | "mod-enter";
|
||||
modelsSnapshot?: ModelMetadata[];
|
||||
fetchModels?: () => Promise<readonly ModelMetadata[]>;
|
||||
refreshModels?: () => Promise<readonly ModelMetadata[]>;
|
||||
observeModels?: SettingsDynamicDataAccess["observeModelsResult"];
|
||||
refreshOpenViews?: () => void;
|
||||
archivedThreads?: Thread[];
|
||||
archivedSnapshot?: Thread[] | null;
|
||||
refreshArchived?: () => Promise<readonly Thread[]>;
|
||||
observeArchived?: SettingsDynamicDataAccess["observeArchivedThreadsResult"];
|
||||
applyThreadCatalogEvent?: (event: ThreadCatalogEvent) => void;
|
||||
dynamicData?: SettingsDynamicDataAccess;
|
||||
settings?: Partial<{
|
||||
threadNamingModel: string | null;
|
||||
threadNamingEffort: string | null;
|
||||
rewriteSelectionModel: string | null;
|
||||
rewriteSelectionEffort: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function settingsTabHost(options: SettingsTabHostOptions = {}): CodexPanelSettingTabHost {
|
||||
const defaultArchivedThreads = [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })];
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
threadNamingModel: options.settings?.threadNamingModel ?? null,
|
||||
threadNamingEffort: options.settings?.threadNamingEffort ?? null,
|
||||
rewriteSelectionModel: options.settings?.rewriteSelectionModel ?? null,
|
||||
rewriteSelectionEffort: options.settings?.rewriteSelectionEffort ?? null,
|
||||
sendShortcut: options.sendShortcut ?? "enter",
|
||||
};
|
||||
const appServerQueries = {
|
||||
modelsSnapshot: vi.fn(() => options.modelsSnapshot ?? []),
|
||||
fetchModels: options.fetchModels ?? vi.fn().mockResolvedValue(options.modelsSnapshot ?? []),
|
||||
refreshModels: options.refreshModels ?? vi.fn().mockResolvedValue(options.modelsSnapshot ?? []),
|
||||
observeModelsResult: options.observeModels ?? vi.fn(() => () => undefined),
|
||||
};
|
||||
const threadCatalog = {
|
||||
archivedSnapshot: vi.fn(() => options.archivedSnapshot ?? null),
|
||||
refreshArchived: options.refreshArchived ?? vi.fn().mockResolvedValue(options.archivedThreads ?? defaultArchivedThreads),
|
||||
observeArchived: options.observeArchived ?? vi.fn(() => () => undefined),
|
||||
apply: options.applyThreadCatalogEvent ?? vi.fn(),
|
||||
};
|
||||
return {
|
||||
settings,
|
||||
dynamicData:
|
||||
options.dynamicData ??
|
||||
createSettingsAppServerDynamicData({
|
||||
vaultPath: "/vault",
|
||||
clientAccess: {
|
||||
withClient: <T>(operation: (client: AppServerClient) => Promise<T>, clientOptions?: AppServerClientAccessOptions) =>
|
||||
currentShortLivedClientMock()(settings.codexPath, "/vault", operation, clientOptions) as Promise<T>,
|
||||
},
|
||||
appServerQueries,
|
||||
threadCatalog,
|
||||
}),
|
||||
publishSettings: async (nextSettings) => {
|
||||
const previousSettings = { ...settings };
|
||||
await (options.saveSettings ?? vi.fn().mockResolvedValue(undefined))(nextSettings);
|
||||
const appServerContextReplaced = previousSettings.codexPath !== nextSettings.codexPath;
|
||||
if (appServerContextReplaced) options.prepareAppServerContextChange?.();
|
||||
Object.assign(settings, nextSettings);
|
||||
if (appServerContextReplaced || previousSettings.showToolbar !== nextSettings.showToolbar) options.refreshOpenViews?.();
|
||||
return { appServerContextReplaced };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function flushPromises(): Promise<void> {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
52
tests/shared/obsidian/components.test.tsx
Normal file
52
tests/shared/obsidian/components.test.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { h } from "preact";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderUiRoot } from "../../../src/shared/dom/preact-root.dom";
|
||||
import { ObsidianToolbarAction } from "../../../src/shared/obsidian/components.obsidian";
|
||||
import { installObsidianDomShims } from "../../support/dom";
|
||||
|
||||
installObsidianDomShims();
|
||||
|
||||
describe("Obsidian UI components", () => {
|
||||
it("renders toolbar actions with Obsidian native structure and interaction semantics", () => {
|
||||
const parent = document.createElement("div");
|
||||
const onClick = vi.fn();
|
||||
|
||||
renderUiRoot(
|
||||
parent,
|
||||
h(ObsidianToolbarAction, {
|
||||
icon: "refresh-cw",
|
||||
label: "Refresh threads",
|
||||
className: "clickable-icon nav-action-button",
|
||||
onClick,
|
||||
}),
|
||||
);
|
||||
|
||||
const action = parent.querySelector<HTMLElement>('[aria-label="Refresh threads"]');
|
||||
if (!action) throw new Error("Expected toolbar action");
|
||||
expect(action.tagName).toBe("DIV");
|
||||
expect(action.getAttribute("role")).toBeNull();
|
||||
expect(action.classList.contains("clickable-icon")).toBe(true);
|
||||
expect(action.classList.contains("nav-action-button")).toBe(true);
|
||||
action.click();
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
|
||||
renderUiRoot(
|
||||
parent,
|
||||
h(ObsidianToolbarAction, {
|
||||
icon: "refresh-cw",
|
||||
label: "Refresh threads",
|
||||
className: "clickable-icon nav-action-button",
|
||||
disabled: true,
|
||||
onClick,
|
||||
}),
|
||||
);
|
||||
|
||||
const disabledAction = parent.querySelector<HTMLElement>('[aria-label="Refresh threads"]');
|
||||
if (!disabledAction) throw new Error("Expected disabled toolbar action");
|
||||
expect(disabledAction.classList.contains("is-disabled")).toBe(true);
|
||||
disabledAction.click();
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue