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" ;
44import { DocsLinks } from "clawhub-schema" ;
55import { getFunctionName } from "convex/server" ;
66import { createElement } from "react" ;
@@ -33,6 +33,7 @@ vi.mock("sonner", () => ({
3333const generateUploadUrl = vi . fn ( ) ;
3434const publishRelease = vi . fn ( ) ;
3535const fetchMock = vi . fn ( ) ;
36+ const writeTextMock = vi . fn ( ) ;
3637const useAuthStatusMock = vi . fn ( ) ;
3738const useQueryMock = vi . fn ( ) ;
3839const 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+
81114function 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 : / S h a r e o n D i s c o r d / i } ) ) . toBeNull ( ) ;
404+ expect ( screen . queryByRole ( "link" , { name : / S h a r e o n T w i t t e r / 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 ( / R u n n i n g T r u f f l e H o g a n d C l a w S c a n / 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