@@ -6,6 +6,8 @@ import vm from "node:vm";
66const shard = ( name : string ) => readFileSync ( new URL ( `../../../assets/inject/renderer-inject/${ name } ` , import . meta. url ) , "utf8" ) ;
77const refs = shard ( "60-plugin-marketplace.js" ) ;
88const deletion = shard ( "80-session-share.js" ) ;
9+ const actionGroups = shard ( "90-action-groups.js" ) ;
10+ const extensionHost = shard ( "92-extension-host.js" ) ;
911const refHelpers = refs . slice ( refs . indexOf ( " function isClientNewThreadId(value)" ) , refs . indexOf ( " if (window.__CODEX_PLUS_TEST_SESSION_REF__)" ) ) ;
1012const singleFunction = ( source : string , name : string ) => source . match ( new RegExp ( `^ (?:async )?function ${ name } \\([^]*?^ \\}` , "m" ) ) ?. [ 0 ] ?? "" ;
1113const uuid = "11111111-1111-4111-8111-111111111111" ;
@@ -119,11 +121,14 @@ function deleteFixture() {
119121 activate ( uuid ) ;
120122 // 删除后隐藏行用的样式表(#2614):stub 只需支持按 id 取回与追加到 head。
121123 const styles = new Map < string , Element & { id ?: string } > ( ) ;
124+ const listeners = new Map < string , Function > ( ) ;
122125 const head = { appendChild : ( node : Element & { id ?: string } ) => { node . removed = false ; node . remove = ( ) => { node . removed = true ; styles . delete ( node . id ?? "" ) ; } ; styles . set ( node . id ?? "" , node ) ; return node ; } } ;
123126 const context = vm . createContext ( { Element, HTMLElement : Element , URL , window : { location } ,
124127 CSS : { escape : ( value : string ) => value . replace ( / [ " \\ ] / g, "\\$&" ) } ,
125128 document : {
126129 activeElement : null , querySelectorAll : ( ) => buttons , head,
130+ addEventListener : ( name : string , callback : Function ) => listeners . set ( name , callback ) ,
131+ removeEventListener : ( name : string , callback : Function ) => { if ( listeners . get ( name ) === callback ) listeners . delete ( name ) ; } ,
127132 querySelector : ( selector : string ) => rows . find ( ( element ) => element . isConnected && selector . includes ( "data-codex-plus-deleted-session-row" ) && element . getAttribute ( "data-codex-plus-deleted-session-row" ) !== null ) ?? null ,
128133 getElementById : ( id : string ) => styles . get ( id ) ?? null ,
129134 createElement : ( ) => new Element ( ) ,
@@ -139,12 +144,208 @@ function deleteFixture() {
139144 setTimeout : ( _fn : Function , delay : number ) => { timers . push ( delay ) ; return 1 ; } ,
140145 rpc : { sendRequest : ( method : string , params : any ) => { refreshes . push ( { method, params } ) ; return Promise . resolve ( { } ) ; } } ,
141146 } ) ;
142- vm . runInContext ( `${ refHelpers } \n${ singleFunction ( refs , "locationThreadId" ) } \n${ singleFunction ( refs , "currentSessionRef" ) } \n${ deletion . slice ( deletion . indexOf ( " function rowHref(row)" ) , deletion . indexOf ( " async function exportMarkdown(ref)" ) ) } \n${ singleFunction ( deletion , "refreshRecentConversationsForHost" ) } \nfunction signal(e,t){return rpc.sendRequest(e,t)}; loadOptionalCodexAppModule=async()=>({signal});` , context ) ;
147+ vm . runInContext ( `${ refHelpers } \n${ singleFunction ( refs , "locationThreadId" ) } \n${ singleFunction ( refs , "currentSessionRef" ) } \n${ deletion . slice ( deletion . indexOf ( " function rowHref(row)" ) , deletion . indexOf ( " async function exportMarkdown(ref)" ) ) } \n${ singleFunction ( deletion , "uncreatedSessionActionFromRow" ) } \n ${ singleFunction ( deletion , "deleteUncreatedSession" ) } \n ${ singleFunction ( deletion , "installDeleteButtonEventDelegation" ) } \n ${ singleFunction ( deletion , " refreshRecentConversationsForHost") } \nfunction signal(e,t){return rpc.sendRequest(e,t)}; loadOptionalCodexAppModule=async()=>({signal});` , context ) ;
143148 const begin = async ( element = rows [ 0 ] ) => { context . openDeleteConfirmForRow ( element , button , context . sessionRefFromRow ( element ) , { preventDefault ( ) { } , stopPropagation ( ) { } , stopImmediatePropagation ( ) { } } ) ; await flush ( ) ; } ;
144149 const finish = async ( result = { status : "local_deleted" , session_id : requests [ 0 ] ?. session_id , message : "deleted" } ) => { request . resolve ( result ) ; await flush ( ) ; } ;
145- return { context, settings, rows, native, buttons, requests, confirmations, diagnostics, timers, toasts, refreshes, styles, begin, finish, activate, setLocation, reloads : ( ) => reloads } ;
150+ return { context, settings, rows, native, buttons, button , listeners , requests, confirmations, diagnostics, timers, toasts, refreshes, styles, begin, finish, activate, setLocation, reloads : ( ) => reloads } ;
146151}
147152
153+ function uncreatedFixture ( prefix = "local:" ) {
154+ const f = deleteFixture ( ) ;
155+ const clientId = `client-new-thread:${ uuid } ` ;
156+ const rawId = `${ prefix } ${ clientId } ` ;
157+ const pending = { clientThreadId : clientId , hostId : "local" , status : "failed" } ;
158+ const calls : string [ ] = [ ] ;
159+ const binding = {
160+ dataAttributes : { "data-app-action-sidebar-thread-id" : rawId } ,
161+ onArchive : ( ) => { calls . push ( clientId ) ; } ,
162+ } ;
163+ const owner = { pending } ;
164+ const element = row ( rawId , "local" ) ;
165+ element . __reactFiber$fixture . return = {
166+ memoizedProps : binding ,
167+ return : { memoizedProps : owner , return : null } ,
168+ } ;
169+ f . rows [ 0 ] = element ;
170+ return { ...f , element, binding, owner, pending, calls, clientId, rawId } ;
171+ }
172+
173+ function attachButtonFixture ( id : string ) {
174+ class ActionElement extends Element {
175+ dataset : Record < string , string > = { } ;
176+ children : ActionElement [ ] = [ ] ;
177+ className = "" ;
178+ appendChild ( child : ActionElement ) {
179+ child . parentElement = this ;
180+ this . children . push ( child ) ;
181+ return child ;
182+ }
183+ }
184+ const element = new ActionElement ( { "data-app-action-sidebar-thread-id" : id } ) ;
185+ const body = new ActionElement ( ) ;
186+ const mounted : string [ ] = [ ] ;
187+ const items = [
188+ { id : "delete" , scriptKey : "builtin:session-delete" , placement : "inline" } ,
189+ { id : "export" , scriptKey : "builtin:session-export" , placement : "menu" } ,
190+ { id : "custom-inline" , scriptKey : "fixture" , placement : "inline" } ,
191+ ] . map ( ( item ) => ( { ...item , onMount : ( ) => mounted . push ( item . id ) , onActivate ( ) { } } ) ) ;
192+ const context = vm . createContext ( {
193+ Element : ActionElement ,
194+ selectors : { threadTitle : ".title" } ,
195+ document : { body, createElement : ( ) => new ActionElement ( ) } ,
196+ codexPlusBackendSettings : { } ,
197+ codexPlusRegistry : { rowActions : items } ,
198+ codexPlusExtensionItems : ( registered : unknown ) => registered ,
199+ actionGroupClass : "actions" ,
200+ actionButtonClass : "action" ,
201+ moreButtonClass : "more" ,
202+ moreMenuClass : "menu" ,
203+ codexActionGroupVersion : "fixture" ,
204+ removeActionGroups : ( ) => { element . children = [ ] ; } ,
205+ actionGroupFromRow : ( ) => null ,
206+ configureSvgActionButton ( ) { } ,
207+ ellipsisIconSvg : ( ) => "" ,
208+ sessionRowActionIconMarkup : ( ) => "" ,
209+ applyNativeSessionActionStyle ( ) { } ,
210+ installMoreButtonEvents ( ) { } ,
211+ installSessionMoreMenuAutoClose ( ) { } ,
212+ installActionButtonEvents ( ) { } ,
213+ syncNativeSessionActionStyle ( ) { } ,
214+ installNativeSessionActionStyleSync ( ) { } ,
215+ syncActionGroupLayout ( ) { } ,
216+ setTimeout ( ) { } ,
217+ runCodexPlusExtensionCallback : ( _key : string , _name : string , callback : Function ) => ( { ok : true , value : callback ( ) } ) ,
218+ markCodexPlusExtensionNode : ( node : ActionElement , key : string ) => node . setAttribute ( "data-codex-plus-ext" , key ) ,
219+ createSessionMoreMenuItem : ( ) => new ActionElement ( ) ,
220+ } ) ;
221+ vm . runInContext ( `${ refHelpers } \n${ singleFunction ( actionGroups , "sessionRowActionItems" ) } \n${ singleFunction ( extensionHost , "appendCodexPlusExtensionRowActions" ) } \n${ singleFunction ( actionGroups , "attachButton" ) } ` , context ) ;
222+ context . attachButton ( element ) ;
223+ const inline = element . children . flatMap ( ( group ) => group . children )
224+ . map ( ( button ) => button . getAttribute ( "data-codex-plus-row-action" ) ) . filter ( Boolean ) ;
225+ const menu = body . children . flatMap ( ( container ) => container . children )
226+ . map ( ( item ) => item . dataset . codexPlusExtensionItem ) ;
227+ return { element, inline, menu, mounted } ;
228+ }
229+
230+ test ( "attachButton mounts only native deletion on an uncreated row" , ( ) => {
231+ for ( const prefix of [ "" , "local:" ] ) {
232+ const f = attachButtonFixture ( `${ prefix } client-new-thread:${ uuid } ` ) ;
233+ assert . deepEqual ( f . inline , [ "delete" ] ) ;
234+ assert . deepEqual ( f . menu , [ ] ) ;
235+ assert . deepEqual ( f . mounted , [ "delete" ] ) ;
236+ assert . equal ( f . element . children . length , 1 ) ;
237+ }
238+ } ) ;
239+
240+ test ( "attachButton leaves unknown rows without actions and preserves formal row actions" , ( ) => {
241+ const unknown = attachButtonFixture ( "" ) ;
242+ assert . deepEqual ( unknown . inline , [ ] ) ;
243+ assert . deepEqual ( unknown . menu , [ ] ) ;
244+ assert . equal ( unknown . element . children . length , 0 ) ;
245+ const formal = attachButtonFixture ( uuid ) ;
246+ assert . deepEqual ( formal . inline , [ "delete" , "custom-inline" ] ) ;
247+ assert . deepEqual ( formal . menu , [ "export" ] ) ;
248+ assert . deepEqual ( formal . mounted , [ "delete" , "custom-inline" ] ) ;
249+ } ) ;
250+
251+ test ( "uncreated deletion invokes the native pending action without deleting its UUID suffix" , async ( ) => {
252+ for ( const prefix of [ "" , "local:" ] ) {
253+ const f = uncreatedFixture ( prefix ) ;
254+ // 草稿没有撤销备份,即使普通本机会话启用快速删除也仍要确认。
255+ f . settings . sessionDeleteSkipConfirm = true ;
256+ await f . begin ( ) ;
257+ assert . deepEqual ( f . calls , [ f . clientId ] ) ;
258+ assert . equal ( f . confirmations . length , 1 ) ;
259+ assert . equal ( f . requests . length , 0 , "临时 id 及其 UUID 后缀都不能发给 /delete" ) ;
260+ assert . equal ( f . refreshes . length , 0 ) ;
261+ assert . equal ( f . styles . size , 0 , "草稿不能写入按正式 UUID 匹配的隐藏表" ) ;
262+ assert . equal ( f . element . removed , false , "清理交给原生状态操作,不直接移除 DOM" ) ;
263+ assert . match ( f . toasts . join ( " " ) , / 已 关 闭 未 创 建 成 功 / ) ;
264+ }
265+ } ) ;
266+
267+ test ( "uncreated deletion cancellation leaves the native pending state untouched" , async ( ) => {
268+ const f = uncreatedFixture ( ) ;
269+ f . context . confirmDelete = ( ) => Promise . resolve ( false ) ;
270+ await f . begin ( ) ;
271+ assert . equal ( f . calls . length , 0 ) ;
272+ assert . equal ( f . requests . length , 0 ) ;
273+ assert . equal ( f . element . removed , false ) ;
274+ assert . equal ( f . toasts . length , 0 ) ;
275+ } ) ;
276+
277+ test ( "uncreated deletion rechecks identity after confirmation" , async ( ) => {
278+ const changes : Array < ( f : ReturnType < typeof uncreatedFixture > ) => void > = [
279+ ( f ) => { f . element . setAttribute ( "href" , `/thread/${ uuid } ` ) ; } ,
280+ ( f ) => { f . element . setAttribute ( "data-app-action-sidebar-thread-id" , uuid ) ; } ,
281+ ( f ) => { f . owner . pending = { ...f . pending } ; } ,
282+ ( f ) => { f . element . setAttribute ( "data-app-action-sidebar-thread-host-id" , "ssh:fixture" ) ; } ,
283+ ( f ) => { f . element . removed = true ; } ,
284+ ] ;
285+ for ( const change of changes ) {
286+ const f = uncreatedFixture ( ) ;
287+ const confirmation = deferred < boolean > ( ) ;
288+ f . context . confirmDelete = ( ) => confirmation . promise ;
289+ await f . begin ( ) ;
290+ assert . equal ( f . calls . length , 0 ) ;
291+ change ( f ) ;
292+ confirmation . resolve ( true ) ;
293+ await flush ( ) ;
294+ assert . equal ( f . calls . length , 0 ) ;
295+ assert . equal ( f . requests . length , 0 , "不能把确认期间生成的正式会话当作原草稿删除" ) ;
296+ assert . match ( f . toasts . join ( " " ) , / 会 话 已 变 化 / ) ;
297+ }
298+ } ) ;
299+
300+ test ( "uncreated deletion refuses callbacks without a matching pending row identity" , async ( ) => {
301+ const changes : Array < ( f : ReturnType < typeof uncreatedFixture > ) => void > = [
302+ ( f ) => { f . owner . pending = { ...f . pending , clientThreadId : `client-new-thread:${ otherUuid } ` } ; } ,
303+ ( f ) => { f . owner . pending = { ...f . pending , hostId : "ssh:fixture" } ; } ,
304+ ( f ) => { f . owner . pending = { ...f . pending , hostId : "" } ; } ,
305+ ( f ) => { f . binding . dataAttributes [ "data-app-action-sidebar-thread-id" ] = `local:client-new-thread:${ otherUuid } ` ; } ,
306+ ( f ) => { f . binding . onArchive = undefined as any ; } ,
307+ ( f ) => { f . element . __reactFiber$fixture . return = null ; } ,
308+ ] ;
309+ for ( const change of changes ) {
310+ const f = uncreatedFixture ( ) ;
311+ change ( f ) ;
312+ await f . begin ( ) ;
313+ assert . equal ( f . calls . length , 0 ) ;
314+ assert . equal ( f . requests . length , 0 ) ;
315+ assert . equal ( f . confirmations . length , 0 ) ;
316+ assert . match ( f . toasts . join ( " " ) , / 未 找 到 .* 原 生 关 闭 入 口 / ) ;
317+ }
318+ } ) ;
319+
320+ test ( "uncreated deletion reports native callback failures explicitly" , async ( ) => {
321+ const f = uncreatedFixture ( ) ;
322+ f . binding . onArchive = ( ) => { throw new Error ( "pending dismiss failed" ) ; } ;
323+ await f . begin ( ) ;
324+ assert . match ( f . toasts . join ( " " ) , / 关 闭 待 创 建 会 话 失 败 : p e n d i n g d i s m i s s f a i l e d / ) ;
325+ assert . doesNotMatch ( f . toasts . join ( " " ) , / 已 关 闭 / ) ;
326+ assert . equal ( f . requests . length , 0 ) ;
327+ assert . equal ( f . element . removed , false ) ;
328+ } ) ;
329+
330+ test ( "delete button document delegation reaches the uncreated native action" , async ( ) => {
331+ const f = uncreatedFixture ( ) ;
332+ f . context . buttonClass = "codex-delete-button" ;
333+ const stopped : string [ ] = [ ] ;
334+ const event = {
335+ target : { closest : ( selector : string ) => selector === ".codex-delete-button" ? f . button : null } ,
336+ preventDefault ( ) { stopped . push ( "default" ) ; } ,
337+ stopPropagation ( ) { stopped . push ( "propagation" ) ; } ,
338+ stopImmediatePropagation ( ) { stopped . push ( "immediate" ) ; } ,
339+ } ;
340+ f . button . closest = ( selector : string ) => selector === "[data-app-action-sidebar-thread-id]" ? f . element : null ;
341+ f . context . installDeleteButtonEventDelegation ( ) ;
342+ f . listeners . get ( "click" ) ! ( event ) ;
343+ await flush ( ) ;
344+ assert . deepEqual ( f . calls , [ f . clientId ] ) ;
345+ assert . deepEqual ( stopped , [ "default" , "propagation" , "immediate" ] ) ;
346+ assert . equal ( f . requests . length , 0 ) ;
347+ } ) ;
348+
148349test ( "local current deletion uses native navigation and refresh without a page reload" , async ( ) => {
149350 const f = deleteFixture ( ) ; await f . begin ( ) ; await f . finish ( ) ;
150351 assert . equal ( f . native . clicks , 1 ) ;
0 commit comments