@reventlessdev/reventless-local 3.0.0-alpha.285 → 3.0.0-alpha.286
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/package.json +9 -9
- package/src/adapter/Scheduler/LocalScheduledPublisher.res +43 -36
- package/src/adapter/Scheduler/LocalScheduledPublisher.res.mjs +32 -25
- package/src/components/AutomationSlice_Builder.res +10 -2
- package/src/components/AutomationSlice_Builder.res.mjs +13 -2
- package/src/components/LocalAutomationSweep.res +61 -0
- package/src/components/LocalAutomationSweep.res.mjs +83 -0
- package/src/test/TestRunner.res +1 -0
- package/src/test/TestRunner.res.mjs +6 -0
- package/tests/adapter/ScheduledPublisherTest.res +156 -155
- package/tests/adapter/ScheduledPublisherTest.res.mjs +239 -137
- package/tests/components/automationslice/AutomationSliceFixtures.res +2 -2
- package/tests/components/automationslice/AutomationSliceFixtures.res.mjs +2 -2
- package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res +4 -1
- package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res.mjs +1 -1
- package/tests/components/automationslice/AutomationSweepFixtures.res +129 -0
- package/tests/components/automationslice/AutomationSweepFixtures.res.mjs +217 -0
- package/tests/components/automationslice/AutomationSweepTest.res +107 -0
- package/tests/components/automationslice/AutomationSweepTest.res.mjs +116 -0
- package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res +1 -1
- package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res.mjs +1 -1
- package/tests/components/scheduler/SchedulerTest.res +5 -5
- package/tests/components/scheduler/SchedulerTest.res.mjs +22 -14
|
@@ -1,41 +1,27 @@
|
|
|
1
1
|
// Unit tests for LocalScheduledPublisher.
|
|
2
|
-
// Uses fake timers
|
|
2
|
+
// Uses fake timers, which also fix Date.now, to check each shape of a time fires when
|
|
3
|
+
// ScheduleTime says it does.
|
|
3
4
|
|
|
4
5
|
open JestGlobals
|
|
5
6
|
|
|
6
|
-
// ─────────────────────────────────────────────────────────────
|
|
7
|
-
// Fake timer bindings
|
|
8
|
-
// ─────────────────────────────────────────────────────────────
|
|
9
|
-
|
|
10
7
|
type jestObj
|
|
11
8
|
@module("@jest/globals") external jest: jestObj = "jest"
|
|
12
9
|
@send external useFakeTimers: jestObj => unit = "useFakeTimers"
|
|
13
10
|
@send external useRealTimers: jestObj => unit = "useRealTimers"
|
|
14
|
-
@send external
|
|
15
|
-
@send external
|
|
11
|
+
@send external advanceTimersByTime: (jestObj, float) => unit = "advanceTimersByTime"
|
|
12
|
+
@send external setSystemTime: (jestObj, float) => unit = "setSystemTime"
|
|
16
13
|
|
|
17
14
|
let _ = beforeAll(() => {
|
|
18
15
|
jest->useFakeTimers
|
|
19
16
|
})
|
|
20
17
|
|
|
21
|
-
// ─────────────────────────────────────────────────────────────
|
|
22
18
|
// Pulumi mock (needed for Pulumi.Output.make in ScheduledPublisher)
|
|
23
|
-
// ─────────────────────────────────────────────────────────────
|
|
24
|
-
|
|
25
19
|
let _ = TestRunner.setup()
|
|
26
20
|
|
|
27
|
-
// ─────────────────────────────────────────────────────────────
|
|
28
|
-
// Restore real timers after all tests in this file
|
|
29
|
-
// ─────────────────────────────────────────────────────────────
|
|
30
|
-
|
|
31
21
|
let _ = afterAll(() => {
|
|
32
22
|
jest->useRealTimers
|
|
33
23
|
})
|
|
34
24
|
|
|
35
|
-
// ─────────────────────────────────────────────────────────────
|
|
36
|
-
// Helper: create a topic resource with a known name
|
|
37
|
-
// ─────────────────────────────────────────────────────────────
|
|
38
|
-
|
|
39
25
|
let makeTopicResource = (topicName: string): ReventlessInfra.Adapter.resolvedResource => {
|
|
40
26
|
name: topicName,
|
|
41
27
|
id: topicName,
|
|
@@ -49,186 +35,201 @@ let makeTopicResource = (topicName: string): ReventlessInfra.Adapter.resolvedRes
|
|
|
49
35
|
tags: Dict.make(),
|
|
50
36
|
}
|
|
51
37
|
|
|
38
|
+
let minute = 60_000.
|
|
39
|
+
let hour = 60. *. minute
|
|
40
|
+
let day = 24. *. hour
|
|
41
|
+
let at = iso => Date.fromString(iso)->Date.getTime
|
|
42
|
+
|
|
43
|
+
// Two microtask ticks: publishEvent uses Effect.all(concurrency=unbounded) which forks
|
|
44
|
+
// child fibers — one tick for the child fiber to offer to the Queue, a second for the
|
|
45
|
+
// drain fiber to run the callback.
|
|
46
|
+
let flush = async () => {
|
|
47
|
+
let _ = await Promise.resolve()
|
|
48
|
+
let _ = await Promise.resolve()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let advance = async ms => {
|
|
52
|
+
jest->advanceTimersByTime(ms)
|
|
53
|
+
await flush()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module TestBus = LocalBus.Make()
|
|
57
|
+
module SP = LocalScheduledPublisher.Make(TestBus)
|
|
58
|
+
|
|
59
|
+
let topicCount = ref(0)
|
|
60
|
+
let fired = topic => {
|
|
61
|
+
let count = ref(0)
|
|
62
|
+
TestBus.subscribeToEvents(topic, async (_, _, _) => {
|
|
63
|
+
count := count.contents + 1
|
|
64
|
+
})
|
|
65
|
+
count
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A fresh topic per test, so a subscriber never sees another test's firings.
|
|
69
|
+
let schedule = async (~now, time) => {
|
|
70
|
+
jest->setSystemTime(now->at)
|
|
71
|
+
topicCount := topicCount.contents + 1
|
|
72
|
+
let topic = `sched-topic-${topicCount.contents->Int.toString}`
|
|
73
|
+
let count = fired(topic)
|
|
74
|
+
let ops = await SP.make(~name="scheduler", ~opts={}).operations->TestRunner.resolve
|
|
75
|
+
await ops.createSchedule([makeTopicResource(topic)], {name: topic, time, payload: "{}"})
|
|
76
|
+
(ops, topic, count)
|
|
77
|
+
}
|
|
78
|
+
|
|
52
79
|
describe("LocalScheduledPublisher", () => {
|
|
53
|
-
|
|
80
|
+
let _ = afterEach(() => SP.reset())
|
|
81
|
+
|
|
82
|
+
describe("Every", () => {
|
|
54
83
|
testPromise(
|
|
55
|
-
"
|
|
84
|
+
"fires one interval after it is set, and again each interval",
|
|
56
85
|
async () => {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
86
|
+
let (_, _, count) = await schedule(~now="2026-10-16T10:00:00Z", Every(Minutes(3)))
|
|
87
|
+
await advance(3. *. minute -. 1.)
|
|
88
|
+
expect(count.contents)->toBe(0)
|
|
89
|
+
await advance(1.)
|
|
90
|
+
expect(count.contents)->toBe(1)
|
|
91
|
+
await advance(3. *. minute)
|
|
92
|
+
expect(count.contents)->toBe(2)
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
testPromise(
|
|
97
|
+
"Hours(n) fires after n hours",
|
|
98
|
+
async () => {
|
|
99
|
+
let (_, _, count) = await schedule(~now="2026-10-16T10:00:00Z", Every(Hours(2)))
|
|
100
|
+
await advance(2. *. hour)
|
|
101
|
+
expect(count.contents)->toBe(1)
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe("Calendar", () => {
|
|
107
|
+
testPromise(
|
|
108
|
+
"fires at the wall-clock time each day, not 24 hours after it is set",
|
|
109
|
+
async () => {
|
|
110
|
+
let (_, _, count) = await schedule(
|
|
111
|
+
~now="2026-10-16T05:58:00Z",
|
|
112
|
+
Calendar({calendar: Daily({hour: 6, minute: 0})}),
|
|
65
113
|
)
|
|
66
|
-
|
|
67
|
-
let ops = await pub.operations->TestRunner.resolve
|
|
68
|
-
let schedule: Reventless.Schedule.schedule = {
|
|
69
|
-
name: "one-shot",
|
|
70
|
-
rate: Reventless.Schedule.Single(2024, 1, 1, 0, 0),
|
|
71
|
-
payload: "{}",
|
|
72
|
-
}
|
|
73
|
-
await ops.createSchedule([makeTopicResource("sched-topic")], schedule)
|
|
74
|
-
jest->runAllTimers
|
|
75
|
-
// Two microtask ticks: publishEvent uses Effect.all(concurrency=unbounded) which forks
|
|
76
|
-
// child fibers via startFork — one tick for the child fiber to offer to the Queue,
|
|
77
|
-
// a second tick for the drain fiber to run the callback.
|
|
78
|
-
let _ = await Promise.resolve()
|
|
79
|
-
let _ = await Promise.resolve()
|
|
114
|
+
await advance(2. *. minute)
|
|
80
115
|
expect(count.contents)->toBe(1)
|
|
81
|
-
|
|
116
|
+
await advance(day)
|
|
117
|
+
expect(count.contents)->toBe(2)
|
|
82
118
|
},
|
|
83
119
|
)
|
|
84
120
|
|
|
85
121
|
testPromise(
|
|
86
|
-
"
|
|
122
|
+
"skips a day whose time falls in the hour daylight saving skips",
|
|
87
123
|
async () => {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
TestBus.subscribeToEvents(
|
|
92
|
-
"repeat-topic",
|
|
93
|
-
async (_, _, _) => {
|
|
94
|
-
count := count.contents + 1
|
|
95
|
-
},
|
|
124
|
+
let (_, _, count) = await schedule(
|
|
125
|
+
~now="2027-03-27T12:00:00Z",
|
|
126
|
+
Calendar({calendar: Daily({hour: 2, minute: 30}), zone: "Europe/Berlin"}),
|
|
96
127
|
)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
rate: Reventless.Schedule.Minutes(1),
|
|
102
|
-
payload: "{}",
|
|
103
|
-
}
|
|
104
|
-
await ops.createSchedule([makeTopicResource("repeat-topic")], schedule)
|
|
105
|
-
jest->advanceTimersByTime(60 * 1000)
|
|
106
|
-
let _ = await Promise.resolve()
|
|
107
|
-
let _ = await Promise.resolve()
|
|
108
|
-
jest->advanceTimersByTime(60 * 1000)
|
|
109
|
-
let _ = await Promise.resolve()
|
|
110
|
-
let _ = await Promise.resolve()
|
|
128
|
+
// 28 March fires; 29 March has no 02:30 in Berlin; 30 March fires
|
|
129
|
+
await advance(at("2027-03-29T12:00:00Z") -. at("2027-03-27T12:00:00Z"))
|
|
130
|
+
expect(count.contents)->toBe(1)
|
|
131
|
+
await advance(at("2027-03-30T12:00:00Z") -. at("2027-03-29T12:00:00Z"))
|
|
111
132
|
expect(count.contents)->toBe(2)
|
|
112
|
-
SP.reset()
|
|
113
133
|
},
|
|
114
134
|
)
|
|
115
|
-
})
|
|
116
135
|
|
|
117
|
-
describe("deleteSchedule", () => {
|
|
118
136
|
testPromise(
|
|
119
|
-
"
|
|
137
|
+
"reaches a firing further away than a timer can wait",
|
|
120
138
|
async () => {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
TestBus.subscribeToEvents(
|
|
125
|
-
"del-topic",
|
|
126
|
-
async (_, _, _) => {
|
|
127
|
-
count := count.contents + 1
|
|
128
|
-
},
|
|
139
|
+
let (_, _, count) = await schedule(
|
|
140
|
+
~now="2026-10-16T10:00:00Z",
|
|
141
|
+
Calendar({calendar: Yearly({month: 1, day: 1, hour: 6, minute: 0})}),
|
|
129
142
|
)
|
|
130
|
-
|
|
131
|
-
let ops = await pub.operations->TestRunner.resolve
|
|
132
|
-
let schedule: Reventless.Schedule.schedule = {
|
|
133
|
-
name: "to-delete",
|
|
134
|
-
rate: Reventless.Schedule.Minutes(1),
|
|
135
|
-
payload: "{}",
|
|
136
|
-
}
|
|
137
|
-
await ops.createSchedule([makeTopicResource("del-topic")], schedule)
|
|
138
|
-
await ops.deleteSchedule([], "to-delete")
|
|
139
|
-
jest->advanceTimersByTime(60 * 1000)
|
|
143
|
+
await advance(at("2027-01-01T05:59:00Z") -. at("2026-10-16T10:00:00Z"))
|
|
140
144
|
expect(count.contents)->toBe(0)
|
|
141
|
-
|
|
145
|
+
await advance(minute)
|
|
146
|
+
expect(count.contents)->toBe(1)
|
|
142
147
|
},
|
|
143
148
|
)
|
|
144
149
|
})
|
|
145
150
|
|
|
146
|
-
describe("
|
|
151
|
+
describe("Once", () => {
|
|
147
152
|
testPromise(
|
|
148
|
-
"
|
|
153
|
+
"fires once, a period after it is set",
|
|
149
154
|
async () => {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
TestBus.subscribeToEvents(
|
|
154
|
-
"reset-topic",
|
|
155
|
-
async (_, _, _) => {
|
|
156
|
-
count := count.contents + 1
|
|
157
|
-
},
|
|
155
|
+
let (_, _, count) = await schedule(
|
|
156
|
+
~now="2026-10-16T10:00:00Z",
|
|
157
|
+
Once({moment: After(Minutes(10))}),
|
|
158
158
|
)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
159
|
+
await advance(10. *. minute)
|
|
160
|
+
expect(count.contents)->toBe(1)
|
|
161
|
+
await advance(day)
|
|
162
|
+
expect(count.contents)->toBe(1)
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
testPromise(
|
|
167
|
+
"never fires at a moment already past",
|
|
168
|
+
async () => {
|
|
169
|
+
let (_, _, count) = await schedule(
|
|
170
|
+
~now="2026-10-16T10:00:00Z",
|
|
171
|
+
Once({moment: At({year: 2024, month: 1, day: 1, hour: 0, minute: 0})}),
|
|
172
|
+
)
|
|
173
|
+
await advance(day)
|
|
169
174
|
expect(count.contents)->toBe(0)
|
|
170
175
|
},
|
|
171
176
|
)
|
|
172
177
|
})
|
|
173
178
|
|
|
174
|
-
describe("
|
|
179
|
+
describe("createSchedule", () => {
|
|
175
180
|
testPromise(
|
|
176
|
-
"
|
|
181
|
+
"replaces a schedule of the same name",
|
|
177
182
|
async () => {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
"rate-topic",
|
|
183
|
-
async (_, _, _) => {
|
|
184
|
-
count := count.contents + 1
|
|
185
|
-
},
|
|
183
|
+
let (ops, topic, count) = await schedule(~now="2026-10-16T10:00:00Z", Every(Minutes(1)))
|
|
184
|
+
await ops.createSchedule(
|
|
185
|
+
[makeTopicResource(topic)],
|
|
186
|
+
{name: topic, time: Every(Minutes(5)), payload: "{}"},
|
|
186
187
|
)
|
|
187
|
-
|
|
188
|
-
let ops = await pub.operations->TestRunner.resolve
|
|
189
|
-
let schedule: Reventless.Schedule.schedule = {
|
|
190
|
-
name: "rate-sched",
|
|
191
|
-
rate: Reventless.Schedule.Minutes(3),
|
|
192
|
-
payload: "{}",
|
|
193
|
-
}
|
|
194
|
-
await ops.createSchedule([makeTopicResource("rate-topic")], schedule)
|
|
195
|
-
// Advance by slightly less than 3 minutes — should not fire
|
|
196
|
-
jest->advanceTimersByTime(3 * 60 * 1000 - 1)
|
|
197
|
-
expect(count.contents)->toBe(0)
|
|
198
|
-
// Advance the remaining millisecond — now fires; two ticks for Effect.all drain
|
|
199
|
-
jest->advanceTimersByTime(1)
|
|
200
|
-
let _ = await Promise.resolve()
|
|
201
|
-
let _ = await Promise.resolve()
|
|
188
|
+
await advance(5. *. minute)
|
|
202
189
|
expect(count.contents)->toBe(1)
|
|
203
|
-
SP.reset()
|
|
204
190
|
},
|
|
205
191
|
)
|
|
206
192
|
|
|
207
193
|
testPromise(
|
|
208
|
-
"
|
|
194
|
+
"refuses a time that cannot fire",
|
|
209
195
|
async () => {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
196
|
+
let ops = await SP.make(~name="scheduler", ~opts={}).operations->TestRunner.resolve
|
|
197
|
+
let refused = switch await ops.createSchedule(
|
|
198
|
+
[makeTopicResource("refused")],
|
|
199
|
+
{
|
|
200
|
+
name: "refused",
|
|
201
|
+
time: Calendar({calendar: Daily({hour: 25, minute: 0}), zone: "Mars/Base"}),
|
|
202
|
+
payload: "{}",
|
|
217
203
|
},
|
|
218
|
-
)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
let schedule: Reventless.Schedule.schedule = {
|
|
222
|
-
name: "hours-sched",
|
|
223
|
-
rate: Reventless.Schedule.Hours(2),
|
|
224
|
-
payload: "{}",
|
|
204
|
+
) {
|
|
205
|
+
| _ => false
|
|
206
|
+
| exception ReventlessCore.ScheduleTime.InvalidTime(_) => true
|
|
225
207
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
208
|
+
expect(refused)->toBe(true)
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe("deleteSchedule", () => {
|
|
214
|
+
testPromise(
|
|
215
|
+
"stops a schedule; advancing timers does not fire it",
|
|
216
|
+
async () => {
|
|
217
|
+
let (ops, topic, count) = await schedule(~now="2026-10-16T10:00:00Z", Every(Minutes(1)))
|
|
218
|
+
await ops.deleteSchedule([], topic)
|
|
219
|
+
await advance(minute)
|
|
220
|
+
expect(count.contents)->toBe(0)
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
describe("reset", () => {
|
|
226
|
+
testPromise(
|
|
227
|
+
"clears all active timers; subsequent advance does not fire",
|
|
228
|
+
async () => {
|
|
229
|
+
let (_, _, count) = await schedule(~now="2026-10-16T10:00:00Z", Every(Minutes(5)))
|
|
231
230
|
SP.reset()
|
|
231
|
+
await advance(5. *. minute)
|
|
232
|
+
expect(count.contents)->toBe(0)
|
|
232
233
|
},
|
|
233
234
|
)
|
|
234
235
|
})
|