@checkstack/automation-backend 0.2.0

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +453 -0
  2. package/drizzle/0000_acoustic_diamondback.sql +80 -0
  3. package/drizzle/0001_mute_vindicator.sql +12 -0
  4. package/drizzle/0002_silky_omega_red.sql +12 -0
  5. package/drizzle/meta/0000_snapshot.json +688 -0
  6. package/drizzle/meta/0001_snapshot.json +785 -0
  7. package/drizzle/meta/0002_snapshot.json +861 -0
  8. package/drizzle/meta/_journal.json +27 -0
  9. package/drizzle.config.ts +12 -0
  10. package/package.json +41 -0
  11. package/src/action-registry.ts +83 -0
  12. package/src/action-types.ts +324 -0
  13. package/src/artifact-store.ts +140 -0
  14. package/src/artifact-type-registry.ts +64 -0
  15. package/src/automation-store.ts +227 -0
  16. package/src/builtin-actions.test.ts +185 -0
  17. package/src/builtin-actions.ts +132 -0
  18. package/src/builtin-triggers.test.ts +264 -0
  19. package/src/builtin-triggers.ts +365 -0
  20. package/src/dispatch/action-kind.ts +44 -0
  21. package/src/dispatch/condition.ts +61 -0
  22. package/src/dispatch/delay-queue.ts +91 -0
  23. package/src/dispatch/engine.test.ts +1198 -0
  24. package/src/dispatch/engine.ts +1672 -0
  25. package/src/dispatch/path-nav.ts +65 -0
  26. package/src/dispatch/render.test.ts +75 -0
  27. package/src/dispatch/render.ts +136 -0
  28. package/src/dispatch/run-state-store.ts +143 -0
  29. package/src/dispatch/run-state.ts +298 -0
  30. package/src/dispatch/scope.test.ts +40 -0
  31. package/src/dispatch/scope.ts +125 -0
  32. package/src/dispatch/stalled-sweeper.ts +164 -0
  33. package/src/dispatch/test-fixtures.ts +558 -0
  34. package/src/dispatch/trigger-subscriber.ts +397 -0
  35. package/src/dispatch/types.ts +259 -0
  36. package/src/extension-points.ts +88 -0
  37. package/src/index.ts +379 -0
  38. package/src/migration/from-webhook-subscriptions.test.ts +237 -0
  39. package/src/migration/from-webhook-subscriptions.ts +398 -0
  40. package/src/registries.test.ts +357 -0
  41. package/src/router.test.ts +724 -0
  42. package/src/router.ts +556 -0
  43. package/src/schema.ts +310 -0
  44. package/src/trigger-registry.ts +99 -0
  45. package/src/validate-definition.test.ts +306 -0
  46. package/src/validate-definition.ts +304 -0
  47. package/tsconfig.json +41 -0
@@ -0,0 +1,724 @@
1
+ /**
2
+ * Router behaviour tests.
3
+ *
4
+ * Each handler is exercised via oRPC's `call()` test helper so the
5
+ * contract's middleware (auth, access checks, input coercion) runs the
6
+ * same way it does in production.
7
+ *
8
+ * For DB-backed handlers (`listRuns`, `getRun`, `cancelRun`) we stub the
9
+ * drizzle chain just enough to satisfy the call shape. The richer
10
+ * round-trip behaviour is covered by integration tests against a real DB
11
+ * via the dispatch engine tests.
12
+ */
13
+ import { describe, it, expect, beforeEach, mock } from "bun:test";
14
+ import { z } from "zod";
15
+ import { call } from "@orpc/server";
16
+ import {
17
+ createHook,
18
+ createMockRpcContext,
19
+ Versioned,
20
+ type RpcContext,
21
+ } from "@checkstack/backend-api";
22
+ import {
23
+ AUTOMATION_DEFINITION_CHANGED,
24
+ AUTOMATION_RUN_COMPLETED,
25
+ type Automation,
26
+ type AutomationDefinition,
27
+ type CreateAutomationInput,
28
+ type UpdateAutomationInput,
29
+ } from "@checkstack/automation-common";
30
+ import {
31
+ createMockSignalService,
32
+ type MockSignalService,
33
+ } from "@checkstack/test-utils-backend";
34
+
35
+ import {
36
+ createActionRegistry,
37
+ type ActionRegistry,
38
+ } from "./action-registry";
39
+ import {
40
+ createArtifactTypeRegistry,
41
+ type ArtifactTypeRegistry,
42
+ } from "./artifact-type-registry";
43
+ import {
44
+ createTriggerRegistry,
45
+ type TriggerRegistry,
46
+ } from "./trigger-registry";
47
+ import type {
48
+ ActionDefinition,
49
+ ArtifactTypeDefinition,
50
+ TriggerDefinition,
51
+ } from "./action-types";
52
+ import type { AutomationStore } from "./automation-store";
53
+ import { createAutomationRouter, type AutomationRouter } from "./router";
54
+ import { makeDispatchDeps } from "./dispatch/test-fixtures";
55
+
56
+ // ─── Test plugin metadata ────────────────────────────────────────────────
57
+
58
+ const testPlugin = { pluginId: "test" } as const;
59
+
60
+ // ─── Sample fixtures ─────────────────────────────────────────────────────
61
+
62
+ const samplePayloadSchema = z.object({ incidentId: z.string() });
63
+ const sampleHook = createHook<{ incidentId: string }>("incident.created");
64
+
65
+ const sampleTrigger: TriggerDefinition<{ incidentId: string }> = {
66
+ id: "incident.created",
67
+ displayName: "Incident Created",
68
+ category: "Incidents",
69
+ payloadSchema: samplePayloadSchema,
70
+ hook: sampleHook,
71
+ contextKey: (p) => p.incidentId,
72
+ };
73
+
74
+ const sampleAction: ActionDefinition<{ message: string }, { id: string }> = {
75
+ id: "noop",
76
+ displayName: "No-op",
77
+ category: "Test",
78
+ config: new Versioned({
79
+ version: 1,
80
+ schema: z.object({ message: z.string() }),
81
+ }),
82
+ // Local artifact id; the registry qualifies it to `test.thing`.
83
+ produces: "thing",
84
+ execute: async () => ({ success: true, artifact: { id: "x" } }),
85
+ };
86
+
87
+ const sampleArtifactType: ArtifactTypeDefinition<{ id: string }> = {
88
+ id: "thing",
89
+ displayName: "Thing",
90
+ description: "A test artifact",
91
+ schema: z.object({ id: z.string() }),
92
+ };
93
+
94
+ const sampleDefinition: AutomationDefinition = {
95
+ name: "Sample",
96
+ triggers: [{ id: "fire", event: "test.incident.created" }],
97
+ conditions: [],
98
+ actions: [],
99
+ mode: "single",
100
+ max_runs: 10,
101
+ };
102
+
103
+ // ─── In-memory automation store ──────────────────────────────────────────
104
+
105
+ function createInMemoryAutomationStore(): {
106
+ store: AutomationStore;
107
+ rows: Map<string, Automation>;
108
+ } {
109
+ const rows = new Map<string, Automation>();
110
+ let counter = 0;
111
+ const now = () => new Date();
112
+
113
+ const store: AutomationStore = {
114
+ async create(input: CreateAutomationInput) {
115
+ const id = `auto-${++counter}`;
116
+ const automation: Automation = {
117
+ id,
118
+ name: input.name,
119
+ description: input.description,
120
+ status: input.status,
121
+ definition: input.definition,
122
+ createdAt: now(),
123
+ updatedAt: now(),
124
+ };
125
+ rows.set(id, automation);
126
+ return automation;
127
+ },
128
+ async update(input: UpdateAutomationInput) {
129
+ const existing = rows.get(input.id);
130
+ if (!existing) throw new Error(`Automation ${input.id} not found`);
131
+ const next: Automation = {
132
+ ...existing,
133
+ name: input.name ?? existing.name,
134
+ description: input.description ?? existing.description,
135
+ status: input.status ?? existing.status,
136
+ definition: input.definition ?? existing.definition,
137
+ updatedAt: now(),
138
+ };
139
+ rows.set(input.id, next);
140
+ return next;
141
+ },
142
+ async delete(id) {
143
+ rows.delete(id);
144
+ },
145
+ async toggle(id, enabled) {
146
+ const existing = rows.get(id);
147
+ if (!existing) throw new Error(`Automation ${id} not found`);
148
+ const next: Automation = {
149
+ ...existing,
150
+ status: enabled ? "enabled" : "disabled",
151
+ updatedAt: now(),
152
+ };
153
+ rows.set(id, next);
154
+ return next;
155
+ },
156
+ async getById(id) {
157
+ return rows.get(id);
158
+ },
159
+ async list(filter) {
160
+ const limit = filter?.limit ?? 50;
161
+ const offset = filter?.offset ?? 0;
162
+ const all = [...rows.values()].filter(
163
+ (a) => !filter?.status || a.status === filter.status,
164
+ );
165
+ return {
166
+ items: all.slice(offset, offset + limit),
167
+ total: all.length,
168
+ };
169
+ },
170
+ async findEnabledByTriggerEvent() {
171
+ return [];
172
+ },
173
+ async listEnabled() {
174
+ return [];
175
+ },
176
+ };
177
+
178
+ return { store, rows };
179
+ }
180
+
181
+ // ─── Fluent drizzle-chain mock helpers ───────────────────────────────────
182
+
183
+ /**
184
+ * Build a fluent stub for a `db.select().from(...).where(...).orderBy().limit().offset()`
185
+ * chain. The terminal call resolves to `rows`. Suitable for both the
186
+ * `await` form and the `.limit/.orderBy/...` chained form.
187
+ */
188
+ interface FluentSelectTail extends Promise<unknown[]> {
189
+ where: ReturnType<typeof mock>;
190
+ orderBy: ReturnType<typeof mock>;
191
+ limit: ReturnType<typeof mock>;
192
+ offset: ReturnType<typeof mock>;
193
+ }
194
+
195
+ function fluentSelect(rows: unknown[]) {
196
+ const terminal = Promise.resolve(rows);
197
+ const tail: FluentSelectTail = Object.assign(terminal, {
198
+ where: mock(() => tail),
199
+ orderBy: mock(() => tail),
200
+ limit: mock(() => tail),
201
+ offset: mock(() => tail),
202
+ });
203
+ return {
204
+ from: mock(() => tail),
205
+ };
206
+ }
207
+
208
+ function fluentInsertReturning(row: unknown) {
209
+ return {
210
+ values: mock(() => ({
211
+ returning: mock(() => Promise.resolve([row])),
212
+ })),
213
+ };
214
+ }
215
+
216
+ function fluentUpdate() {
217
+ return {
218
+ set: mock(() => ({
219
+ where: mock(() => Promise.resolve()),
220
+ })),
221
+ };
222
+ }
223
+
224
+ function fluentDelete() {
225
+ return {
226
+ where: mock(() => Promise.resolve()),
227
+ };
228
+ }
229
+
230
+ // ─── Shared per-test deps ─────────────────────────────────────────────────
231
+
232
+ interface RouterHarness {
233
+ router: AutomationRouter;
234
+ context: RpcContext;
235
+ automationStore: AutomationStore;
236
+ triggerRegistry: TriggerRegistry;
237
+ actionRegistry: ActionRegistry;
238
+ artifactTypeRegistry: ArtifactTypeRegistry;
239
+ signalService: MockSignalService;
240
+ automationRows: Map<string, Automation>;
241
+ db: ReturnType<typeof createMockDbForRouter>;
242
+ }
243
+
244
+ function createMockDbForRouter() {
245
+ return {
246
+ select: mock(() => fluentSelect([])),
247
+ insert: mock(() => fluentInsertReturning(undefined)),
248
+ update: mock(() => fluentUpdate()),
249
+ delete: mock(() => fluentDelete()),
250
+ };
251
+ }
252
+
253
+ function makeRouter(): RouterHarness {
254
+ const { store: automationStore, rows: automationRows } =
255
+ createInMemoryAutomationStore();
256
+
257
+ const triggerRegistry = createTriggerRegistry();
258
+ triggerRegistry.register(sampleTrigger, testPlugin);
259
+
260
+ const actionRegistry = createActionRegistry();
261
+ actionRegistry.register(sampleAction, testPlugin);
262
+
263
+ const artifactTypeRegistry = createArtifactTypeRegistry();
264
+ artifactTypeRegistry.register(sampleArtifactType, testPlugin);
265
+
266
+ const signalService = createMockSignalService();
267
+ const db = createMockDbForRouter();
268
+
269
+ const { deps: dispatchDeps } = makeDispatchDeps({
270
+ actions: actionRegistry,
271
+ artifactTypes: artifactTypeRegistry,
272
+ triggers: triggerRegistry,
273
+ });
274
+
275
+ const router = createAutomationRouter({
276
+ db: db as never,
277
+ automationStore,
278
+ triggerRegistry,
279
+ actionRegistry,
280
+ artifactTypeRegistry,
281
+ dispatchDeps,
282
+ signalService,
283
+ logger: dispatchDeps.logger,
284
+ });
285
+
286
+ const context = createMockRpcContext();
287
+
288
+ return {
289
+ router,
290
+ context,
291
+ automationStore,
292
+ triggerRegistry,
293
+ actionRegistry,
294
+ artifactTypeRegistry,
295
+ signalService,
296
+ automationRows,
297
+ db,
298
+ };
299
+ }
300
+
301
+ // ─── Tests ───────────────────────────────────────────────────────────────
302
+
303
+ describe("Automation Router", () => {
304
+ let h: RouterHarness;
305
+ beforeEach(() => {
306
+ h = makeRouter();
307
+ });
308
+
309
+ // ─── CRUD ──────────────────────────────────────────────────────────────
310
+
311
+ describe("listAutomations", () => {
312
+ it("paginates the in-memory store and returns total", async () => {
313
+ await h.automationStore.create({
314
+ name: "A",
315
+ status: "enabled",
316
+ definition: sampleDefinition,
317
+ });
318
+ await h.automationStore.create({
319
+ name: "B",
320
+ status: "disabled",
321
+ definition: sampleDefinition,
322
+ });
323
+ const res = await call(
324
+ h.router.listAutomations,
325
+ { limit: 50, offset: 0 },
326
+ { context: h.context },
327
+ );
328
+ expect(res.total).toBe(2);
329
+ expect(res.items).toHaveLength(2);
330
+ });
331
+
332
+ it("filters by status", async () => {
333
+ await h.automationStore.create({
334
+ name: "A",
335
+ status: "enabled",
336
+ definition: sampleDefinition,
337
+ });
338
+ await h.automationStore.create({
339
+ name: "B",
340
+ status: "disabled",
341
+ definition: sampleDefinition,
342
+ });
343
+ const res = await call(
344
+ h.router.listAutomations,
345
+ { limit: 50, offset: 0, status: "enabled" },
346
+ { context: h.context },
347
+ );
348
+ expect(res.total).toBe(1);
349
+ expect(res.items[0]?.name).toBe("A");
350
+ });
351
+ });
352
+
353
+ describe("getAutomation", () => {
354
+ it("returns the row by id", async () => {
355
+ const created = await h.automationStore.create({
356
+ name: "A",
357
+ status: "enabled",
358
+ definition: sampleDefinition,
359
+ });
360
+ const res = await call(
361
+ h.router.getAutomation,
362
+ { id: created.id },
363
+ { context: h.context },
364
+ );
365
+ expect(res.id).toBe(created.id);
366
+ });
367
+
368
+ it("404s on unknown id", async () => {
369
+ await expect(
370
+ call(
371
+ h.router.getAutomation,
372
+ { id: "missing" },
373
+ { context: h.context },
374
+ ),
375
+ ).rejects.toThrow(/not found/i);
376
+ });
377
+ });
378
+
379
+ describe("createAutomation", () => {
380
+ it("persists and broadcasts a `created` signal", async () => {
381
+ const res = await call(
382
+ h.router.createAutomation,
383
+ {
384
+ name: "New",
385
+ status: "enabled",
386
+ definition: sampleDefinition,
387
+ },
388
+ { context: h.context },
389
+ );
390
+ expect(res.name).toBe("New");
391
+ expect(h.signalService.wasSignalEmitted(AUTOMATION_DEFINITION_CHANGED.id)).toBe(
392
+ true,
393
+ );
394
+ const emitted = h.signalService.getRecordedSignalsById(
395
+ AUTOMATION_DEFINITION_CHANGED.id,
396
+ );
397
+ expect(emitted[0]?.payload).toMatchObject({
398
+ action: "created",
399
+ automationId: res.id,
400
+ });
401
+ });
402
+ });
403
+
404
+ describe("updateAutomation", () => {
405
+ it("updates and broadcasts an `updated` signal", async () => {
406
+ const created = await h.automationStore.create({
407
+ name: "Old",
408
+ status: "enabled",
409
+ definition: sampleDefinition,
410
+ });
411
+ const res = await call(
412
+ h.router.updateAutomation,
413
+ { id: created.id, name: "New" },
414
+ { context: h.context },
415
+ );
416
+ expect(res.name).toBe("New");
417
+ expect(
418
+ h.signalService
419
+ .getRecordedSignalsById(AUTOMATION_DEFINITION_CHANGED.id)[0]
420
+ ?.payload,
421
+ ).toMatchObject({ action: "updated", automationId: created.id });
422
+ });
423
+
424
+ it("404s when the automation does not exist", async () => {
425
+ await expect(
426
+ call(
427
+ h.router.updateAutomation,
428
+ { id: "missing", name: "Whatever" },
429
+ { context: h.context },
430
+ ),
431
+ ).rejects.toThrow(/not found/i);
432
+ });
433
+ });
434
+
435
+ describe("deleteAutomation", () => {
436
+ it("removes the row and broadcasts a `deleted` signal", async () => {
437
+ const created = await h.automationStore.create({
438
+ name: "Doomed",
439
+ status: "enabled",
440
+ definition: sampleDefinition,
441
+ });
442
+ const res = await call(
443
+ h.router.deleteAutomation,
444
+ { id: created.id },
445
+ { context: h.context },
446
+ );
447
+ expect(res.success).toBe(true);
448
+ expect(await h.automationStore.getById(created.id)).toBeUndefined();
449
+ expect(
450
+ h.signalService
451
+ .getRecordedSignalsById(AUTOMATION_DEFINITION_CHANGED.id)[0]
452
+ ?.payload,
453
+ ).toMatchObject({ action: "deleted", automationId: created.id });
454
+ });
455
+
456
+ it("404s when missing", async () => {
457
+ await expect(
458
+ call(
459
+ h.router.deleteAutomation,
460
+ { id: "missing" },
461
+ { context: h.context },
462
+ ),
463
+ ).rejects.toThrow(/not found/i);
464
+ });
465
+ });
466
+
467
+ describe("toggleAutomation", () => {
468
+ it("disables an enabled row", async () => {
469
+ const created = await h.automationStore.create({
470
+ name: "X",
471
+ status: "enabled",
472
+ definition: sampleDefinition,
473
+ });
474
+ const res = await call(
475
+ h.router.toggleAutomation,
476
+ { id: created.id, enabled: false },
477
+ { context: h.context },
478
+ );
479
+ expect(res.status).toBe("disabled");
480
+ });
481
+ });
482
+
483
+ // ─── Definition validation ───────────────────────────────────────────
484
+
485
+ describe("validateDefinition", () => {
486
+ it("returns valid: true for a well-formed definition", async () => {
487
+ const res = await call(
488
+ h.router.validateDefinition,
489
+ { definition: sampleDefinition },
490
+ { context: h.context },
491
+ );
492
+ expect(res.valid).toBe(true);
493
+ expect(res.errors).toEqual([]);
494
+ });
495
+
496
+ it("returns issues for an invalid definition", async () => {
497
+ const res = await call(
498
+ h.router.validateDefinition,
499
+ { definition: { name: "", triggers: [] } },
500
+ { context: h.context },
501
+ );
502
+ expect(res.valid).toBe(false);
503
+ expect(res.errors.length).toBeGreaterThan(0);
504
+ // every error should carry path + message
505
+ for (const err of res.errors) {
506
+ expect(typeof err.message).toBe("string");
507
+ expect(Array.isArray(err.path)).toBe(true);
508
+ }
509
+ });
510
+ });
511
+
512
+ // ─── Registry introspection ─────────────────────────────────────────
513
+
514
+ describe("listTriggers", () => {
515
+ it("returns the registered triggers with payload schemas", async () => {
516
+ const res = await call(
517
+ h.router.listTriggers,
518
+ {},
519
+ { context: h.context },
520
+ );
521
+ expect(res.items).toHaveLength(1);
522
+ expect(res.items[0]?.qualifiedId).toBe("test.incident.created");
523
+ expect(res.items[0]?.payloadSchema).toBeDefined();
524
+ });
525
+ });
526
+
527
+ describe("listActions", () => {
528
+ it("returns the registered actions with `produces` propagated", async () => {
529
+ const res = await call(
530
+ h.router.listActions,
531
+ {},
532
+ { context: h.context },
533
+ );
534
+ expect(res.items).toHaveLength(1);
535
+ expect(res.items[0]?.produces).toBe("test.thing");
536
+ expect(res.items[0]?.consumes).toEqual([]);
537
+ });
538
+ });
539
+
540
+ describe("listArtifactTypes", () => {
541
+ it("returns the registered artifact types", async () => {
542
+ const res = await call(
543
+ h.router.listArtifactTypes,
544
+ {},
545
+ { context: h.context },
546
+ );
547
+ expect(res.items).toHaveLength(1);
548
+ expect(res.items[0]?.qualifiedId).toBe("test.thing");
549
+ });
550
+ });
551
+
552
+ // ─── manualRun ───────────────────────────────────────────────────────
553
+
554
+ describe("manualRun", () => {
555
+ it("404s on missing automation", async () => {
556
+ await expect(
557
+ call(
558
+ h.router.manualRun,
559
+ { automationId: "missing", payload: {} },
560
+ { context: h.context },
561
+ ),
562
+ ).rejects.toThrow(/not found/i);
563
+ });
564
+
565
+ it("400s when no trigger matches", async () => {
566
+ const created = await h.automationStore.create({
567
+ name: "X",
568
+ status: "enabled",
569
+ definition: sampleDefinition,
570
+ });
571
+ await expect(
572
+ call(
573
+ h.router.manualRun,
574
+ {
575
+ automationId: created.id,
576
+ triggerId: "does-not-exist",
577
+ payload: {},
578
+ },
579
+ { context: h.context },
580
+ ),
581
+ ).rejects.toThrow(/Trigger does-not-exist not found/);
582
+ });
583
+
584
+ it("dispatches with the first trigger when none specified", async () => {
585
+ const created = await h.automationStore.create({
586
+ name: "X",
587
+ status: "enabled",
588
+ definition: sampleDefinition,
589
+ });
590
+ const res = await call(
591
+ h.router.manualRun,
592
+ {
593
+ automationId: created.id,
594
+ payload: { incidentId: "INC-1" },
595
+ },
596
+ { context: h.context },
597
+ );
598
+ expect(res.runId).toMatch(/^run-/);
599
+ // run-completed signal fires once the dispatch returns
600
+ expect(
601
+ h.signalService.wasSignalEmitted(AUTOMATION_RUN_COMPLETED.id),
602
+ ).toBe(true);
603
+ });
604
+ });
605
+
606
+ // ─── cancelRun ───────────────────────────────────────────────────────
607
+
608
+ describe("cancelRun", () => {
609
+ it("404s when the run does not exist", async () => {
610
+ h.db.select.mockReturnValueOnce(fluentSelect([]) as never);
611
+ await expect(
612
+ call(h.router.cancelRun, { id: "missing" }, { context: h.context }),
613
+ ).rejects.toThrow(/not found/i);
614
+ });
615
+
616
+ it("is idempotent for runs already in a terminal state", async () => {
617
+ h.db.select.mockReturnValueOnce(
618
+ fluentSelect([
619
+ {
620
+ id: "r1",
621
+ automationId: "a1",
622
+ triggerId: "t",
623
+ triggerEventId: "e",
624
+ triggerPayload: {},
625
+ contextKey: null,
626
+ status: "success",
627
+ errorMessage: null,
628
+ startedAt: new Date(),
629
+ finishedAt: new Date(),
630
+ },
631
+ ]) as never,
632
+ );
633
+ const res = await call(
634
+ h.router.cancelRun,
635
+ { id: "r1" },
636
+ { context: h.context },
637
+ );
638
+ expect(res.success).toBe(true);
639
+ // no update / delete because already terminal
640
+ expect(h.db.update).not.toHaveBeenCalled();
641
+ expect(h.db.delete).not.toHaveBeenCalled();
642
+ });
643
+
644
+ it("cancels a running run, tears down wait locks + state, and broadcasts", async () => {
645
+ h.db.select.mockReturnValueOnce(
646
+ fluentSelect([
647
+ {
648
+ id: "r1",
649
+ automationId: "a1",
650
+ triggerId: "t",
651
+ triggerEventId: "e",
652
+ triggerPayload: {},
653
+ contextKey: null,
654
+ status: "running",
655
+ errorMessage: null,
656
+ startedAt: new Date(),
657
+ finishedAt: null,
658
+ },
659
+ ]) as never,
660
+ );
661
+ const res = await call(
662
+ h.router.cancelRun,
663
+ { id: "r1" },
664
+ { context: h.context },
665
+ );
666
+ expect(res.success).toBe(true);
667
+ expect(h.db.update).toHaveBeenCalled();
668
+ expect(h.db.delete).toHaveBeenCalledTimes(2); // wait locks + run state
669
+ expect(
670
+ h.signalService
671
+ .getRecordedSignalsById(AUTOMATION_RUN_COMPLETED.id)[0]
672
+ ?.payload,
673
+ ).toMatchObject({ runId: "r1", status: "cancelled" });
674
+ });
675
+ });
676
+
677
+ // ─── renderTemplate ──────────────────────────────────────────────────
678
+
679
+ describe("renderTemplate", () => {
680
+ it("renders a template with the provided context", async () => {
681
+ const res = await call(
682
+ h.router.renderTemplate,
683
+ {
684
+ template: "Hello {{ name }}!",
685
+ context: { name: "world" },
686
+ mode: "template",
687
+ },
688
+ { context: h.context },
689
+ );
690
+ expect(res.success).toBe(true);
691
+ expect(res.output).toBe("Hello world!");
692
+ });
693
+
694
+ it("evaluates a condition", async () => {
695
+ const res = await call(
696
+ h.router.renderTemplate,
697
+ {
698
+ template: "x > 0",
699
+ context: { x: 5 },
700
+ mode: "condition",
701
+ },
702
+ { context: h.context },
703
+ );
704
+ expect(res.success).toBe(true);
705
+ expect(res.booleanResult).toBe(true);
706
+ });
707
+
708
+ it("returns parse errors with line / column", async () => {
709
+ const res = await call(
710
+ h.router.renderTemplate,
711
+ {
712
+ template: "Hello {{ name",
713
+ context: {},
714
+ mode: "template",
715
+ },
716
+ { context: h.context },
717
+ );
718
+ expect(res.success).toBe(false);
719
+ expect(res.error?.message).toBeDefined();
720
+ expect(typeof res.error?.line).toBe("number");
721
+ expect(typeof res.error?.column).toBe("number");
722
+ });
723
+ });
724
+ });