Skip to content

Commit aaa7362

Browse files
feat: add plugin submission success modal (#3141)
Add the missing plugin submission success state, align plugin and skill success icons with the muted marketplace treatment, and harden pending-publish and public URL fallback behavior. Validated with real full-stack browser proof, focused tests, maintainer review, and all required checks green. Vercel remains the expected contributor authorization failure. Co-authored-by: Nancy <nancymxgao@gmail.com> Co-authored-by: vyctorbrzezowski <krzyszchweski@gmail.com>
1 parent db3b3fe commit aaa7362

11 files changed

Lines changed: 805 additions & 51 deletions

src/__tests__/plugins-publish-route.test.tsx

Lines changed: 307 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* @vitest-environment jsdom */
22

3-
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
3+
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
44
import { DocsLinks } from "clawhub-schema";
55
import { getFunctionName } from "convex/server";
66
import { createElement } from "react";
@@ -33,6 +33,7 @@ vi.mock("sonner", () => ({
3333
const generateUploadUrl = vi.fn();
3434
const publishRelease = vi.fn();
3535
const fetchMock = vi.fn();
36+
const writeTextMock = vi.fn();
3637
const useAuthStatusMock = vi.fn();
3738
const useQueryMock = vi.fn();
3839
const useSearchMock = vi.fn();
@@ -78,6 +79,38 @@ function makeCodePluginPackageJson(overrides: Record<string, unknown>) {
7879
});
7980
}
8081

82+
function uploadCodePluginPackage(
83+
packageJsonOverrides: Record<string, unknown>,
84+
directory = "demo-plugin",
85+
) {
86+
const packageJson = withRelativePath(
87+
new File([makeCodePluginPackageJson(packageJsonOverrides)], "package.json", {
88+
type: "application/json",
89+
}),
90+
`${directory}/package.json`,
91+
);
92+
const manifest = withRelativePath(
93+
new File(['{"id":"demo.plugin"}'], "openclaw.plugin.json", { type: "application/json" }),
94+
`${directory}/openclaw.plugin.json`,
95+
);
96+
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest] } });
97+
}
98+
99+
function makeVintageAyuMembership(
100+
overrides: { kind?: "user" | "org"; role?: "owner" | "publisher" } = {},
101+
) {
102+
return {
103+
publisher: {
104+
_id: "publishers:vintageayu",
105+
handle: "vintageayu",
106+
displayName: "VintageAyu",
107+
kind: overrides.kind ?? "user",
108+
image: "/clawd-logo.png",
109+
},
110+
role: overrides.role ?? "owner",
111+
};
112+
}
113+
81114
function getFileInput() {
82115
const input = document.querySelector('input[type="file"]');
83116
if (!(input instanceof HTMLInputElement)) throw new Error("Missing file input");
@@ -102,6 +135,7 @@ describe("plugins publish route", () => {
102135
generateUploadUrl.mockReset();
103136
publishRelease.mockReset();
104137
fetchMock.mockReset();
138+
writeTextMock.mockReset();
105139
useAuthStatusMock.mockReset();
106140
useQueryMock.mockReset();
107141
useSearchMock.mockReset();
@@ -125,18 +159,7 @@ describe("plugins publish route", () => {
125159
if (args === "skip") return undefined;
126160
const name = fn ? getFunctionName(fn as Parameters<typeof getFunctionName>[0]) : "";
127161
if (name !== "publishers:listMine") return null;
128-
return [
129-
{
130-
publisher: {
131-
_id: "publishers:vintageayu",
132-
handle: "vintageayu",
133-
displayName: "VintageAyu",
134-
kind: "user",
135-
image: "/clawd-logo.png",
136-
},
137-
role: "owner",
138-
},
139-
];
162+
return [makeVintageAyuMembership()];
140163
});
141164
generateUploadUrl.mockResolvedValue("https://upload.local");
142165
publishRelease.mockResolvedValue({ ok: true, packageId: "pkg:1", releaseId: "rel:1" });
@@ -151,6 +174,12 @@ describe("plugins publish route", () => {
151174
configurable: true,
152175
writable: true,
153176
});
177+
Object.defineProperty(navigator, "clipboard", {
178+
configurable: true,
179+
value: {
180+
writeText: writeTextMock.mockResolvedValue(undefined),
181+
},
182+
});
154183
});
155184

156185
afterEach(() => {
@@ -319,6 +348,163 @@ describe("plugins publish route", () => {
319348
});
320349
});
321350

351+
it("shows the submitted plugin modal with plugin identity and canonical actions", async () => {
352+
useSearchMock.mockReturnValue({
353+
ownerHandle: "@VintageAyu",
354+
name: undefined,
355+
displayName: undefined,
356+
family: undefined,
357+
nextVersion: undefined,
358+
sourceRepo: undefined,
359+
});
360+
renderPublishRoute();
361+
362+
uploadCodePluginPackage({
363+
name: "demo-plugin",
364+
displayName: "Demo Plugin",
365+
version: "1.2.3",
366+
repository: "https://github.com/openclaw/demo-plugin.git",
367+
});
368+
369+
await waitFor(() => {
370+
expect(screen.getByDisplayValue("demo-plugin")).toBeTruthy();
371+
});
372+
fireEvent.change(screen.getByPlaceholderText("Full commit SHA"), {
373+
target: { value: "abc123" },
374+
});
375+
fireEvent.click(screen.getByRole("button", { name: "Publish plugin" }));
376+
377+
expect(await screen.findByRole("heading", { name: "Plugin submitted" })).toBeTruthy();
378+
const submittedDialog = within(screen.getByRole("dialog"));
379+
expect(submittedDialog.getByText("Your plugin is under review")).toBeTruthy();
380+
expect(submittedDialog.getByText("Demo Plugin")).toBeTruthy();
381+
expect(submittedDialog.getByText("VintageAyu")).toBeTruthy();
382+
expect(submittedDialog.getByText("@vintageayu")).toBeTruthy();
383+
384+
const pluginLink = screen.getByRole("link", {
385+
name: "clawhub.ai/vintageayu/plugins/demo-plugin",
386+
});
387+
expect(pluginLink.getAttribute("href")).toBe(
388+
"https://clawhub.ai/vintageayu/plugins/demo-plugin",
389+
);
390+
391+
fireEvent.click(screen.getByRole("button", { name: "Copy plugin link" }));
392+
await waitFor(() => {
393+
expect(writeTextMock).toHaveBeenCalledWith(
394+
"https://clawhub.ai/vintageayu/plugins/demo-plugin",
395+
);
396+
});
397+
398+
const viewPlugin = screen.getByRole("link", { name: "View plugin" });
399+
expect(viewPlugin.getAttribute("href")).toBe("/vintageayu/plugins/demo-plugin");
400+
expect(publishRelease).toHaveBeenCalledWith({
401+
payload: expect.objectContaining({ ownerHandle: "vintageayu" }),
402+
});
403+
expect(screen.queryByRole("link", { name: /Share on Discord/i })).toBeNull();
404+
expect(screen.queryByRole("link", { name: /Share on Twitter/i })).toBeNull();
405+
406+
fireEvent.click(screen.getByRole("button", { name: "Close" }));
407+
await waitFor(() => {
408+
expect(screen.queryByRole("heading", { name: "Plugin submitted" })).toBeNull();
409+
});
410+
});
411+
412+
it("keeps publish disabled until a publisher identity resolves", async () => {
413+
useQueryMock.mockImplementation((fn: unknown, args: unknown) => {
414+
if (args === "skip") return undefined;
415+
const name = fn ? getFunctionName(fn as Parameters<typeof getFunctionName>[0]) : "";
416+
if (name === "publishers:listMine") return undefined;
417+
return null;
418+
});
419+
renderPublishRoute();
420+
421+
uploadCodePluginPackage({ name: "demo-plugin", version: "1.0.0" });
422+
423+
expect(await screen.findByText("Loading publishing identities…")).toBeTruthy();
424+
expect(
425+
screen.getByRole("button", { name: "Publish plugin" }).getAttribute("disabled"),
426+
).not.toBeNull();
427+
expect(publishRelease).not.toHaveBeenCalled();
428+
});
429+
430+
it("keeps publish disabled when the requested publisher is not available", async () => {
431+
useSearchMock.mockReturnValue({
432+
ownerHandle: "not-a-member",
433+
name: undefined,
434+
displayName: undefined,
435+
family: undefined,
436+
nextVersion: undefined,
437+
sourceRepo: undefined,
438+
});
439+
440+
renderPublishRoute();
441+
442+
uploadCodePluginPackage({ name: "demo-plugin", version: "1.0.0" });
443+
await waitFor(() => {
444+
expect(screen.getByPlaceholderText("Full commit SHA").getAttribute("disabled")).toBeNull();
445+
});
446+
fireEvent.change(screen.getByPlaceholderText("Full commit SHA"), {
447+
target: { value: "abc123" },
448+
});
449+
450+
expect(screen.getByText("Select an available publisher to publish.")).toBeTruthy();
451+
expect(
452+
screen.getByRole("button", { name: "Publish plugin" }).getAttribute("disabled"),
453+
).not.toBeNull();
454+
expect(publishRelease).not.toHaveBeenCalled();
455+
});
456+
457+
it("skips the package-page lookup for backend-reserved package names", async () => {
458+
let getByNameCalls = 0;
459+
useQueryMock.mockImplementation((fn: unknown, args: unknown) => {
460+
if (args === "skip") return undefined;
461+
const name = fn ? getFunctionName(fn as Parameters<typeof getFunctionName>[0]) : "";
462+
if (name === "packages:getByName") {
463+
getByNameCalls += 1;
464+
throw new Error("Reserved package names must not be queried");
465+
}
466+
if (name === "publishers:listMine") {
467+
return [makeVintageAyuMembership()];
468+
}
469+
return null;
470+
});
471+
472+
renderPublishRoute();
473+
474+
uploadCodePluginPackage({ name: "publish", version: "1.0.0" }, "publish");
475+
476+
expect(await screen.findByDisplayValue("publish")).toBeTruthy();
477+
expect(getByNameCalls).toBe(0);
478+
});
479+
480+
it("keeps an existing-plugin publish disabled until its context resolves", () => {
481+
useSearchMock.mockReturnValue({
482+
ownerHandle: "vintageayu",
483+
name: "demo-plugin",
484+
displayName: "Demo Plugin",
485+
family: "code-plugin",
486+
nextVersion: "1.2.4",
487+
sourceRepo: "openclaw/demo-plugin",
488+
});
489+
useQueryMock.mockImplementation((fn: unknown, args: unknown) => {
490+
if (args === "skip") return undefined;
491+
const name = fn ? getFunctionName(fn as Parameters<typeof getFunctionName>[0]) : "";
492+
if (name === "packages:getManageContext") return undefined;
493+
if (name === "publishers:listMine") {
494+
return [makeVintageAyuMembership()];
495+
}
496+
return null;
497+
});
498+
499+
renderPublishRoute();
500+
501+
expect(screen.getByText("Loading plugin details…")).toBeTruthy();
502+
expect(
503+
screen.getByRole("button", { name: "Publish plugin" }).getAttribute("disabled"),
504+
).not.toBeNull();
505+
expect(publishRelease).not.toHaveBeenCalled();
506+
});
507+
322508
it("prefills and preserves catalog metadata when publishing a new plugin version", async () => {
323509
useSearchMock.mockReturnValue({
324510
ownerHandle: "vintageayu",
@@ -343,7 +529,9 @@ describe("plugins publish route", () => {
343529
suggestedCategories: [],
344530
};
345531
}
346-
if (name === "publishers:listMine") return [];
532+
if (name === "publishers:listMine") {
533+
return [makeVintageAyuMembership()];
534+
}
347535
return null;
348536
});
349537

@@ -418,7 +606,9 @@ describe("plugins publish route", () => {
418606
suggestedCategories: [],
419607
};
420608
}
421-
if (name === "publishers:listMine") return [];
609+
if (name === "publishers:listMine") {
610+
return [makeVintageAyuMembership()];
611+
}
422612
return null;
423613
});
424614

@@ -884,6 +1074,108 @@ describe("plugins publish route", () => {
8841074
});
8851075
expect(screen.queryByText(/Running TruffleHog and ClawScan/i)).toBeNull();
8861076
expect(screen.queryByText("Publishing release...")).toBeNull();
1077+
expect(screen.queryByRole("heading", { name: "Plugin submitted" })).toBeNull();
1078+
});
1079+
1080+
it("shows the submitted modal for a staged version of an existing plugin", async () => {
1081+
useSearchMock.mockReturnValue({
1082+
ownerHandle: "vintageayu",
1083+
name: "demo-plugin",
1084+
displayName: "Demo Plugin",
1085+
family: "code-plugin",
1086+
nextVersion: "1.2.4",
1087+
sourceRepo: "openclaw/demo-plugin",
1088+
});
1089+
useQueryMock.mockImplementation((fn: unknown, args: unknown) => {
1090+
if (args === "skip") return undefined;
1091+
const name = fn ? getFunctionName(fn as Parameters<typeof getFunctionName>[0]) : "";
1092+
if (name === "packages:getManageContext") {
1093+
return {
1094+
package: { name: "demo-plugin", displayName: "Demo Plugin" },
1095+
latestRelease: { version: "1.2.3" },
1096+
suggestedCategories: [],
1097+
};
1098+
}
1099+
if (name === "publishers:listMine") {
1100+
return [makeVintageAyuMembership()];
1101+
}
1102+
return null;
1103+
});
1104+
publishRelease.mockResolvedValueOnce({
1105+
ok: true,
1106+
status: "pending",
1107+
attemptId: "publishAttempts:2",
1108+
packageName: "demo-plugin",
1109+
version: "1.2.4",
1110+
});
1111+
renderPublishRoute();
1112+
1113+
uploadCodePluginPackage({ name: "demo-plugin", version: "1.2.4" });
1114+
await waitFor(() => {
1115+
expect(screen.getByPlaceholderText("Full commit SHA").getAttribute("disabled")).toBeNull();
1116+
});
1117+
fireEvent.change(screen.getByPlaceholderText("Full commit SHA"), {
1118+
target: { value: "abc123" },
1119+
});
1120+
fireEvent.click(screen.getByRole("button", { name: "Publish plugin" }));
1121+
1122+
expect(await screen.findByRole("heading", { name: "Plugin submitted" })).toBeTruthy();
1123+
expect(screen.getByRole("link", { name: "View plugin" }).getAttribute("href")).toBe(
1124+
"/vintageayu/plugins/demo-plugin",
1125+
);
1126+
fireEvent.click(screen.getByRole("button", { name: "Close" }));
1127+
expect(screen.getByText("Publish received. Security checks are running.")).toBeTruthy();
1128+
expect(
1129+
screen.getByRole("button", { name: "Publish plugin" }).getAttribute("disabled"),
1130+
).not.toBeNull();
1131+
});
1132+
1133+
it("shows a canonical submitted modal for an existing plugin uploaded from the generic route", async () => {
1134+
useQueryMock.mockImplementation((fn: unknown, args: unknown) => {
1135+
if (args === "skip") return undefined;
1136+
const name = fn ? getFunctionName(fn as Parameters<typeof getFunctionName>[0]) : "";
1137+
if (name === "packages:getByName") {
1138+
return {
1139+
package: { name: "demo-plugin", displayName: "Demo Plugin" },
1140+
latestRelease: { version: "1.2.3" },
1141+
owner: { handle: "vintageayu" },
1142+
};
1143+
}
1144+
if (name === "publishers:listMine") {
1145+
return [makeVintageAyuMembership({ kind: "org", role: "publisher" })];
1146+
}
1147+
return null;
1148+
});
1149+
publishRelease.mockResolvedValueOnce({
1150+
ok: true,
1151+
status: "pending",
1152+
attemptId: "publishAttempts:3",
1153+
packageName: "demo-plugin",
1154+
version: "1.2.4",
1155+
});
1156+
renderPublishRoute();
1157+
1158+
uploadCodePluginPackage({
1159+
name: "Demo-Plugin",
1160+
displayName: "Demo Plugin",
1161+
version: "1.2.4",
1162+
repository: "https://github.com/openclaw/demo-plugin.git",
1163+
});
1164+
await waitFor(() => {
1165+
expect(screen.getByPlaceholderText("Full commit SHA").getAttribute("disabled")).toBeNull();
1166+
});
1167+
fireEvent.change(screen.getByPlaceholderText("Full commit SHA"), {
1168+
target: { value: "abc123" },
1169+
});
1170+
fireEvent.click(screen.getByRole("button", { name: "Publish plugin" }));
1171+
1172+
expect(await screen.findByRole("heading", { name: "Plugin submitted" })).toBeTruthy();
1173+
expect(screen.getByRole("link", { name: "View plugin" }).getAttribute("href")).toBe(
1174+
"/vintageayu/plugins/demo-plugin",
1175+
);
1176+
expect(
1177+
screen.getByRole("link", { name: "clawhub.ai/vintageayu/plugins/demo-plugin" }),
1178+
).toBeTruthy();
8871179
});
8881180

8891181
it("warns when README references relative image paths but no source repo/commit is set", async () => {

0 commit comments

Comments
 (0)