66
77import type { ITriggerFireContext } from "@workglow/triggers" ;
88import { IntervalTrigger , TriggerConfigurationError } from "@workglow/triggers" ;
9+ import type { ILogger } from "@workglow/util" ;
10+ import { getLogger , setLogger } from "@workglow/util" ;
911import { afterEach , beforeEach , describe , expect , test , vi } from "vitest" ;
1012
1113import { advanceFakeTimers , flushAsyncWork } from "../helpers/advanceFakeTimers" ;
@@ -26,6 +28,38 @@ function createGate(): Gate {
2628 return { promise, open } ;
2729}
2830
31+ interface WarnRecord {
32+ readonly message : string ;
33+ readonly meta : Record < string , unknown > | undefined ;
34+ }
35+
36+ /**
37+ * Installs a logger that records `warn` calls; returns the sink and a restore fn.
38+ * Built from scratch rather than spread from the installed logger, whose methods
39+ * live on a class prototype and would be lost.
40+ */
41+ function captureWarnings ( ) : { warnings : WarnRecord [ ] ; restore : ( ) => void } {
42+ const warnings : WarnRecord [ ] = [ ] ;
43+ const previous = getLogger ( ) ;
44+ const noop = ( ) : void => { } ;
45+ const logger : ILogger = {
46+ debug : noop ,
47+ info : noop ,
48+ warn : ( message : string , meta ?: Record < string , unknown > ) => {
49+ warnings . push ( { message, meta } ) ;
50+ } ,
51+ error : noop ,
52+ fatal : noop ,
53+ child : ( ) => logger ,
54+ time : noop ,
55+ timeEnd : noop ,
56+ group : noop ,
57+ groupEnd : noop ,
58+ } ;
59+ setLogger ( logger ) ;
60+ return { warnings, restore : ( ) => setLogger ( previous ) } ;
61+ }
62+
2963describe ( "IntervalTrigger" , ( ) => {
3064 beforeEach ( ( ) => {
3165 vi . useFakeTimers ( ) ;
@@ -286,6 +320,93 @@ describe("IntervalTrigger", () => {
286320 } ) ;
287321 } ) ;
288322
323+ describe ( "skip logging" , ( ) => {
324+ test ( "a contiguous run of skips logs once, then a count when it ends" , async ( ) => {
325+ // A wedged handler at a 1s period would otherwise write 3,600 identical
326+ // warnings an hour, forever.
327+ const { warnings, restore } = captureWarnings ( ) ;
328+ try {
329+ const trigger = new IntervalTrigger ( { intervalMs : PERIOD } ) ;
330+ const gate = createGate ( ) ;
331+ const skips : number [ ] = [ ] ;
332+ trigger . on ( "skip" , ( scheduledAt ) => skips . push ( scheduledAt ) ) ;
333+ trigger . start ( async ( ) => {
334+ await gate . promise ;
335+ } ) ;
336+
337+ await advanceFakeTimers ( PERIOD ) ;
338+ await advanceFakeTimers ( PERIOD * 4 ) ;
339+
340+ // The EVENT still fires per dropped tick; only the log is collapsed.
341+ expect ( skips ) . toHaveLength ( 4 ) ;
342+ expect ( warnings ) . toHaveLength ( 1 ) ;
343+ expect ( warnings [ 0 ] ?. message ) . toContain ( "skipped" ) ;
344+
345+ gate . open ( ) ;
346+ await flushAsyncWork ( ) ;
347+ await advanceFakeTimers ( PERIOD ) ;
348+
349+ expect ( warnings ) . toHaveLength ( 2 ) ;
350+ expect ( warnings [ 1 ] ?. meta ?. skipped ) . toBe ( 4 ) ;
351+
352+ await trigger . stop ( ) ;
353+ } finally {
354+ restore ( ) ;
355+ }
356+ } ) ;
357+
358+ test ( "an isolated skip logs once and adds no summary" , async ( ) => {
359+ // The ordinary intermittent case must not have its log volume doubled.
360+ const { warnings, restore } = captureWarnings ( ) ;
361+ try {
362+ const trigger = new IntervalTrigger ( { intervalMs : PERIOD } ) ;
363+ const gate = createGate ( ) ;
364+ const skips : number [ ] = [ ] ;
365+ trigger . on ( "skip" , ( scheduledAt ) => skips . push ( scheduledAt ) ) ;
366+ trigger . start ( async ( ) => {
367+ await gate . promise ;
368+ } ) ;
369+
370+ await advanceFakeTimers ( PERIOD * 2 ) ;
371+ expect ( skips ) . toHaveLength ( 1 ) ;
372+
373+ gate . open ( ) ;
374+ await flushAsyncWork ( ) ;
375+ await advanceFakeTimers ( PERIOD ) ;
376+
377+ expect ( warnings ) . toHaveLength ( 1 ) ;
378+
379+ await trigger . stop ( ) ;
380+ } finally {
381+ restore ( ) ;
382+ }
383+ } ) ;
384+
385+ test ( "stopping mid-skip still reports the skip count" , async ( ) => {
386+ const { warnings, restore } = captureWarnings ( ) ;
387+ try {
388+ const trigger = new IntervalTrigger ( { intervalMs : PERIOD } ) ;
389+ const gate = createGate ( ) ;
390+ const skips : number [ ] = [ ] ;
391+ trigger . on ( "skip" , ( scheduledAt ) => skips . push ( scheduledAt ) ) ;
392+ trigger . start ( async ( ) => {
393+ await gate . promise ;
394+ } ) ;
395+
396+ await advanceFakeTimers ( PERIOD * 4 ) ;
397+ expect ( skips ) . toHaveLength ( 3 ) ;
398+
399+ const stopping = trigger . stop ( ) ;
400+ expect ( warnings . map ( ( warning ) => warning . meta ?. skipped ) ) . toContain ( 3 ) ;
401+
402+ gate . open ( ) ;
403+ await stopping ;
404+ } finally {
405+ restore ( ) ;
406+ }
407+ } ) ;
408+ } ) ;
409+
289410 test ( "a period past the host timer ceiling is served in chunks" , async ( ) => {
290411 // A delay over 2^31-1 ms overflows a host timer and fires IMMEDIATELY, so
291412 // an unchunked wait would turn a very long period into a hot loop.
@@ -515,6 +636,35 @@ describe("IntervalTrigger", () => {
515636 await trigger . stop ( ) ;
516637 } ) ;
517638
639+ test ( "a restart during stop() does not emit stop while the trigger is running" , async ( ) => {
640+ // An observer that sees `stop` while `running` is true concludes the
641+ // trigger is dead and stops watching it — while it is actively firing.
642+ const trigger = new IntervalTrigger ( { intervalMs : PERIOD } ) ;
643+ const gate = createGate ( ) ;
644+ const runningAtStop : boolean [ ] = [ ] ;
645+ trigger . on ( "stop" , ( ) => runningAtStop . push ( trigger . running ) ) ;
646+
647+ trigger . start ( async ( ) => {
648+ await gate . promise ;
649+ } ) ;
650+ await advanceFakeTimers ( PERIOD ) ;
651+
652+ // NOT awaited: the gated gen-1 handler keeps the old run draining.
653+ const stopping = trigger . stop ( ) ;
654+ trigger . start ( ( ) => { } ) ;
655+
656+ gate . open ( ) ;
657+ await stopping ;
658+
659+ // Gen 1 drained, but gen 2 owns the trigger.
660+ expect ( runningAtStop ) . toEqual ( [ ] ) ;
661+ expect ( trigger . running ) . toBe ( true ) ;
662+
663+ // ...and the real stop still reports itself.
664+ await trigger . stop ( ) ;
665+ expect ( runningAtStop ) . toEqual ( [ false ] ) ;
666+ } ) ;
667+
518668 test ( "a restart during stop() does not emit spurious skips (skip policy)" , async ( ) => {
519669 // The first generation's handler is still gated when the second starts.
520670 // Overlap state belongs to a RUN, not to the trigger: if the new
0 commit comments