mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Refactor high-noise test setup
This commit is contained in:
parent
be63c6171d
commit
eeefedfc8f
3 changed files with 150 additions and 324 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,32 +29,50 @@ function renderComposerController(
|
|||
renderUiRoot(parent, h(ComposerShell, controller.renderState(composerReadModelFromChatState(stateStore.getState()), actions)));
|
||||
}
|
||||
|
||||
type ComposerControllerOptions = ConstructorParameters<typeof ChatComposerController>[0];
|
||||
|
||||
function composerControllerFixture(
|
||||
options: { stateStore?: ChatStateStore; controller?: Partial<ComposerControllerOptions>; renderActions?: ChatComposerRenderActions } = {},
|
||||
): {
|
||||
controller: ChatComposerController;
|
||||
parent: HTMLElement;
|
||||
renderShell: ReturnType<typeof vi.fn>;
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<unknown>) => 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<unknown>) =>
|
||||
operation(firstClient),
|
||||
)
|
||||
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
|
||||
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<unknown>) => operation(oldClient))
|
||||
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) => 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<unknown>) => 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<ModelMetadata[]>();
|
||||
const firstClient = settingsClient();
|
||||
const secondClient = settingsClient({ models: [model("gpt-new")] });
|
||||
withShortLivedAppServerClientMock
|
||||
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
|
||||
operation(firstClient),
|
||||
)
|
||||
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
|
||||
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<unknown>) =>
|
||||
operation(initialClient),
|
||||
)
|
||||
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
|
||||
operation(trustClient),
|
||||
)
|
||||
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
|
||||
operation(staleClient),
|
||||
)
|
||||
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
|
||||
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<unknown>) =>
|
||||
operation(fullRefreshClient),
|
||||
)
|
||||
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
|
||||
operation(trustClient),
|
||||
)
|
||||
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
|
||||
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<unknown>) =>
|
||||
operation(initialClient),
|
||||
)
|
||||
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
|
||||
operation(restoreClient),
|
||||
)
|
||||
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
|
||||
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<unknown>) =>
|
||||
operation(initialClient),
|
||||
)
|
||||
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
|
||||
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<unknown>) =>
|
||||
operation(initialClient),
|
||||
)
|
||||
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
|
||||
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<unknown>) =>
|
||||
operation(initialClient),
|
||||
)
|
||||
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
|
||||
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<unknown>) => 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<unknown>) => 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<unknown>) => 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<unknown>) => 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<unknown>) => 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<unknown>) => 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<unknown>) => operation(client),
|
||||
);
|
||||
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 })] });
|
||||
|
|
@ -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<unknown>) => operation(client),
|
||||
);
|
||||
useShortLivedClients(client);
|
||||
const deleteRequest = deferred<undefined>();
|
||||
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<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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue