From 215dedc5b4ab2d44b28dd8e179bb9eacca8642e1 Mon Sep 17 00:00:00 2001 From: xiexian <3468714225@qq.com> Date: Mon, 5 Oct 2026 19:35:41 +0800 Subject: [PATCH] fix(studio): hold navigation during market publication --- desktop/frontend-next/src/ui/Market.tsx | 2 +- .../frontend-next/src/ui/MarketPublish.tsx | 9 +- ...ttings-market-publish.interaction.test.tsx | 254 ++++++++++++++++++ 3 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 desktop/frontend-next/src/ui/settings-market-publish.interaction.test.tsx diff --git a/desktop/frontend-next/src/ui/Market.tsx b/desktop/frontend-next/src/ui/Market.tsx index b9bb201bc2..63d3a3890b 100644 --- a/desktop/frontend-next/src/ui/Market.tsx +++ b/desktop/frontend-next/src/ui/Market.tsx @@ -404,7 +404,7 @@ export function MarketGroup({ port, onInstalled, onViewInstalled, account, onSig if (!handle) return; setDraft({ port, handle, pkg }); setView("publish"); }} />} - {at === "publish" && handle && { setDraft(null); setView("mine"); }} />} + {at === "publish" && handle && { setDraft(null); setView("mine"); }} />} ); } diff --git a/desktop/frontend-next/src/ui/MarketPublish.tsx b/desktop/frontend-next/src/ui/MarketPublish.tsx index 6e7f7074bf..57757e6d85 100644 --- a/desktop/frontend-next/src/ui/MarketPublish.tsx +++ b/desktop/frontend-next/src/ui/MarketPublish.tsx @@ -41,7 +41,7 @@ interface Draft { const EMPTY: Draft = { kind: "skill", name: "", source: "", summary: "", description: "", repoUrl: "", version: "", tags: [], tagInput: "", private: false }; -interface PublishProps { port: AgentPort; handle: string; onMine: () => void; initial?: MarketPackage } +interface PublishProps { port: AgentPort; handle: string; onMine: () => void; onApplying?: (applying: boolean) => void; initial?: MarketPackage } export function PublishForm(props: PublishProps) { const [owner, setOwner] = useState({ port: props.port, handle: props.handle, generation: 0 }); @@ -53,7 +53,7 @@ export function PublishForm(props: PublishProps) { // The form only collects; which sources are publishable and what the registry // accepts are the kernel's and the registry's answers, shown as they come back. -function PublishDraft({ port, handle, onMine, initial }: PublishProps) { +function PublishDraft({ port, handle, onMine, onApplying, initial }: PublishProps) { const [d, setD] = useState(() => initial ? { ...EMPTY, kind: initial.kind, name: initial.name, summary: initial.summary, description: initial.description, repoUrl: initial.repoUrl, tags: [...initial.tags], @@ -64,6 +64,11 @@ function PublishDraft({ port, handle, onMine, initial }: PublishProps) { const [done, setDone] = useState(null); const set = (k: keyof Draft) => (e: { target: { value: string } }) => setD({ ...d, [k]: e.target.value }); + useEffect(() => { + onApplying?.(busy); + return () => onApplying?.(false); + }, [busy, onApplying]); + const submit = async () => { setBusy(true); setError(""); diff --git a/desktop/frontend-next/src/ui/settings-market-publish.interaction.test.tsx b/desktop/frontend-next/src/ui/settings-market-publish.interaction.test.tsx new file mode 100644 index 0000000000..a5857bccbc --- /dev/null +++ b/desktop/frontend-next/src/ui/settings-market-publish.interaction.test.tsx @@ -0,0 +1,254 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; +import { StrictMode } from "react"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "./testkit"; +import { Settings } from "./Settings"; +import { MarketGroup } from "./Market"; +import { MockPort } from "../port/mock"; +import { MockHub } from "../port/mock_hub"; +import { boot, STORAGE, t } from "../i18n"; +import type { AgentPort, MarketPlan, MarketPublished, SessionStatus } from "../port/port"; + +afterEach(() => { cleanup(); vi.restoreAllMocks(); localStorage.setItem(STORAGE, "zh"); boot(); }); + +const account = (handle = "demo") => ({ signedIn: true, user: { handle, email: `${handle}@example.com`, label: handle } }); +const field = (label: string) => screen.getByLabelText(new RegExp(`^${t(label)}`)); +const published = () => document.querySelector('.mkt-pub[data-stage="done"] [role="status"]'); +const fill = (name = "notes-kit") => { + fireEvent.change(field("名称"), { target: { value: name } }); + fireEvent.change(field("来源地址"), { target: { value: `https://example.com/${name}/SKILL.md` } }); +}; +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +const receipt = async (port: AgentPort, privateDraft = false, name = "notes-kit") => port.publishMarket({ + kind: "skill", name, source: `https://example.com/${name}/SKILL.md`, visibility: privateDraft ? "private" : "public", +}); +const draw = (port: AgentPort, onClose = vi.fn()) => {}} contrast="" onContrast={() => {}} weight="" onWeight={() => {}} + look={{} as never} onLook={() => {}} reloadThemes={() => {}} + onClose={onClose} onChanged={() => {}} onError={() => {}} at="ext:market" + account={account()} accountUnread="" reloadAccount={() => {}} +/>; + +it("keeps a pending publish receipt visible after clicking the browse view", async () => { + const port = new MockPort() as unknown as AgentPort; + const out = await receipt(port); + const pending = deferred(); + vi.spyOn(port, "publishMarket").mockImplementation(() => pending.promise); + render(draw(port)); + await userEvent.click(screen.getByRole("radio", { name: "发布" })); + fill(); + await userEvent.click(screen.getByRole("button", { name: "提交审核" })); + await userEvent.click(screen.getByRole("radio", { name: "浏览" })); + await act(async () => pending.resolve(out)); + expect(published()).not.toBeNull(); + expect(published()?.textContent).toContain("已提交 demo/notes-kit 0.1.0"); +}); + +it.each(["zh", "en"].flatMap((lang) => [false, true].flatMap((privateDraft) => + ["success", "failure"].map((outcome) => ({ lang, privateDraft, outcome })), +)))("holds the $lang author form and Settings navigation through private=$privateDraft publish $outcome", async ({ lang, privateDraft, outcome }) => { + localStorage.setItem(STORAGE, lang); boot(); + const port = new MockPort() as unknown as AgentPort; + const out = await receipt(port, privateDraft); + const pending = deferred(); + const publish = vi.spyOn(port, "publishMarket").mockImplementationOnce(() => pending.promise).mockResolvedValue(out); + const onClose = vi.fn(); + render(draw(port, onClose)); + await userEvent.click(screen.getByRole("radio", { name: t("发布") })); + fill(); + if (privateDraft) await userEvent.click(screen.getByRole("checkbox")); + await userEvent.click(screen.getByRole("button", { name: t(privateDraft ? "保存为私有" : "提交审核") })); + const form = document.querySelector("form.mkt-pub")!; + const controls = [ + document.querySelector('[data-action="settings.close"]')!, + document.querySelector('[data-action="settings.section"][data-value="session"]')!, + screen.getByRole("tab", { name: t("已安装") }), + screen.getByRole("radio", { name: t("浏览") }), + screen.getByRole("radio", { name: t("我的发布") }), + ]; + for (const button of controls) { + expect(button.disabled).toBe(true); + await userEvent.click(button); + } + await userEvent.type(screen.getByRole("textbox", { name: t("搜索设置") }), "模型"); + const found = screen.getAllByRole("option"); + for (const button of found) { + expect(button.disabled).toBe(true); + await userEvent.click(button); + } + await userEvent.click(form); + await userEvent.keyboard("{Escape}"); + const backdrop = document.querySelector(".prefs")!; + fireEvent.mouseDown(backdrop); + fireEvent.mouseUp(backdrop); + expect(onClose).not.toHaveBeenCalled(); + expect(form.isConnected).toBe(true); + expect(field("名称").value).toBe("notes-kit"); + expect(form.getAttribute("aria-busy")).toBe("true"); + expect(publish).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ name: "notes-kit", visibility: privateDraft ? "private" : "public" })); + await act(async () => { + if (outcome === "success") pending.resolve(out); + else pending.reject(new Error("registry temporarily unavailable")); + }); + for (const button of [...controls, ...found]) expect(button.disabled).toBe(false); + if (outcome === "success") { + expect(published()?.textContent).toContain(out.package.slug); + expect(screen.queryByRole("alert")).toBeNull(); + await userEvent.click(screen.getByRole("button", { name: t("再发布一个") })); + expect(field("名称").value).toBe(""); + expect(field("来源地址").value).toBe(""); + } else { + expect(screen.getByRole("alert").textContent).toContain("registry temporarily unavailable"); + expect(field("名称").value).toBe("notes-kit"); + expect(field("来源地址").value).toBe("https://example.com/notes-kit/SKILL.md"); + await userEvent.click(screen.getByRole("button", { name: t(privateDraft ? "保存为私有" : "提交审核") })); + expect(published()?.textContent).toContain(out.package.slug); + expect(publish).toHaveBeenCalledTimes(2); + expect(screen.queryByRole("alert")).toBeNull(); + } + await userEvent.click(controls[0]!); + expect(onClose).toHaveBeenCalledTimes(1); +}); + +it.each(["connection", "account"].flatMap((owner) => ["success", "failure"].map((outcome) => ({ owner, outcome }))))( + "releases a changed $owner but does not let its late $outcome unlock a new submission", + async ({ owner, outcome }) => { + const port = new MockPort() as unknown as AgentPort; + const next = new MockPort() as unknown as AgentPort; + const out = await receipt(port); + const old = deferred(); + const fresh = deferred(); + vi.spyOn(port, "publishMarket").mockImplementationOnce(() => old.promise).mockImplementationOnce(() => fresh.promise); + vi.spyOn(next, "publishMarket").mockImplementation(() => fresh.promise); + const applying = vi.fn(); + const group = (p: AgentPort, handle = "demo") => {}} onSignIn={() => {}} onApplying={applying} />; + const view = render(group(port)); + await userEvent.click(screen.getByRole("radio", { name: "发布" })); + fill(); + await userEvent.click(screen.getByRole("button", { name: "提交审核" })); + expect(applying).toHaveBeenLastCalledWith(true); + view.rerender(group(owner === "connection" ? next : port, owner === "account" ? "other" : "demo")); + expect(applying).toHaveBeenLastCalledWith(false); + expect(screen.getByRole("radio", { name: "浏览" }).disabled).toBe(false); + fill("fresh-kit"); + await userEvent.click(screen.getByRole("button", { name: "提交审核" })); + expect(applying).toHaveBeenLastCalledWith(true); + const calls = applying.mock.calls.length; + await act(async () => { + if (outcome === "success") old.resolve(out); + else old.reject(new Error("old publish refused")); + }); + expect(applying).toHaveBeenCalledTimes(calls); + expect(screen.getByRole("radio", { name: "浏览" }).disabled).toBe(true); + expect(screen.queryByRole("status")).toBeNull(); + expect(screen.queryByRole("alert")).toBeNull(); + await act(async () => fresh.resolve({ ...out, package: { ...out.package, name: "fresh-kit", slug: "demo/fresh-kit" } })); + expect(applying).toHaveBeenLastCalledWith(false); + expect(screen.getByRole("radio", { name: "浏览" }).disabled).toBe(false); + expect(published()?.textContent).toContain("demo/fresh-kit"); + }, +); + +it("releases the navigation channel on unmount during a StrictMode submission", async () => { + const port = new MockPort() as unknown as AgentPort; + const out = await receipt(port); + const pending = deferred(); + vi.spyOn(port, "publishMarket").mockImplementation(() => pending.promise); + const applying = vi.fn(); + const view = render( {}} onSignIn={() => {}} onApplying={applying} />); + await userEvent.click(screen.getByRole("radio", { name: "发布" })); + fill(); + await userEvent.click(screen.getByRole("button", { name: "提交审核" })); + expect(applying).toHaveBeenLastCalledWith(true); + view.unmount(); + expect(applying).toHaveBeenLastCalledWith(false); + const calls = applying.mock.calls.length; + await act(async () => pending.resolve(out)); + expect(applying).toHaveBeenCalledTimes(calls); +}); + +it.each(["publish", "install"].flatMap((first) => ["success", "failure"].flatMap((outcome) => + [false, true].map((strict) => ({ first, outcome, strict })), +)))("keeps views exclusive and the replacement hold during old $first $outcome (StrictMode=$strict)", async ({ first, outcome, strict }) => { + const old = new MockPort() as unknown as AgentPort; + const current = new MockPort() as unknown as AgentPort; + const pkg = (await old.myMarket()).find((p) => p.slug === "demo/ship-notes")!; + const shown = await old.planOwnMarket({ slug: pkg.slug }); + const installed: MarketPlan = { ...shown, applied: true, status: "done" }; + const out = await receipt(old); + const oldPublish = deferred(); + const newPublish = deferred(); + const oldInstall = deferred(); + const newInstall = deferred(); + const publishOld = vi.spyOn(old, "publishMarket").mockReturnValue(oldPublish.promise); + const publishNew = vi.spyOn(current, "publishMarket").mockReturnValue(newPublish.promise); + const installOld = vi.spyOn(old, "installOwnMarket").mockReturnValue(oldInstall.promise); + const installNew = vi.spyOn(current, "installOwnMarket").mockReturnValue(newInstall.promise); + for (const port of [old, current]) { + vi.spyOn(port, "myMarket").mockResolvedValue([pkg]); + vi.spyOn(port, "planOwnMarket").mockResolvedValue(shown); + } + const applying = vi.fn(); + const onInstalled = vi.fn(); + const group = (port: AgentPort) => { + const child = {}} onApplying={applying} />; + return strict ? {child} : child; + }; + const begin = async (kind: string) => { + if (kind === "publish") { + await userEvent.click(screen.getByRole("radio", { name: "发布" })); + fill(); + await userEvent.click(screen.getByRole("button", { name: "提交审核" })); + expect(document.querySelector("form.mkt-pub")).not.toBeNull(); + expect(screen.queryByRole("button", { name: "安装中…" })).toBeNull(); + } else { + await userEvent.click(screen.getByRole("radio", { name: "我的发布" })); + const row = (await screen.findByText(pkg.name)).closest("li")!; + await userEvent.click(row.querySelector('[data-action="market.own-inspect"]')!); + await userEvent.click(await screen.findByRole("button", { name: "安装" })); + expect(screen.getByRole("button", { name: "安装中…" }).disabled).toBe(true); + expect(document.querySelector("form.mkt-pub")).toBeNull(); + } + }; + const view = render(group(old)); + await begin(first); + expect(applying).toHaveBeenLastCalledWith(true); + for (const radio of screen.getAllByRole("radio")) { + expect(radio.disabled).toBe(true); + await userEvent.click(radio); + } + expect(first === "publish" ? publishOld : installOld).toHaveBeenCalledTimes(1); + expect(first === "publish" ? installOld : publishOld).not.toHaveBeenCalled(); + view.rerender(group(current)); + expect(applying).toHaveBeenLastCalledWith(false); + await begin(first === "publish" ? "install" : "publish"); + expect(applying).toHaveBeenLastCalledWith(true); + expect(first === "publish" ? installNew : publishNew).toHaveBeenCalledTimes(1); + const calls = applying.mock.calls.length; + await act(async () => { + if (first === "publish") { + if (outcome === "success") oldPublish.resolve(out); + else oldPublish.reject(new Error("old publish refused")); + } else { + if (outcome === "success") oldInstall.resolve(installed); + else oldInstall.reject(new Error("old install refused")); + } + }); + expect(applying).toHaveBeenCalledTimes(calls); + expect(onInstalled).not.toHaveBeenCalled(); + expect(screen.queryByRole("alert")).toBeNull(); + for (const radio of screen.getAllByRole("radio")) expect(radio.disabled).toBe(true); + await act(async () => first === "publish" ? newInstall.resolve(installed) : newPublish.resolve(out)); + expect(applying).toHaveBeenLastCalledWith(false); + for (const radio of screen.getAllByRole("radio")) expect(radio.disabled).toBe(false); + expect(onInstalled).toHaveBeenCalledTimes(first === "publish" ? 1 : 0); +});