From eeefedfc8f40c1721c03b6848826a12609d7c4fa Mon Sep 17 00:00:00 2001 From: murashit Date: Mon, 6 Jul 2026 14:32:55 +0900 Subject: [PATCH] Refactor high-noise test setup --- .../application/state/root-reducer.test.ts | 149 +++++++------- .../chat/panel/composer-controller.test.ts | 188 ++++-------------- tests/settings/settings-tab.test.ts | 137 ++++--------- 3 files changed, 150 insertions(+), 324 deletions(-) diff --git a/tests/features/chat/application/state/root-reducer.test.ts b/tests/features/chat/application/state/root-reducer.test.ts index fd47ec00..62b5a345 100644 --- a/tests/features/chat/application/state/root-reducer.test.ts +++ b/tests/features/chat/application/state/root-reducer.test.ts @@ -12,33 +12,17 @@ import { chatStateFixture, chatStateWith } from "../../support/state"; describe("chatReducer", () => { it("clears active turn and thread-scoped state", () => { - let state = chatStateFixture(); - state = chatStateWith(state, { activeThread: { id: "thread" } }); - state = chatStateWith(state, { turn: { lifecycle: { kind: "running", turnId: "turn" } } }); - state = chatStateWith(state, { runtime: { active: { model: "gpt-5.1" } } }); - state = chatStateWith(state, { runtime: { active: { reasoningEffort: "high" } } }); - state = chatStateWith(state, { runtime: { active: { serviceTier: "fast" } } }); - state = chatStateWith(state, { runtime: { active: { approvalsReviewer: "auto_review" } } }); - state = chatStateWith(state, { runtime: { active: { activePermissionProfile: { id: ":workspace", extends: null } } } }); - state = chatStateWith(state, { runtime: { active: { collaborationMode: "plan" } } }); - state = chatStateWith(state, { runtime: { pending: { collaborationMode: setCollaborationModeIntent("plan") } } }); - state = chatStateWith(state, { activeThread: { goal: goal("thread") } }); - state = chatStateWith(state, { messageStream: { historyCursor: "cursor" } }); - state = chatStateWith(state, { messageStream: { loadingHistory: true } }); - state = chatStateWith(state, { composer: { draft: "keep me" } }); - state = withChatStateMessageStreamItems(state, [message("m1")]); - state = chatStateWith(state, { messageStream: { turnDiffs: new Map([["turn", "@@"]]) } }); - state = chatStateWith(state, { requests: { approvals: [approval(1)] } }); - state = chatStateWith(state, { requests: { pendingUserInputs: [userInput(2)] } }); - state = chatStateWith(state, { requests: { userInputDrafts: new Map([["2:note", "draft"]]) } }); - state = chatStateWith(state, { composer: { suggestSelected: 1 } }); - state = chatStateWith(state, { composer: { suggestions: [suggestion("/plan")] } }); - state = chatStateWith(state, { composer: { suggestionsDismissedSignature: "dismissed" } }); - state = chatStateWith(state, { ui: { disclosures: { approvalDetails: new Set(["1:details"]) } } }); - state = chatStateWith(state, { ui: { disclosures: { textDetails: new Set(["previous:details"]) } } }); - state = chatStateWith(state, { ui: { messageActionMenu: { forkMenuItemId: "previous" } } }); + let state = threadScopedResidue({ draft: "keep me", messageId: "m1" }); state = chatStateWith(state, { - ui: { goalEditor: { kind: "editing", threadId: "thread", objectiveDraft: "draft", tokenBudgetDraft: null } }, + runtime: { + active: { + model: "gpt-5.1", + reasoningEffort: "high", + serviceTier: "fast", + approvalsReviewer: "auto_review", + activePermissionProfile: { id: ":workspace", extends: null }, + }, + }, }); let pendingState = chatReducer(state, { type: "runtime/model-requested", model: "gpt-5.2" }); pendingState = chatReducer(pendingState, { type: "runtime/permission-profile-requested", permissionProfile: ":read-only" }); @@ -62,47 +46,11 @@ describe("chatReducer", () => { expect(next.runtime.pending.fastMode).toEqual({ kind: "unchanged" }); expect(next.runtime.pending.approvalsReviewer).toEqual({ kind: "unchanged" }); expect(next.runtime.pending.collaborationMode).toEqual({ kind: "unchanged" }); - expect(activeTurnId(next)).toBeNull(); - expect(chatStateMessageStreamItems(next)).toEqual([]); - expect(next.messageStream.turnDiffs.size).toBe(0); - expect(next.messageStream.historyCursor).toBeNull(); - expect(next.messageStream.loadingHistory).toBe(false); - expect(next.requests.approvals).toEqual([]); - expect(next.requests.pendingUserInputs).toEqual([]); - expect(next.requests.userInputDrafts.size).toBe(0); - expect(next.composer.draft).toBe(""); - expect(next.composer.suggestSelected).toBe(0); - expect(next.composer.suggestions).toEqual([]); - expect(next.composer.suggestionsDismissedSignature).toBeNull(); - expect(uiDisclosureCount(next)).toBe(0); - expect(next.ui.messageActionMenu.forkMenuItemId).toBeNull(); - expect(next.ui.goalEditor.kind).toBe("closed"); + expectThreadScopeReset(next, { items: [] }); }); it("resets thread-scoped state when resuming a thread", () => { - let state = chatStateFixture(); - state = chatStateWith(state, { activeThread: { id: "previous-thread" } }); - state = chatStateWith(state, { turn: { lifecycle: { kind: "running", turnId: "previous-turn" } } }); - state = chatStateWith(state, { messageStream: { historyCursor: "cursor" } }); - state = chatStateWith(state, { activeThread: { goal: goal("previous-thread") } }); - state = chatStateWith(state, { messageStream: { loadingHistory: true } }); - state = chatStateWith(state, { composer: { draft: "previous draft" } }); - state = withChatStateMessageStreamItems(state, [message("previous-message")]); - state = chatStateWith(state, { messageStream: { turnDiffs: new Map([["previous-turn", "@@"]]) } }); - state = chatStateWith(state, { requests: { approvals: [approval(1)] } }); - state = chatStateWith(state, { requests: { pendingUserInputs: [userInput(2)] } }); - state = chatStateWith(state, { requests: { userInputDrafts: new Map([["2:note", "draft"]]) } }); - state = chatStateWith(state, { composer: { suggestSelected: 1 } }); - state = chatStateWith(state, { composer: { suggestions: [suggestion("/plan")] } }); - state = chatStateWith(state, { composer: { suggestionsDismissedSignature: "dismissed" } }); - state = chatStateWith(state, { runtime: { active: { collaborationMode: "plan" } } }); - state = chatStateWith(state, { runtime: { pending: { collaborationMode: setCollaborationModeIntent("plan") } } }); - state = chatStateWith(state, { ui: { disclosures: { approvalDetails: new Set(["1:details"]) } } }); - state = chatStateWith(state, { ui: { disclosures: { textDetails: new Set(["previous:details"]) } } }); - state = chatStateWith(state, { ui: { messageActionMenu: { forkMenuItemId: "previous" } } }); - state = chatStateWith(state, { - ui: { goalEditor: { kind: "editing", threadId: "previous-thread", objectiveDraft: "draft", tokenBudgetDraft: null } }, - }); + const state = threadScopedResidue({ threadId: "previous-thread", turnId: "previous-turn" }); const resumedItems = [message("resumed-message")]; const next = chatReducer(state, { @@ -124,23 +72,9 @@ describe("chatReducer", () => { expect(next.activeThread.id).toBe("resumed-thread"); expect(next.activeThread.goal).toBeNull(); - expect(activeTurnId(next)).toBeNull(); - expect(next.messageStream.historyCursor).toBeNull(); - expect(next.messageStream.loadingHistory).toBe(false); - expect(next.composer.draft).toBe(""); - expect(chatStateMessageStreamItems(next)).toEqual(resumedItems); - expect(next.messageStream.turnDiffs.size).toBe(0); - expect(next.requests.approvals).toEqual([]); - expect(next.requests.pendingUserInputs).toEqual([]); - expect(next.requests.userInputDrafts.size).toBe(0); - expect(next.composer.suggestSelected).toBe(0); - expect(next.composer.suggestions).toEqual([]); - expect(next.composer.suggestionsDismissedSignature).toBeNull(); expect(next.runtime.active.collaborationMode).toBeNull(); expect(next.runtime.pending.collaborationMode).toEqual({ kind: "unchanged" }); - expect(uiDisclosureCount(next)).toBe(0); - expect(next.ui.messageActionMenu.forkMenuItemId).toBeNull(); - expect(next.ui.goalEditor.kind).toBe("closed"); + expectThreadScopeReset(next, { items: resumedItems }); }); it("preserves empty-panel runtime reservations when thread activation explicitly requests it", () => { @@ -805,6 +739,63 @@ function message(id: string): MessageStreamItem { return { id, kind: "message", role: "assistant", text: id, messageKind: "assistantResponse", messageState: "completed" }; } +function threadScopedResidue(options: { threadId?: string; turnId?: string; draft?: string; messageId?: string } = {}): ChatState { + const threadId = options.threadId ?? "thread"; + const turnId = options.turnId ?? "turn"; + let state = chatStateFixture({ + activeThread: { id: threadId, goal: goal(threadId) }, + turn: { lifecycle: { kind: "running", turnId } }, + runtime: { + active: { collaborationMode: "plan" }, + pending: { collaborationMode: setCollaborationModeIntent("plan") }, + }, + messageStream: { + historyCursor: "cursor", + loadingHistory: true, + turnDiffs: new Map([[turnId, "@@"]]), + }, + requests: { + approvals: [approval(1)], + pendingUserInputs: [userInput(2)], + userInputDrafts: new Map([["2:note", "draft"]]), + }, + composer: { + draft: options.draft ?? "previous draft", + suggestSelected: 1, + suggestions: [suggestion("/plan")], + suggestionsDismissedSignature: "dismissed", + }, + ui: { + disclosures: { + approvalDetails: new Set(["1:details"]), + textDetails: new Set(["previous:details"]), + }, + messageActionMenu: { forkMenuItemId: "previous" }, + goalEditor: { kind: "editing", threadId, objectiveDraft: "draft", tokenBudgetDraft: null }, + }, + }); + state = withChatStateMessageStreamItems(state, [message(options.messageId ?? "previous-message")]); + return state; +} + +function expectThreadScopeReset(state: ChatState, options: { items: readonly MessageStreamItem[] }): void { + expect(activeTurnId(state)).toBeNull(); + expect(chatStateMessageStreamItems(state)).toEqual(options.items); + expect(state.messageStream.turnDiffs.size).toBe(0); + expect(state.messageStream.historyCursor).toBeNull(); + expect(state.messageStream.loadingHistory).toBe(false); + expect(state.requests.approvals).toEqual([]); + expect(state.requests.pendingUserInputs).toEqual([]); + expect(state.requests.userInputDrafts.size).toBe(0); + expect(state.composer.draft).toBe(""); + expect(state.composer.suggestSelected).toBe(0); + expect(state.composer.suggestions).toEqual([]); + expect(state.composer.suggestionsDismissedSignature).toBeNull(); + expect(uiDisclosureCount(state)).toBe(0); + expect(state.ui.messageActionMenu.forkMenuItemId).toBeNull(); + expect(state.ui.goalEditor.kind).toBe("closed"); +} + function suggestion(display: string): ChatState["composer"]["suggestions"][number] { return { display, detail: "Plan mode", replacement: display, start: 0, appendSpaceOnInsert: true }; } diff --git a/tests/features/chat/panel/composer-controller.test.ts b/tests/features/chat/panel/composer-controller.test.ts index 0cdfffb4..68d1459e 100644 --- a/tests/features/chat/panel/composer-controller.test.ts +++ b/tests/features/chat/panel/composer-controller.test.ts @@ -29,32 +29,50 @@ function renderComposerController( renderUiRoot(parent, h(ComposerShell, controller.renderState(composerReadModelFromChatState(stateStore.getState()), actions))); } +type ComposerControllerOptions = ConstructorParameters[0]; + +function composerControllerFixture( + options: { stateStore?: ChatStateStore; controller?: Partial; renderActions?: ChatComposerRenderActions } = {}, +): { + controller: ChatComposerController; + parent: HTMLElement; + renderShell: ReturnType; + stateStore: ChatStateStore; +} { + const stateStore = options.stateStore ?? createChatStateStore(); + const parent = document.createElement("div"); + const controller = new ChatComposerController({ + noteCandidateProvider: noteProvider(), + contextReferenceProvider: contextProvider(), + sourcePath: () => "", + viewId: "view", + referenceActiveNoteOnSend: () => false, + sendShortcut: () => "enter", + scrollThreadFromComposerEdges: () => false, + threadScrollFromComposer: vi.fn(), + canInterrupt: (_state) => false, + composerProjection: defaultComposerProjection, + currentModelForSuggestions: () => null, + togglePlan: vi.fn(), + toggleAutoReview: vi.fn(), + toggleFast: vi.fn(), + onDraftChange: vi.fn(), + onHeightChange: vi.fn(), + ...options.controller, + stateStore, + }); + const renderShell = vi.fn(() => renderComposerController(parent, controller, stateStore, options.renderActions)); + stateStore.subscribe(renderShell); + return { controller, parent, renderShell, stateStore }; +} + describe("ChatComposerController", () => { it("derives composer placeholder and meta from the projection", () => { - const stateStore = createChatStateStore(); const projection = vi.fn((model: ChatPanelComposerReadModel) => ({ placeholder: `Projected ${model.draft.value || "empty"}`, meta: defaultComposerProjection(model).meta, })); - const controller = new ChatComposerController({ - noteCandidateProvider: noteProvider(), - contextReferenceProvider: contextProvider(), - sourcePath: () => "", - stateStore, - viewId: "view", - referenceActiveNoteOnSend: () => false, - sendShortcut: () => "enter", - scrollThreadFromComposerEdges: () => false, - threadScrollFromComposer: vi.fn(), - canInterrupt: (_state) => false, - composerProjection: projection, - currentModelForSuggestions: () => null, - togglePlan: vi.fn(), - toggleAutoReview: vi.fn(), - toggleFast: vi.fn(), - onDraftChange: vi.fn(), - onHeightChange: vi.fn(), - }); + const { controller, stateStore } = composerControllerFixture({ controller: { composerProjection: projection } }); const props = controller.renderState(composerReadModelFromChatState(stateStore.getState()), { submit: vi.fn() }); @@ -712,28 +730,8 @@ describe("ChatComposerController", () => { }); it("delegates composer runtime toggles", () => { - const stateStore = createChatStateStore(); - const parent = document.createElement("div"); const togglePlan = vi.fn(); - const controller = new ChatComposerController({ - noteCandidateProvider: noteProvider(), - contextReferenceProvider: contextProvider(), - sourcePath: () => "", - stateStore, - viewId: "view", - referenceActiveNoteOnSend: () => false, - sendShortcut: () => "enter", - scrollThreadFromComposerEdges: () => false, - threadScrollFromComposer: vi.fn(), - canInterrupt: (_state) => false, - composerProjection: defaultComposerProjection, - currentModelForSuggestions: () => null, - togglePlan, - toggleAutoReview: vi.fn(), - toggleFast: vi.fn(), - onDraftChange: vi.fn(), - onHeightChange: vi.fn(), - }); + const { controller, parent, stateStore } = composerControllerFixture({ controller: { togglePlan } }); renderComposerController(parent, controller, stateStore); @@ -745,27 +743,8 @@ describe("ChatComposerController", () => { it("delegates submit events through render actions", () => { const stateStore = createChatStateStore(); stateStore.dispatch({ type: "composer/draft-set", draft: "hello" }); - const parent = document.createElement("div"); const submit = vi.fn(); - const controller = new ChatComposerController({ - noteCandidateProvider: noteProvider(), - contextReferenceProvider: contextProvider(), - sourcePath: () => "", - stateStore, - viewId: "view", - referenceActiveNoteOnSend: () => false, - sendShortcut: () => "enter", - scrollThreadFromComposerEdges: () => false, - threadScrollFromComposer: vi.fn(), - canInterrupt: (_state) => false, - composerProjection: defaultComposerProjection, - currentModelForSuggestions: () => null, - togglePlan: vi.fn(), - toggleAutoReview: vi.fn(), - toggleFast: vi.fn(), - onDraftChange: vi.fn(), - onHeightChange: vi.fn(), - }); + const { controller, parent } = composerControllerFixture({ stateStore }); renderComposerController(parent, controller, stateStore, { submit }); composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" })); @@ -774,28 +753,8 @@ describe("ChatComposerController", () => { }); it("scrolls by page from the composer even when line edge scrolling is disabled", () => { - const stateStore = createChatStateStore(); - const parent = document.createElement("div"); const threadScrollFromComposer = vi.fn(); - const controller = new ChatComposerController({ - noteCandidateProvider: noteProvider(), - contextReferenceProvider: contextProvider(), - sourcePath: () => "", - stateStore, - viewId: "view", - referenceActiveNoteOnSend: () => false, - sendShortcut: () => "enter", - scrollThreadFromComposerEdges: () => false, - threadScrollFromComposer, - canInterrupt: (_state) => false, - composerProjection: defaultComposerProjection, - currentModelForSuggestions: () => null, - togglePlan: vi.fn(), - toggleAutoReview: vi.fn(), - toggleFast: vi.fn(), - onDraftChange: vi.fn(), - onHeightChange: vi.fn(), - }); + const { controller, parent, stateStore } = composerControllerFixture({ controller: { threadScrollFromComposer } }); renderComposerController(parent, controller, stateStore); setTextAreaValue(composer(parent), "first\nsecond"); @@ -808,28 +767,8 @@ describe("ChatComposerController", () => { }); it("scrolls to stream edges from the composer even when line edge scrolling is disabled", () => { - const stateStore = createChatStateStore(); - const parent = document.createElement("div"); const threadScrollFromComposer = vi.fn(); - const controller = new ChatComposerController({ - noteCandidateProvider: noteProvider(), - contextReferenceProvider: contextProvider(), - sourcePath: () => "", - stateStore, - viewId: "view", - referenceActiveNoteOnSend: () => false, - sendShortcut: () => "enter", - scrollThreadFromComposerEdges: () => false, - threadScrollFromComposer, - canInterrupt: (_state) => false, - composerProjection: defaultComposerProjection, - currentModelForSuggestions: () => null, - togglePlan: vi.fn(), - toggleAutoReview: vi.fn(), - toggleFast: vi.fn(), - onDraftChange: vi.fn(), - onHeightChange: vi.fn(), - }); + const { controller, parent, stateStore } = composerControllerFixture({ controller: { threadScrollFromComposer } }); renderComposerController(parent, controller, stateStore); setTextAreaValue(composer(parent), "first\nsecond"); @@ -846,28 +785,8 @@ describe("ChatComposerController", () => { }); it("leaves composer line edge scrolling disabled by the setting", () => { - const stateStore = createChatStateStore(); - const parent = document.createElement("div"); const threadScrollFromComposer = vi.fn(); - const controller = new ChatComposerController({ - noteCandidateProvider: noteProvider(), - contextReferenceProvider: contextProvider(), - sourcePath: () => "", - stateStore, - viewId: "view", - referenceActiveNoteOnSend: () => false, - sendShortcut: () => "enter", - scrollThreadFromComposerEdges: () => false, - threadScrollFromComposer, - canInterrupt: (_state) => false, - composerProjection: defaultComposerProjection, - currentModelForSuggestions: () => null, - togglePlan: vi.fn(), - toggleAutoReview: vi.fn(), - toggleFast: vi.fn(), - onDraftChange: vi.fn(), - onHeightChange: vi.fn(), - }); + const { controller, parent, stateStore } = composerControllerFixture({ controller: { threadScrollFromComposer } }); renderComposerController(parent, controller, stateStore); setTextAreaValue(composer(parent), "first\nsecond"); @@ -882,26 +801,7 @@ describe("ChatComposerController", () => { it("clears the Preact-owned textarea ref when the composer unmounts", () => { const stateStore = createChatStateStore(); stateStore.dispatch({ type: "composer/draft-set", draft: "state draft" }); - const parent = document.createElement("div"); - const controller = new ChatComposerController({ - noteCandidateProvider: noteProvider(), - contextReferenceProvider: contextProvider(), - sourcePath: () => "", - stateStore, - viewId: "view", - referenceActiveNoteOnSend: () => false, - sendShortcut: () => "enter", - scrollThreadFromComposerEdges: () => false, - threadScrollFromComposer: vi.fn(), - canInterrupt: (_state) => false, - composerProjection: defaultComposerProjection, - currentModelForSuggestions: () => null, - togglePlan: vi.fn(), - toggleAutoReview: vi.fn(), - toggleFast: vi.fn(), - onDraftChange: vi.fn(), - onHeightChange: vi.fn(), - }); + const { controller, parent } = composerControllerFixture({ stateStore }); renderComposerController(parent, controller, stateStore); const mountedComposer = composer(parent); diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index 6c789f14..93dd4722 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -38,9 +38,7 @@ describe("settings tab", () => { it("auto-loads dynamic sections once and keeps one global refresh button", async () => { const client = settingsClient(); const fetchModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-5.5")])); - withShortLivedAppServerClientMock.mockImplementation( - (_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => operation(client), - ); + useShortLivedClients(client); const tab = newSettingsTab({ fetchModels }); tab.display(); @@ -241,13 +239,7 @@ describe("settings tab", () => { it("refreshes models, hooks, and archived threads from the global refresh button", async () => { const firstClient = settingsClient({ models: [model("gpt-5.4")] }); const secondClient = settingsClient({ models: [model("gpt-5.5")] }); - withShortLivedAppServerClientMock - .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => - operation(firstClient), - ) - .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => - operation(secondClient), - ); + useShortLivedClients(firstClient, secondClient); const fetchModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-5.4")])); const refreshModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-5.5")])); const refreshArchived = vi @@ -282,9 +274,7 @@ describe("settings tab", () => { models: [model("gpt-new")], hooks: [hook({ key: "hook-new", command: "new hook", currentHash: "newhash" })], }); - withShortLivedAppServerClientMock - .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => operation(oldClient)) - .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => operation(newClient)); + useShortLivedClients(oldClient, newClient); const fetchModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-old")])); const refreshModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-new")])); const refreshArchived = vi @@ -329,9 +319,7 @@ describe("settings tab", () => { }); it("subscribes model updates only while the settings tab is displayed", () => { - withShortLivedAppServerClientMock.mockImplementation( - (_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => operation(settingsClient()), - ); + useShortLivedClients(settingsClient()); const unsubscribe = vi.fn(); const observeModels = vi.fn(() => unsubscribe); const tab = newSettingsTab({ observeModels }); @@ -375,13 +363,7 @@ describe("settings tab", () => { const firstModels = deferred(); const firstClient = settingsClient(); const secondClient = settingsClient({ models: [model("gpt-new")] }); - withShortLivedAppServerClientMock - .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => - operation(firstClient), - ) - .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => - operation(secondClient), - ); + useShortLivedClients(firstClient, secondClient); const refreshModels = vi .fn() .mockReturnValueOnce(firstModels.promise) @@ -424,19 +406,7 @@ describe("settings tab", () => { const newerClient = settingsClient({ hooks: [hook({ key: "hook-new", command: "new hook", currentHash: "newhash" })], }); - withShortLivedAppServerClientMock - .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => - operation(initialClient), - ) - .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => - operation(trustClient), - ) - .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => - operation(staleClient), - ) - .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => - operation(newerClient), - ); + useShortLivedClients(initialClient, trustClient, staleClient, newerClient); const controller = new SettingsDynamicSectionsController(settingsTabHost(), { display: vi.fn(), notify: vi.fn() }); await controller.refreshDynamicSections(); @@ -467,16 +437,7 @@ describe("settings tab", () => { const hookReloadClient = settingsClient({ hooks: [hook({ key: "hook-new", command: "new hook", currentHash: "newhash" })], }); - withShortLivedAppServerClientMock - .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => - operation(fullRefreshClient), - ) - .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => - operation(trustClient), - ) - .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => - operation(hookReloadClient), - ); + 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 }), { @@ -507,16 +468,7 @@ describe("settings tab", () => { "thread/unarchive": vi.fn(() => staleRestore.promise), }); const newerClient = settingsClient(); - withShortLivedAppServerClientMock - .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => - operation(initialClient), - ) - .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => - operation(restoreClient), - ) - .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => - operation(newerClient), - ); + useShortLivedClients(initialClient, restoreClient, newerClient); const refreshArchived = vi .fn() .mockResolvedValueOnce([panelThread({ id: "thread-old", preview: "Old archived", archived: true })]) @@ -547,13 +499,7 @@ describe("settings tab", () => { const restoreClient = settingsRequestClient({ "thread/unarchive": vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }), }); - withShortLivedAppServerClientMock - .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => - operation(initialClient), - ) - .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => - operation(restoreClient), - ); + useShortLivedClients(initialClient, restoreClient); const controller = new SettingsDynamicSectionsController( settingsTabHost({ archivedThreads: [panelThread({ id: "thread-old", preview: "Old archived", archived: true })], @@ -581,13 +527,7 @@ describe("settings tab", () => { const restoreClient = settingsRequestClient({ "thread/unarchive": vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }), }); - withShortLivedAppServerClientMock - .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => - operation(initialClient), - ) - .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => - operation(restoreClient), - ); + useShortLivedClients(initialClient, restoreClient); const controllerRef: { current: SettingsDynamicSectionsController | null } = { current: null }; let emitArchived = (_threads: readonly Thread[]): void => undefined; const controller = new SettingsDynamicSectionsController( @@ -628,13 +568,7 @@ describe("settings tab", () => { const deleteClient = settingsRequestClient({ "thread/delete": vi.fn().mockResolvedValue({}), }); - withShortLivedAppServerClientMock - .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => - operation(initialClient), - ) - .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => - operation(deleteClient), - ); + useShortLivedClients(initialClient, deleteClient); const controllerRef: { current: SettingsDynamicSectionsController | null } = { current: null }; let emitArchived = (_threads: readonly Thread[]): void => undefined; const controller = new SettingsDynamicSectionsController( @@ -672,9 +606,7 @@ describe("settings tab", () => { 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")] }); - withShortLivedAppServerClientMock.mockImplementation( - (_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => operation(client), - ); + useShortLivedClients(client); const tab = newSettingsTab({ modelsSnapshot: modelMetadataFromCatalogModels([model("gpt-cached")]), fetchModels }); tab.display(); @@ -690,9 +622,7 @@ describe("settings tab", () => { it("replaces stale cached model options with an empty successful refresh while preserving saved values", async () => { const fetchModels = vi.fn().mockResolvedValue([]); const client = settingsClient({ models: [] }); - withShortLivedAppServerClientMock.mockImplementation( - (_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => operation(client), - ); + useShortLivedClients(client); const tab = newSettingsTab({ modelsSnapshot: modelMetadataFromCatalogModels([model("gpt-cached")]), fetchModels, @@ -738,9 +668,7 @@ describe("settings tab", () => { models: [model("gpt-5.4")], hooksError: new Error("hooks unavailable"), }); - withShortLivedAppServerClientMock.mockImplementation( - (_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => operation(client), - ); + useShortLivedClients(client); const tab = newSettingsTab({ archivedThreads: [panelThread({ preview: "Archived thread", archived: true })] }); tab.display(); @@ -757,9 +685,7 @@ describe("settings tab", () => { const client = settingsClient({ hooks: [hook({ key: "hook-1", command: "node hook.js", currentHash: "abc123", trustStatus: "untrusted" })], }); - withShortLivedAppServerClientMock.mockImplementation( - (_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => operation(client), - ); + useShortLivedClients(client); const tab = newSettingsTab({ archivedThreads: [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })] }); tab.display(); @@ -787,9 +713,7 @@ describe("settings tab", () => { it("confirms archived thread deletion inline and cancels from outside clicks", async () => { const client = settingsClient(); - withShortLivedAppServerClientMock.mockImplementation( - (_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => operation(client), - ); + useShortLivedClients(client); const tab = newSettingsTab({ archivedThreads: [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })] }); tab.display(); @@ -810,9 +734,7 @@ describe("settings tab", () => { it("keeps the settings shell mounted while dynamic sections update", async () => { const saveSettings = vi.fn().mockResolvedValue(undefined); const client = settingsClient(); - withShortLivedAppServerClientMock.mockImplementation( - (_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => operation(client), - ); + useShortLivedClients(client); const tab = newSettingsTab({ saveSettings, archivedThreads: [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })], @@ -845,9 +767,7 @@ describe("settings tab", () => { const client = settingsClient({ hooks: [hook({ key: "hook-1", command: "node hook.js", currentHash: "abc123", enabled: true })], }); - withShortLivedAppServerClientMock.mockImplementation( - (_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => operation(client), - ); + useShortLivedClients(client); const hookUpdate = deferred(); client.requestHandlers["config/batchWrite"]?.mockReturnValue(hookUpdate.promise); const tab = newSettingsTab({ archivedThreads: [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })] }); @@ -888,9 +808,7 @@ describe("settings tab", () => { it("permanently deletes an archived thread from the confirmed settings row", async () => { const client = settingsClient(); - withShortLivedAppServerClientMock.mockImplementation( - (_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => operation(client), - ); + useShortLivedClients(client); const deleteRequest = deferred(); client.requestHandlers["thread/delete"]?.mockReturnValue(deleteRequest.promise); const tab = newSettingsTab({ archivedThreads: [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })] }); @@ -1027,6 +945,23 @@ type SettingsRequestClient = AppServerClient & { requestHandlers: Record unknown>>>; }; +function useShortLivedClients(...clients: SettingsRequestClient[]): void { + const runWithClient = (client: SettingsRequestClient, operation: (client: AppServerClient) => Promise) => 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) => runWithClient(client, operation), + ); + return; + } + for (const client of clients) { + withShortLivedAppServerClientMock.mockImplementationOnce( + (_codexPath: string, _cwd: string, operation: (client: AppServerClient) => Promise) => runWithClient(client, operation), + ); + } +} + function settingsRequestClient( handlers: Record unknown>>>, ): SettingsRequestClient {