2525
2626import { describe , it , expect , vi } from 'vitest' ;
2727import type { PluginContext } from '@objectstack/core' ;
28- import { scheduleAppArtifactJobs , collectJobsWithoutBody } from './app-artifact-handlers.js' ;
28+ import { scheduleAppArtifactJobs , collectJobsWithoutBody , PACKAGE_JOBS_UNINSTALL_CLEANUP } from './app-artifact-handlers.js' ;
2929import { jobBodyRunnerFactory } from './sandbox/body-runner.js' ;
3030import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js' ;
3131import { AppPlugin } from './app-plugin.js' ;
@@ -64,36 +64,62 @@ function recordingEngine() {
6464 } ;
6565}
6666
67- /** `IJobService` as state: what was handed to `schedule`, by job name. */
68- function recordingJobService ( ) {
67+ /**
68+ * `IJobService` as state: what is scheduled right now, by job name — `schedule`
69+ * replaces by name and `cancel` removes, the adapters' own semantics — plus
70+ * every cancel in order. `failCancel` names a job whose cancel throws.
71+ */
72+ function recordingJobService ( opts : { failCancel ?: string } = { } ) {
6973 const scheduled = new Map < string , { schedule : unknown ; run : ( c : any ) => Promise < unknown > ; options : unknown } > ( ) ;
74+ const cancels : string [ ] = [ ] ;
7075 return {
7176 scheduled,
77+ cancels,
7278 svc : {
7379 schedule : async ( name : string , schedule : unknown , run : ( c : any ) => Promise < unknown > , options ?: unknown ) => {
7480 scheduled . set ( name , { schedule, run, options } ) ;
7581 } ,
76- cancel : async ( ) => undefined ,
82+ cancel : async ( name : string ) => {
83+ if ( name === opts . failCancel ) throw new Error ( `cannot cancel ${ name } ` ) ;
84+ cancels . push ( name ) ;
85+ scheduled . delete ( name ) ;
86+ } ,
7787 trigger : async ( ) => undefined ,
7888 } ,
7989 } ;
8090}
8191
82- function harness ( ) {
92+ /** The protocol's uninstall-cleanup registry as state — the two verbs the binder and the doors use. */
93+ function recordingProtocol ( ) {
94+ const cleanups = new Map < string , ( args : { packageId : string } ) => Promise < { success : boolean ; removed : number ; error ?: string } > > ( ) ;
95+ const registrations : string [ ] = [ ] ;
96+ return {
97+ cleanups,
98+ registrations,
99+ protocol : {
100+ registerUninstallCleanup : ( name : string , cleanup : any ) => { registrations . push ( name ) ; cleanups . set ( name , cleanup ) ; } ,
101+ } ,
102+ } ;
103+ }
104+
105+ function harness ( opts : { failCancel ?: string ; withProtocol ?: boolean } = { } ) {
83106 const engine = recordingEngine ( ) ;
84- const jobs = recordingJobService ( ) ;
107+ const jobs = recordingJobService ( { failCancel : opts . failCancel } ) ;
108+ const reg = recordingProtocol ( ) ;
85109 const logger = { debug : vi . fn ( ) , info : vi . fn ( ) , warn : vi . fn ( ) , error : vi . fn ( ) } ;
86110 const ctx = {
87111 logger,
88112 getService : ( name : string ) => {
89113 if ( name === 'job' ) return jobs . svc ;
114+ if ( name === 'protocol' && opts . withProtocol ) return reg . protocol ;
90115 throw new Error ( `no ${ name } ` ) ;
91116 } ,
92117 } as unknown as PluginContext ;
93- const schedule = ( bundle : unknown ) =>
94- scheduleAppArtifactJobs ( ctx , bundle , { appId : APP_ID , ql : engine . ql as any , source : 'Test' } ) ;
118+ const schedule = ( bundle : unknown , appId : string = APP_ID ) =>
119+ scheduleAppArtifactJobs ( ctx , bundle , { appId, ql : engine . ql as any , source : 'Test' } ) ;
95120 const warned = ( ) => logger . warn . mock . calls . map ( ( c ) => String ( c [ 0 ] ) ) ;
96- return { engine, jobs, logger, ctx, schedule, warned } ;
121+ const errored = ( ) => logger . error . mock . calls . map ( ( c ) => String ( c [ 0 ] ) ) ;
122+ return { engine, jobs, reg, logger, ctx, schedule, warned, errored } ;
97123}
98124
99125/** A flattened JSON package, as install-local holds it: no `functions`. */
@@ -240,7 +266,7 @@ describe('#21489: scheduleAppArtifactJobs — handler jobs and the door-wide gat
240266 { name : 'off_handler' , schedule : INTERVAL , handler : 'tick' , enabled : false } ,
241267 ] ) ) ;
242268
243- expect ( out ) . toEqual ( { bodies : [ ] , handlers : [ ] , notScheduled : [ ] , failed : [ ] } ) ;
269+ expect ( out ) . toEqual ( { bodies : [ ] , handlers : [ ] , notScheduled : [ ] , failed : [ ] , cancelled : [ ] } ) ;
244270 expect ( h . jobs . scheduled . size ) . toBe ( 0 ) ;
245271 } ) ;
246272
@@ -258,6 +284,103 @@ describe('#21489: scheduleAppArtifactJobs — handler jobs and the door-wide gat
258284 } ) ;
259285} ) ;
260286
287+ describe ( '#21489: re-scheduling replaces — a job the new version does not schedule is CANCELLED' , ( ) => {
288+ const job = ( name : string ) => ( { name, schedule : INTERVAL , body : WRITE_BODY } ) ;
289+
290+ it ( 'a reinstall that DROPS a job cancels it, and keeps the one it still declares' , async ( ) => {
291+ const h = harness ( ) ;
292+ await h . schedule ( pkg ( [ job ( 'kept_job' ) , job ( 'gone_job' ) ] ) ) ;
293+
294+ const out = await h . schedule ( pkg ( [ job ( 'kept_job' ) ] ) ) ;
295+
296+ expect ( out . cancelled ) . toEqual ( [ 'gone_job' ] ) ;
297+ expect ( h . jobs . cancels ) . toEqual ( [ 'gone_job' ] ) ;
298+ expect ( [ ...h . jobs . scheduled . keys ( ) ] ) . toEqual ( [ 'kept_job' ] ) ;
299+ } ) ;
300+
301+ it ( 'a version that disables a job, or declares no jobs at all, cancels what it no longer runs' , async ( ) => {
302+ const h = harness ( ) ;
303+ await h . schedule ( pkg ( [ job ( 'a_job' ) , job ( 'b_job' ) ] ) ) ;
304+
305+ const disabled = await h . schedule ( pkg ( [ job ( 'a_job' ) , { ...job ( 'b_job' ) , enabled : false } ] ) ) ;
306+ expect ( disabled . cancelled ) . toEqual ( [ 'b_job' ] ) ;
307+
308+ const none = await h . schedule ( pkg ( [ ] ) ) ;
309+ expect ( none . cancelled ) . toEqual ( [ 'a_job' ] ) ;
310+ expect ( h . jobs . scheduled . size ) . toBe ( 0 ) ;
311+ } ) ;
312+
313+ it ( "another app's jobs are never cancelled — not even one that took over a name this app once scheduled" , async ( ) => {
314+ const h = harness ( ) ;
315+ await h . schedule ( pkg ( [ job ( 'shared_name' ) , job ( 'mine_only' ) ] ) , APP_ID ) ;
316+ await h . schedule ( pkg ( [ job ( 'shared_name' ) , job ( 'theirs_only' ) ] ) , 'com.example.other' ) ;
317+
318+ // This app's next version drops both: only its own remaining job stops.
319+ const out = await h . schedule ( pkg ( [ ] ) , APP_ID ) ;
320+
321+ expect ( out . cancelled ) . toEqual ( [ 'mine_only' ] ) ;
322+ expect ( [ ...h . jobs . scheduled . keys ( ) ] . sort ( ) ) . toEqual ( [ 'shared_name' , 'theirs_only' ] ) ;
323+ } ) ;
324+
325+ it ( 'a cancel that throws is said at error, and the job stays on the record for the next attempt' , async ( ) => {
326+ const h = harness ( { failCancel : 'stuck_job' } ) ;
327+ await h . schedule ( pkg ( [ job ( 'stuck_job' ) ] ) ) ;
328+
329+ const first = await h . schedule ( pkg ( [ ] ) ) ;
330+ expect ( first . cancelled ) . toEqual ( [ ] ) ;
331+ expect ( h . errored ( ) . some ( ( m ) => m . includes ( 'could NOT be cancelled' ) ) ) . toBe ( true ) ;
332+ } ) ;
333+ } ) ;
334+
335+ describe ( '#21489: the uninstall cleanup cancels the uninstalled package\'s jobs' , ( ) => {
336+ const job = ( name : string ) => ( { name, schedule : INTERVAL , body : WRITE_BODY } ) ;
337+
338+ it ( 'is registered once per protocol, as runtime.package-jobs, when a package\'s jobs are scheduled' , async ( ) => {
339+ const h = harness ( { withProtocol : true } ) ;
340+
341+ await h . schedule ( pkg ( [ job ( 'a_job' ) ] ) , APP_ID ) ;
342+ await h . schedule ( pkg ( [ job ( 'b_job' ) ] ) , 'com.example.other' ) ;
343+
344+ expect ( h . reg . registrations ) . toEqual ( [ PACKAGE_JOBS_UNINSTALL_CLEANUP ] ) ;
345+ expect ( PACKAGE_JOBS_UNINSTALL_CLEANUP ) . toBe ( 'runtime.package-jobs' ) ;
346+ } ) ;
347+
348+ it ( "cancels every job of the uninstalled package and none of another package's" , async ( ) => {
349+ const h = harness ( { withProtocol : true } ) ;
350+ await h . schedule ( pkg ( [ job ( 'a_job' ) , job ( 'a_other' ) ] ) , APP_ID ) ;
351+ await h . schedule ( pkg ( [ job ( 'b_job' ) ] ) , 'com.example.other' ) ;
352+ const cleanup = h . reg . cleanups . get ( PACKAGE_JOBS_UNINSTALL_CLEANUP ) ! ;
353+
354+ const result = await cleanup ( { packageId : APP_ID } ) ;
355+
356+ expect ( result ) . toEqual ( { success : true , removed : 2 } ) ;
357+ expect ( h . jobs . cancels . sort ( ) ) . toEqual ( [ 'a_job' , 'a_other' ] ) ;
358+ expect ( [ ...h . jobs . scheduled . keys ( ) ] ) . toEqual ( [ 'b_job' ] ) ;
359+ // A second uninstall of the same package has nothing left to cancel.
360+ await expect ( cleanup ( { packageId : APP_ID } ) ) . resolves . toEqual ( { success : true , removed : 0 } ) ;
361+ } ) ;
362+
363+ it ( 'a package that scheduled nothing is a no-op' , async ( ) => {
364+ const h = harness ( { withProtocol : true } ) ;
365+ await h . schedule ( pkg ( [ job ( 'a_job' ) ] ) , APP_ID ) ;
366+
367+ await expect ( h . reg . cleanups . get ( PACKAGE_JOBS_UNINSTALL_CLEANUP ) ! ( { packageId : 'com.example.never' } ) )
368+ . resolves . toEqual ( { success : true , removed : 0 } ) ;
369+ expect ( h . jobs . cancels ) . toEqual ( [ ] ) ;
370+ } ) ;
371+
372+ it ( 'a job it could not cancel is an outcome, never a throw — success:false naming the job' , async ( ) => {
373+ const h = harness ( { withProtocol : true , failCancel : 'stuck_job' } ) ;
374+ await h . schedule ( pkg ( [ job ( 'stuck_job' ) , job ( 'fine_job' ) ] ) , APP_ID ) ;
375+
376+ const result = await h . reg . cleanups . get ( PACKAGE_JOBS_UNINSTALL_CLEANUP ) ! ( { packageId : APP_ID } ) ;
377+
378+ expect ( result . success ) . toBe ( false ) ;
379+ expect ( result . removed ) . toBe ( 1 ) ;
380+ expect ( result . error ) . toContain ( 'stuck_job' ) ;
381+ } ) ;
382+ } ) ;
383+
261384describe ( '#21489: the sandbox job origin' , ( ) => {
262385 it ( "a job body with no timeoutMs gets the runner's JOB default — not the hook's, not the action's" , async ( ) => {
263386 const runner = new QuickJSScriptRunner ( { jobTimeoutMs : 30 , hookTimeoutMs : 5_000 , actionTimeoutMs : 5_000 } ) ;
0 commit comments