fix(studio): opening the model panel reads the model list once, and the settings, composer and plan tests follow the deliberate structure of the settings nav folds, the account page behind its pill, the scope tag, the merged model and effort button and the send button staying beside stop

This commit is contained in:
yhh committed 2026-11-07 00:42:50 -08:00
1 parent e386c45525
commit 6fd42831e2
15 files changed
+50 -47

No files matched your search

@@ -48,14 +48,14 @@ describe("composer model tooltip", () => {
view.rerender(<Composer {...props} pulse={1} status={status({ modelRef: models[1].ref })} />);
expect(screen.getByRole("button", { name: /plain-v2/ }).title).toBe("Other account / plain-v2");
view.rerender(<Composer {...props} pulse={1} status={status({ modelRef: "missing/vendor/model" })} />);
expect(screen.getByRole("button", { name: "vendor/model" }).title).toBe("missing/vendor/model");
expect(screen.getByRole("button", { name: /^vendor\/model/ }).title).toMatch(/^missing\/vendor\/model/);
});
it("uses canonical provider naming when aliases are blank", async () => {
const port = new MockPort();
vi.spyOn(port, "models").mockResolvedValue([{ ref: "local/model", provider: "Local account", model: "model", displayName: " ", modelAlias: " " }]);
draw({ port, st: status({ modelRef: "local/model" }) });
expect((await screen.findByRole("button", { name: "model" })).title).toBe("Local account / model");
expect((await screen.findByRole("button", { name: /^model/ })).title).toBe("Local account / model");
});
});
@@ -342,26 +342,26 @@ describe("composer drafts", () => {
});
describe("composer run controls", () => {
it.each([false, true])("shows steering only with content during a live turn (pending ask: %s)", async (asking) => {
it.each([false, true])("enables steering only with content during a live turn (pending ask: %s)", async (asking) => {
const port = new MockPort();
const cancel = vi.spyOn(port, "cancel").mockResolvedValue();
const onSubmit = vi.fn(async () => true);
const { box } = draw({ port, running: true, onSubmit,
st: status({ running: true, decisions: asking ? [{ id: "ask-1", kind: "ask" }] : [] }),
});
expect(screen.queryByRole("button", { name: "插话" })).toBeNull();
expect((screen.getByRole("button", { name: "插话" }) as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByRole("button", { name: "停下" }) as HTMLButtonElement).disabled).toBe(false);
fireEvent.change(box, { target: { value: " " } });
expect(screen.queryByRole("button", { name: "插话" })).toBeNull();
expect((screen.getByRole("button", { name: "插话" }) as HTMLButtonElement).disabled).toBe(true);
fireEvent.change(box, { target: { value: "Check" } });
expect((screen.getByRole("button", { name: "插话" }) as HTMLButtonElement).disabled).toBe(false);
expect(screen.getByRole("button", { name: "停下" })).toBeTruthy();
fireEvent.change(box, { target: { value: "" } });
expect(screen.queryByRole("button", { name: "插话" })).toBeNull();
expect((screen.getByRole("button", { name: "插话" }) as HTMLButtonElement).disabled).toBe(true);
fireEvent.change(box, { target: { value: "先检查日志", selectionStart: 5 } });
fireEvent.click(screen.getByRole("button", { name: "插话" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("先检查日志"));
await waitFor(() => expect(screen.queryByRole("button", { name: "插话" })).toBeNull());
await waitFor(() => expect((screen.getByRole("button", { name: "插话" }) as HTMLButtonElement).disabled).toBe(true));
fireEvent.click(screen.getByRole("button", { name: "停下" }));
await waitFor(() => expect(cancel).toHaveBeenCalledTimes(1));
});
@@ -404,7 +404,7 @@ describe("composer run controls", () => {
expect((pending as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(pending);
expect(cancel).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("button", { name: "插话" })).toBeNull();
expect((screen.getByRole("button", { name: "插话" }) as HTMLButtonElement).disabled).toBe(true);
view.rerender(<Composer {...props} running={false} />);
await waitFor(() => expect(screen.queryByRole("button", { name: "正在停止…" })).toBeNull());
});
@@ -482,14 +482,13 @@ describe("composer menus", () => {
onSubmit: async () => true, onChanged, onError: vi.fn() };
const view = render(<Composer {...props} />);
fireEvent.click(view.container.querySelector('button.model-picker')!);
await waitFor(() => expect(screen.getAllByRole("menuitem")).toHaveLength(4));
await waitFor(() => expect(screen.getAllByRole("menuitem").filter((el) => !el.closest(".menu-aside"))).toHaveLength(4));
expect(screen.getAllByRole("menuitem")[0].getAttribute("data-value")).toBe("beta/third");
expect(screen.getAllByRole("menuitem")[0].hasAttribute("data-on")).toBe(true);
fireEvent.click(screen.getAllByRole("menuitem").find((el) => el.getAttribute("data-value") === "alpha/second")!);
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce());
expect(setModel).toHaveBeenCalledExactlyOnceWith("alpha/second");
view.rerender(<Composer {...props} status={status({ modelRef: "alpha/second" })} />);
fireEvent.click(view.container.querySelector('button.model-picker')!);
expect(screen.getAllByRole("menuitem")[0].getAttribute("data-value")).toBe("alpha/second");
expect(screen.getAllByRole("menuitem")[0].hasAttribute("data-on")).toBe(true);
expect(screen.getAllByRole("menuitem").filter((el) => el.getAttribute("data-value") === "alpha/second")).toHaveLength(1);
@@ -19,19 +19,19 @@ it("keeps account headings, visible hosts and row metadata with checks off and o
const probe = vi.spyOn(port, "providerHealth").mockResolvedValue({ connection: "reachable", wallet: "available", checkedAt: 1 });
const props = { port, status: { modelRef: "a/current" } as SessionStatus, running: false, onSubmit: async () => true as const, onChanged: vi.fn(), onError: vi.fn() };
const view = render(<Composer {...props} pulse={0} />);
fireEvent.click(await screen.findByRole("button", { name: "current" }));
fireEvent.click(await screen.findByRole("button", { name: /^current/ }));
const menu = screen.getByRole("menu");
expect(menu.classList.contains("compact-model-menu")).toBe(false);
expect(within(menu).getByText("one.example")).toBeTruthy();
expect(within(menu).getByText("two.example")).toBeTruthy();
expect(within(menu).getAllByRole("menuitem")).toHaveLength(4);
expect(within(menu).getAllByRole("menuitem").filter((it) => !it.closest(".menu-aside"))).toHaveLength(4);
expect(probe).not.toHaveBeenCalled();
fireEvent.keyDown(menu, { key: "Escape" });
await port.saveProviderHealthSettings(true);
view.rerender(<Composer {...props} pulse={1} />);
fireEvent.click(screen.getByRole("button", { name: "current" }));
fireEvent.click(screen.getByRole("button", { name: /^current/ }));
await within(screen.getByRole("menu")).findAllByText(t("models.connected"));
expect(screen.getByRole("menu").querySelectorAll(".mi.head .rt")).toHaveLength(2);
expect(screen.getByRole("menu").querySelectorAll(".mi.head .rt:not(.menu-aside *)")).toHaveLength(2);
expect(screen.getByRole("menuitem", { name: /second/ }).textContent).toContain(t("OpenAI 兼容"));
expect(screen.getByRole("menuitem", { name: /second/ }).textContent).not.toContain("Gateway");
});
@@ -49,7 +49,7 @@ it("checks on opening, shows wallet failures and keeps selection and management
const pick = vi.spyOn(port, "setModel").mockResolvedValue();
const settings = vi.fn();
render(<Composer port={port} status={{ modelRef: "one/alpha" } as SessionStatus} running={false} onSubmit={async () => true} onChanged={vi.fn()} onError={vi.fn()} onSettings={settings} />);
const trigger = await screen.findByRole("button", { name: "alpha" });
const trigger = await screen.findByRole("button", { name: /^alpha/ });
expect(check).not.toHaveBeenCalled();
fireEvent.click(trigger);
await screen.findAllByText(/Insufficient balance|余额不足/);
@@ -58,7 +58,6 @@ it("checks on opening, shows wallet failures and keeps selection and management
expect(screen.getByRole("menuitem", { name: /alpha/ }).hasAttribute("data-on")).toBe(true);
fireEvent.click(screen.getByRole("menuitem", { name: /gamma/ }));
await waitFor(() => expect(pick).toHaveBeenCalledWith("two/gamma"));
fireEvent.click(trigger);
fireEvent.click(screen.getByRole("menuitem", { name: /Manage models and connections|管理模型与连接/ }));
expect(settings).toHaveBeenCalledWith("providers");
});
@@ -75,7 +74,7 @@ it("ignores a late health result after switching runtime", async () => {
vi.spyOn(port, "providerHealth").mockResolvedValue({ connection: "reachable", wallet: "available", checkedAt: Date.now() });
const props = { status: { modelRef: "one/alpha" } as SessionStatus, running: false, onSubmit: async () => true as const, onChanged: vi.fn(), onError: vi.fn() };
const view = render(<Composer {...props} port={oldPort} />);
fireEvent.click(await screen.findByRole("button", { name: "alpha" }));
fireEvent.click(await screen.findByRole("button", { name: /^alpha/ }));
await waitFor(() => expect(finish).toBeDefined());
view.rerender(<Composer {...props} port={port} />);
await act(async () => { finish({ connection: "auth", wallet: "insufficient", checkedAt: Date.now() }); });
@@ -92,7 +91,7 @@ it("limits background checks, tolerates older servers and excludes extension mod
const pending: Array<() => void> = [];
const check = vi.spyOn(port, "providerHealth").mockImplementation(() => new Promise((_, reject) => { pending.push(() => reject(new Error("old server"))); }));
render(<Composer port={port} status={{ modelRef: "one/alpha" } as SessionStatus} running={false} onSubmit={async () => true} onChanged={vi.fn()} onError={vi.fn()} />);
fireEvent.click(await screen.findByRole("button", { name: "alpha" }));
fireEvent.click(await screen.findByRole("button", { name: /^alpha/ }));
await waitFor(() => expect(check).toHaveBeenCalledTimes(3));
await act(async () => { pending[0](); });
await waitFor(() => expect(check).toHaveBeenCalledTimes(4));
+2 -1
View File
@@ -696,6 +696,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
<Picker
className="mode studio-mode-picker"
triggerAction="plan.mode"
data-action="plan.mode"
ariaPressed={status?.plan ?? false}
place="bottom"
title={t("composer.work_mode")}
@@ -738,7 +739,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
searchAlways
searchPlaceholder={t("composer.search_models_or_providers")}
menuTitle={<><b>{t("composer.choose_model")}</b><small>{t("composer.for_future_tasks")}</small></>}
onOpen={() => { void modelHealth.check(loadModels()); loadModels(); }}
onOpen={() => { void modelHealth.check(loadModels()); }}
pending={busy["model"] || busy["effort"] || busy["mode"]}
asideClassName="studio-effort-menu"
stayOpen={(ref) => ref !== "__manage-models"}
@@ -25,6 +25,6 @@ describe("the context reading", () => {
it("turns warm from 80% so the limit is seen coming, with no dialog", () => {
const { container } = rail(86);
expect(level(container)).toBe("high");
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(container.querySelector('[data-open] [role="dialog"]')).toBeNull();
});
});
@@ -58,7 +58,7 @@ describe("the model and its reasoning effort share one button and one panel", ()
const set = vi.spyOn(port, "setModel");
const { container } = draw(status(), port);
await open(container);
fireEvent.click(document.querySelector('.portmenu > [data-action="model.select"][data-value="deepseek/deepseek-flash"]')!);
fireEvent.click(document.querySelector('.portmenu [data-action="model.select"][data-value="deepseek/deepseek-flash"]')!);
await waitFor(() => expect(set).toHaveBeenCalledWith("deepseek/deepseek-flash"));
expect((document.querySelector(".portmenu.has-aside") as HTMLElement).hidden).toBe(false);
});
@@ -28,7 +28,8 @@ describe("optional progress below the collapsed plan", () => {
const settings = render(<PlanProgressSection />);
const cards = render(<><PlanFold plan={plan} shown /><PlanFold plan={plan} shown /></>);
expect(screen.queryAllByRole("progressbar")).toHaveLength(0);
await userEvent.click(within(settings.container).getByRole("button", { name: "显示" }));
const toggle = within(settings.container).getByRole("switch");
await userEvent.click(toggle);
expect(localStorage.getItem("rx-plan-progress")).toBe("on");
expect(screen.getAllByRole("progressbar")).toHaveLength(2);
const progress = screen.getAllByRole("progressbar")[0];
@@ -39,7 +40,7 @@ describe("optional progress below the collapsed plan", () => {
cards.unmount();
render(<PlanFold plan={plan} shown />);
expect(screen.getByRole("progressbar")).toBeTruthy();
await userEvent.click(within(settings.container).getByRole("button", { name: "关闭" }));
await userEvent.click(toggle);
expect(screen.queryByRole("progressbar")).toBeNull();
expect(localStorage.getItem("rx-plan-progress")).toBe("off");
});
@@ -58,7 +58,8 @@ it("draws a block that names its action as a setting row with a pill, and opens
</SettingDeepContext.Provider>,
);
const row = document.querySelector('.srow[data-setting="rules"]')!;
expect(row.querySelector(".srow-name")?.textContent).toBe("明确的规则");
expect(row.querySelector(".srow-name")?.firstChild?.textContent).toBe("明确的规则");
expect(row.querySelector(".srow-name .scope-tag")?.textContent).toBe("重建运行时");
expect(row.querySelector(".srow-desc")?.textContent).toBe("哪些操作始终禁止");
expect(row.querySelector(".apply")?.textContent).toContain("本机");
fireEvent.click(screen.getByRole("button", { name: "编辑规则(3 条)" }));
@@ -117,11 +117,12 @@ describe("the emphasised action while a turn runs", () => {
expect(primary?.textContent).toBe("停下");
});
it("shows only stop during a live turn with an empty draft", () => {
it("offers stop and a disabled steer during a live turn with an empty draft", () => {
const idle = draw().container.querySelector(".go .btn:last-child span:last-child")?.textContent;
cleanup();
const live = draw(status(), true).container.querySelector(".go .btn:last-child span:last-child")?.textContent;
expect([idle, live]).toEqual(["发送", "停下"]);
const { container } = draw(status(), true);
expect([idle, container.querySelector(".go .btn.stop span:last-child")?.textContent]).toEqual(["发送", "停下"]);
expect(container.querySelector<HTMLButtonElement>(".go .btn.send")?.disabled).toBe(true);
});
it("puts it back on sending once the turn is over", () => {
@@ -75,6 +75,7 @@ async function fixture(strict: boolean, initial: AccountState, target: AccountSt
render(strict ? <StrictMode>{form}</StrictMode> : form);
const user = userEvent.setup();
const requests = (path: string, method?: string) => fetcher.mock.calls.filter(([url, init]) => url === path && (!method || init?.method === method));
await user.click(document.querySelector<HTMLButtonElement>('[data-action="settings.open-page"][data-target="account"]')!);
if (!initial.signedIn) {
await user.click(screen.getByRole("button", { name: "登录" }));
await waitFor(() => expect(requests("/kernel/account/poll")).toHaveLength(1), { timeout: 2500 });
@@ -95,8 +96,7 @@ async function fixture(strict: boolean, initial: AccountState, target: AccountSt
const settle = async () => {
await act(async () => transition.resolve());
await waitFor(() => expect(requests("/kernel/account").length).toBeGreaterThan(0));
expect(document.querySelector('[data-action="settings.section"][data-value="account"]')?.textContent)
.toContain(target.signedIn ? target.user?.label ?? "已登录" : "未登录");
if (target.user || !target.signedIn) await waitFor(() => expect(document.querySelector(".mkt-signin") === null).toBe(target.signedIn));
};
return { user, port, pkg, requests, votePath, readReplies, writeReplies, openMarket, settle, list, detail, install };
}
@@ -22,12 +22,12 @@ function draw(port = new MockPort()) {
it.each(["close", "navigation", "search"])("protects a draft on settings %s", async (destination) => {
const user = userEvent.setup();
const { onClose } = draw();
expect(screen.getByRole("tab", { name: "人设" })).toBeTruthy();
expect(screen.getByRole("tab", { name: /^Agent 行为/ })).toBeTruthy();
await user.click(await screen.findByRole("button", { name: "新建预设" }));
await user.type(screen.getByLabelText("预设名称"), "保留草稿");
const leave = async () => {
if (destination === "close") await user.click(screen.getByRole("button", { name: /关闭.*Esc/ }));
else if (destination === "navigation") await user.click(screen.getAllByRole<HTMLButtonElement>("tab").find(el => el.id === "prefs-session")!);
else if (destination === "navigation") await user.click(screen.getAllByRole<HTMLButtonElement>("tab").find(el => el.id === "prefs-model")!);
else {
const search = screen.getByRole("textbox", { name: "搜索设置" });
await user.clear(search);
@@ -83,7 +83,7 @@ describe("the search result, which is where a setting has no page around it", ()
expect(src("Settings.tsx")).toMatch(/import \{ Group, SCOPE_SAID \} from "\.\/Group"/);
// One table, defined once. A second copy is how the block and the result
// come to disagree about what a setting owns.
expect(src("Group.tsx").match(/SCOPE_SAID[^=]*=/g)?.length ?? 0).toBe(1);
expect(src("Group.tsx").match(/const SCOPE_SAID[^=]*=/g)?.length ?? 0).toBe(1);
});
// The words live in one place. Checked on the taxonomy's own ids rather than
@@ -122,9 +122,9 @@ it("cancels a pending landing when the user chooses another category", async ()
let finish!: (value: typeof saved) => void;
vi.spyOn(port, "observabilitySettings").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
draw("account:trace-export", port);
await userEvent.click(screen.getByRole("tab", { name: /^版本/ }));
await userEvent.click(screen.getByRole("tab", { name: /^数据/ }));
await act(async () => { finish(saved); });
await userEvent.click(screen.getByRole("tab", { name: /^网络/ }));
await userEvent.click(screen.getByRole("tab", { name: /^账号/ }));
const target = await waitFor(() => {
const found = document.querySelector<HTMLElement>('[data-setting="trace-export"]');
expect(found).toBeTruthy();
@@ -64,7 +64,7 @@ describe("finding a setting", () => {
const { find, block } = draw();
await userEvent.type(find(), "todo");
const hit = await screen.findByRole("option", { name: /收起时显示计划进度/ });
expect(hit.getAttribute("data-value")).toBe("appearance");
expect(hit.getAttribute("data-value")).toBe("display");
expect(hit.getAttribute("data-target")).toBe("plan-progress");
await userEvent.click(hit);
await waitFor(() => expect(block("plan-progress")).toBeTruthy());
@@ -72,19 +72,21 @@ describe("finding a setting", () => {
expect(section.closest("[hidden]")).toBeNull();
expect(section.querySelector("[data-apply]")?.getAttribute("data-apply")).toBe("immediate");
expect(section.querySelector("[data-scope]")?.getAttribute("data-scope")).toBe("machine");
await userEvent.click(section.querySelector<HTMLButtonElement>('[data-value="on"]')!);
expect(localStorage.getItem("rx-plan-progress")).toBe("on");
await userEvent.click(section.querySelector<HTMLButtonElement>('[data-value="off"]')!);
const toggle = section.querySelector<HTMLButtonElement>('[role="switch"]')!;
expect(toggle.getAttribute("aria-checked")).toBe("true");
await userEvent.click(toggle);
expect(localStorage.getItem("rx-plan-progress")).toBe("off");
await userEvent.click(toggle);
expect(localStorage.getItem("rx-plan-progress")).toBe("on");
});
it("finds file delivery by its config key and opens its switch under tools", async () => {
it("finds file delivery by its config key and opens its switch under conversation display", async () => {
const { find, block } = draw();
await userEvent.type(find(), "tools.present_files");
const hit = await screen.findByRole("option", { name: /AI 文件交付卡片/ });
expect(hit.getAttribute("data-value")).toBe("tools");
expect(hit.getAttribute("data-value")).toBe("display");
await userEvent.click(hit);
await waitFor(() => expect(block("present-files")).toBeTruthy());
expect(block("present-files")?.previousElementSibling?.getAttribute("data-setting")).toBe("approval");
expect(block("present-files")?.previousElementSibling?.getAttribute("data-setting")).toBe("changed-files");
expect((await screen.findByRole("switch", { name: "启用 AI 文件交付卡片" })).getAttribute("aria-checked")).toBe("false");
expect(block("present-files")?.querySelector("[data-apply]")?.getAttribute("data-apply")).toBe("on-open");
expect(screen.queryByRole("switch", { name: "启用内置浏览器" })).toBeNull();
@@ -26,10 +26,10 @@ const watch = async (newer: boolean) => {
};
describe("the update mark in the settings list", () => {
it("sits on the versions row, where the window's gear points", async () => {
it("sits on the account row, which versions folds into, where the window's gear points", async () => {
const stop = await watch(true);
render(settings(new MockPort()));
const row = (await screen.findAllByRole("tab")).find((el) => el.getAttribute("data-value") === "versions");
const row = (await screen.findAllByRole("tab")).find((el) => el.getAttribute("data-value") === "account");
expect(row?.querySelector(".upd-dot")).not.toBeNull();
expect(row?.textContent).toContain("有新版本 2.21.0");
for (const other of screen.getAllByRole("tab").filter((el) => el !== row)) expect(other.querySelector(".upd-dot")).toBeNull();
@@ -134,18 +134,17 @@ it("changes the real paint through appearance controls and leaves contrast, pale
}
const view = render(<Picker />);
expect(screen.queryByLabelText(t("深色开始时间"))).toBeNull();
fireEvent.click(screen.getByRole("button", { name: t("定时") }));
fireEvent.click(screen.getByRole("radio", { name: t("定时") }));
view.rerender(<Picker />);
expect(result.current.scheme).toBe("dark");
fireEvent.change(screen.getByLabelText(t("深色开始时间")), { target: { value: "22:00" } });
expect(result.current.scheme).toBe("light");
expect(screen.getByLabelText(t("恢复浅色时间"))).toBeTruthy();
expect(screen.getByRole("group", { name: t("look.interface_theme") })).toBeTruthy();
fireEvent.click(view.container.querySelector(".appearance-advanced summary")!);
fireEvent.click(screen.getByRole("button", { name: t("更强") }));
fireEvent.click(screen.getByRole("radio", { name: t("更强") }));
view.rerender(<Picker />);
expect(result.current.contrast).toBe("strong");
fireEvent.click(screen.getByRole("button", { name: t("深色") }));
fireEvent.click(screen.getByRole("radio", { name: t("深色") }));
view.rerender(<Picker />);
expect(result.current.scheme).toBe("dark");
expect(screen.queryByLabelText(t("深色开始时间"))).toBeNull();