Files
cc-switch/tests/hooks/useDraftEditorProjection.test.tsx
f9db9f7056 feat(codex): support GitHub Copilot managed accounts with Responses/Chat routing (#7157)
Add a GitHub Copilot preset for Codex that uses a managed GitHub account.
The local route resolves each request's model against the account's live
Copilot /models catalogue and picks the upstream protocol the model
advertises (native Responses first, Chat Completions as fallback in auto
mode), with an optional explicit protocol override.

- Response handling, the opaque-state rectifier and Stack web_search
  stripping follow the transport actually used for each request rather
  than the stored apiFormat.
- Fetching models fills the catalogue from the account's live prompt
  limit, parallel tool calls and reasoning levels; presets keep
  conservative fallbacks.
- Codex only treats a card as managed Copilot by its provider type, so
  self-hosted relays keep their own key and endpoint.
- Copilot request identity headers are shared between the Claude and
  Codex paths.

Co-authored-by: yuwk <52391318+ump45nose@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-10-08 00:10:45 +08:00

189 lines
5.7 KiB
TypeScript

import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
useDraftEditorProjection,
type EditorBaseChange,
} from "@/components/providers/forms/hooks/useDraftEditorProjection";
import type { AppId } from "@/lib/api";
import type { ProviderMeta } from "@/types";
const getEditorView = vi.fn();
const toastError = vi.fn();
const copilotMeta: ProviderMeta = {
providerType: "github_copilot",
apiFormat: "openai_responses",
codexCopilotApiFormat: "openai_responses",
};
vi.mock("@/lib/api", () => ({
providersApi: {
getEditorView: (...args: unknown[]) => getEditorView(...args),
},
}));
vi.mock("sonner", () => ({
toast: { error: (...args: unknown[]) => toastError(...args) },
}));
const deferred = <T,>() => {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
describe("useDraftEditorProjection", () => {
beforeEach(() => {
getEditorView.mockReset();
toastError.mockReset();
});
it("连续切换预设时只认最后一次投影", async () => {
const first = deferred<{ settings: Record<string, unknown> }>();
const second = deferred<{ settings: Record<string, unknown> }>();
getEditorView
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const onBase = vi.fn();
const apply = vi.fn();
const { result } = renderHook(() =>
useDraftEditorProjection("codex", onBase),
);
act(() => {
result.current.projectDraft(
{ config: "a" },
undefined,
apply,
copilotMeta,
);
result.current.projectDraft({ config: "b" }, "official", apply);
});
expect(getEditorView).toHaveBeenNthCalledWith(
1,
"codex",
{ config: "a" },
undefined,
undefined,
copilotMeta,
);
expect(getEditorView).toHaveBeenLastCalledWith(
"codex",
{ config: "b" },
"official",
undefined,
undefined,
);
await act(async () => {
second.resolve({ settings: { config: "B" } });
await second.promise;
});
await act(async () => {
first.resolve({ settings: { config: "A" } });
await first.promise;
});
expect(apply).toHaveBeenCalledTimes(1);
expect(apply).toHaveBeenCalledWith({ config: "B" });
// 底和投影成它的草稿一起交出去,保存时后端按草稿分开预设带的字段。
expect(onBase).toHaveBeenLastCalledWith({ config: "B" }, { config: "b" });
});
it("投影失败时提示并保持没有底", async () => {
const failing = deferred<{ settings: Record<string, unknown> }>();
getEditorView.mockReturnValueOnce(failing.promise);
const onBase = vi.fn();
const apply = vi.fn();
const { result } = renderHook(() =>
useDraftEditorProjection("gemini", onBase),
);
act(() => {
result.current.projectDraft({ env: {} }, undefined, apply);
});
await act(async () => {
failing.reject(new Error("broken settings.json"));
await failing.promise.catch(() => undefined);
});
expect(apply).not.toHaveBeenCalled();
expect(onBase).toHaveBeenCalledTimes(1);
expect(onBase).toHaveBeenCalledWith(null);
expect(toastError).toHaveBeenCalledTimes(1);
});
it("作废后迟到的投影不再生效", async () => {
const pending = deferred<{ settings: Record<string, unknown> }>();
getEditorView.mockReturnValueOnce(pending.promise);
const onBase = vi.fn();
const apply = vi.fn();
const { result } = renderHook(() =>
useDraftEditorProjection("grokbuild", onBase),
);
act(() => {
result.current.projectDraft({ config: "x" }, undefined, apply);
result.current.clearDraftProjection();
});
await act(async () => {
pending.resolve({ settings: { config: "X" } });
await pending.promise;
});
expect(apply).not.toHaveBeenCalled();
expect(onBase).toHaveBeenLastCalledWith(null);
});
it("没有接收方时不发请求(编辑对话框等场景)", () => {
const { result } = renderHook(() => useDraftEditorProjection("codex"));
act(() => {
result.current.projectDraft({ config: "x" }, undefined, vi.fn());
});
expect(getEditorView).not.toHaveBeenCalled();
});
it.each(["app change", "receiver change", "unmount"])(
"discards a pending Copilot projection after a form lifecycle %s",
async (lifecycle) => {
const pending = deferred<{ settings: Record<string, unknown> }>();
getEditorView.mockReturnValueOnce(pending.promise);
const onBase = vi.fn();
const apply = vi.fn();
const initialProps: { appId: AppId; onBase?: EditorBaseChange } = {
appId: "codex",
onBase,
};
const { result, rerender, unmount } = renderHook(
({ appId, onBase }) => useDraftEditorProjection(appId, onBase),
{ initialProps },
);
act(() => {
result.current.projectDraft(
{ config: "copilot" },
"third_party",
apply,
copilotMeta,
);
});
if (lifecycle === "app change") {
rerender({ ...initialProps, appId: "gemini" });
} else if (lifecycle === "receiver change") {
rerender({ ...initialProps, onBase: undefined });
} else {
unmount();
}
await act(async () => {
pending.resolve({ settings: { config: "old Copilot projection" } });
await pending.promise;
});
expect(apply).not.toHaveBeenCalled();
expect(onBase).toHaveBeenCalledTimes(1);
expect(onBase).toHaveBeenCalledWith(null);
},
);
});