@checkstack/automation-frontend 0.2.0 → 0.3.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 (44) hide show
  1. package/CHANGELOG.md +352 -0
  2. package/package.json +13 -9
  3. package/src/components/AutomationGroupCombobox.tsx +133 -0
  4. package/src/editor/ActionEditor.tsx +180 -90
  5. package/src/editor/ActionListEditor.tsx +27 -1
  6. package/src/editor/AddActionDialog.tsx +15 -45
  7. package/src/editor/AddConditionDialog.tsx +86 -0
  8. package/src/editor/AddTriggerDialog.tsx +97 -0
  9. package/src/editor/AutomationDefinitionEditor.tsx +41 -2
  10. package/src/editor/ConditionEditor.tsx +359 -70
  11. package/src/editor/ConditionsEditor.tsx +113 -44
  12. package/src/editor/ItemSheet.tsx +51 -0
  13. package/src/editor/RunReplayPicker.tsx +97 -0
  14. package/src/editor/ScriptServicesBooter.tsx +53 -0
  15. package/src/editor/ScriptTestRenderer.tsx +150 -0
  16. package/src/editor/SystemEntityPicker.test.ts +37 -0
  17. package/src/editor/SystemEntityPicker.tsx +109 -0
  18. package/src/editor/TriggersEditor.tsx +345 -137
  19. package/src/editor/action-helpers.test.ts +107 -0
  20. package/src/editor/action-helpers.ts +72 -0
  21. package/src/editor/action-leaf-cards.tsx +98 -1
  22. package/src/editor/condition-kind.test.ts +126 -0
  23. package/src/editor/condition-kind.ts +130 -0
  24. package/src/editor/item-summary.test.ts +171 -0
  25. package/src/editor/item-summary.ts +210 -0
  26. package/src/editor/picker-dialog.tsx +156 -0
  27. package/src/editor/registry-context.tsx +9 -2
  28. package/src/editor/script-actions.test.ts +184 -0
  29. package/src/editor/script-actions.ts +146 -0
  30. package/src/editor/system-entity-picker.logic.ts +23 -0
  31. package/src/editor/template-completion.test.ts +22 -3
  32. package/src/editor/template-completion.ts +16 -8
  33. package/src/editor/template-helpers.ts +4 -0
  34. package/src/editor/trigger-helpers.test.ts +28 -0
  35. package/src/editor/trigger-helpers.ts +17 -0
  36. package/src/editor/useScriptDiagnostics.ts +108 -0
  37. package/src/index.tsx +2 -0
  38. package/src/pages/AutomationEditPage.tsx +95 -47
  39. package/src/pages/AutomationListPage.tsx +172 -123
  40. package/src/pages/automation-grouping.test.ts +86 -0
  41. package/src/pages/automation-grouping.ts +65 -0
  42. package/src/script-context.test.ts +142 -1
  43. package/src/script-context.ts +115 -0
  44. package/tsconfig.json +12 -0
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
- import { Plus, Trash2 } from "lucide-react";
3
2
  import {
4
- Button,
3
+ ActionCard,
4
+ type ActionCardMenuItem,
5
5
  Card,
6
6
  CardContent,
7
7
  CardHeader,
@@ -9,17 +9,36 @@ import {
9
9
  Input,
10
10
  Label,
11
11
  DynamicForm,
12
+ DurationInput,
12
13
  TemplateValueInput,
14
+ Toggle,
13
15
  Badge,
16
+ Select,
17
+ SelectContent,
18
+ SelectItem,
19
+ SelectTrigger,
20
+ SelectValue,
21
+ VariablePicker,
22
+ type DurationValue,
23
+ type TemplateCompletionProvider,
24
+ type VariableNode,
14
25
  } from "@checkstack/ui";
15
26
  import type {
16
27
  AutomationDefinition,
28
+ Duration,
17
29
  Trigger,
30
+ Window,
18
31
  } from "@checkstack/automation-common";
19
32
  import { useAutomationRegistry, useVariableScope } from "./registry-context";
20
- import { ItemPicker } from "./ItemPicker";
33
+ import { AddTriggerDialog } from "./AddTriggerDialog";
34
+ import { ItemSheet } from "./ItemSheet";
21
35
  import { useTriggerIssues } from "./editor-validation";
22
- import { collectTriggerIds, defaultTriggerId } from "./trigger-helpers";
36
+ import {
37
+ collectTriggerIds,
38
+ defaultTriggerId,
39
+ makeTrigger,
40
+ } from "./trigger-helpers";
41
+ import { summarizeTrigger } from "./item-summary";
23
42
 
24
43
  /**
25
44
  * Build a minimal `AutomationDefinition` that only subscribes to the
@@ -37,6 +56,7 @@ function buildTriggerFilterDefinition(triggerEvent: string): AutomationDefinitio
37
56
  conditions: [],
38
57
  actions: [],
39
58
  mode: "single",
59
+ concurrency_scope: "automation",
40
60
  max_runs: 1,
41
61
  };
42
62
  }
@@ -69,43 +89,10 @@ export const TriggersEditor: React.FC<TriggersEditorProps> = ({
69
89
  onChange,
70
90
  disabled,
71
91
  }) => {
72
- const { triggers } = useAutomationRegistry();
73
- const pickerItems = React.useMemo(
74
- () =>
75
- triggers.map((t) => ({
76
- id: t.qualifiedId,
77
- label: t.displayName,
78
- description: t.description,
79
- category: t.category,
80
- })),
81
- [triggers],
82
- );
83
-
84
- const handleAdd = () => {
85
- // Assign a unique default id up front (deduped against existing triggers)
86
- // so the new trigger is immediately referenceable as `trigger.id` and the
87
- // field shows a value rather than appearing blank.
88
- const fresh: Trigger = { event: triggers[0]?.qualifiedId ?? "" };
89
- const id = defaultTriggerId(fresh, collectTriggerIds(value));
90
- onChange([...value, { ...fresh, id }]);
91
- };
92
-
93
92
  return (
94
93
  <Card>
95
94
  <CardHeader className="border-b">
96
- <div className="flex items-center justify-between">
97
- <CardTitle className="text-base">Triggers</CardTitle>
98
- <Button
99
- type="button"
100
- variant="outline"
101
- size="sm"
102
- onClick={handleAdd}
103
- disabled={disabled}
104
- >
105
- <Plus className="mr-1 h-3 w-3" />
106
- Add trigger
107
- </Button>
108
- </div>
95
+ <CardTitle className="text-base">Triggers</CardTitle>
109
96
  </CardHeader>
110
97
  <CardContent className="space-y-2 p-3">
111
98
  {value.length === 0 && (
@@ -124,13 +111,36 @@ export const TriggersEditor: React.FC<TriggersEditorProps> = ({
124
111
  onChange(list);
125
112
  }}
126
113
  onRemove={() => onChange(value.filter((_, i) => i !== index))}
114
+ onDuplicate={() => {
115
+ // Clone the trigger with a fresh, unique id (deduped against
116
+ // every sibling) inserted directly after the original.
117
+ const taken = collectTriggerIds(value);
118
+ const { id: _id, ...rest } = trigger;
119
+ const id = defaultTriggerId(rest, taken);
120
+ onChange([
121
+ ...value.slice(0, index + 1),
122
+ { ...rest, id },
123
+ ...value.slice(index + 1),
124
+ ]);
125
+ }}
127
126
  disabled={disabled}
128
- pickerItems={pickerItems}
129
127
  // Ids of the other triggers — used to keep this trigger's
130
128
  // auto-filled id unique when the operator clears the field.
131
129
  siblingIds={collectTriggerIds(value.filter((_, i) => i !== index))}
132
130
  />
133
131
  ))}
132
+ <AddTriggerDialog
133
+ disabled={disabled}
134
+ onAdd={(event) =>
135
+ // Assign a unique default id up front (deduped against existing
136
+ // triggers) so the new trigger is immediately referenceable as
137
+ // `trigger.id` and the field shows a value rather than blank.
138
+ onChange([
139
+ ...value,
140
+ makeTrigger({ event, taken: collectTriggerIds(value) }),
141
+ ])
142
+ }
143
+ />
134
144
  </CardContent>
135
145
  </Card>
136
146
  );
@@ -141,13 +151,22 @@ const TriggerCard: React.FC<{
141
151
  value: Trigger;
142
152
  onChange: (next: Trigger) => void;
143
153
  onRemove: () => void;
154
+ onDuplicate: () => void;
144
155
  disabled?: boolean;
145
- pickerItems: Array<{ id: string; label: string; description?: string; category?: string }>;
146
156
  siblingIds: Set<string>;
147
- }> = ({ index, value, onChange, onRemove, disabled, pickerItems, siblingIds }) => {
157
+ }> = ({
158
+ index,
159
+ value,
160
+ onChange,
161
+ onRemove,
162
+ onDuplicate,
163
+ disabled,
164
+ siblingIds,
165
+ }) => {
148
166
  const { triggers } = useAutomationRegistry();
149
167
  const selected = triggers.find((t) => t.qualifiedId === value.event);
150
168
  const issues = useTriggerIssues(index);
169
+ const [sheetOpen, setSheetOpen] = React.useState(false);
151
170
 
152
171
  // Templates inside the filter / config see only the selected
153
172
  // trigger's payload — there are no other triggers, no upstream
@@ -156,114 +175,303 @@ const TriggerCard: React.FC<{
156
175
  () => buildTriggerFilterDefinition(value.event),
157
176
  [value.event],
158
177
  );
159
- const { templateCompletion } = useVariableScope({
160
- definition: filterScopeDefinition,
161
- path: [{ slot: "root", index: 0 }],
162
- });
178
+ const { templateCompletion, expressionCompletion, variableNodes } =
179
+ useVariableScope({
180
+ definition: filterScopeDefinition,
181
+ path: [{ slot: "root", index: 0 }],
182
+ });
183
+
184
+ // Surface a problem rather than hide it behind a collapsed row + closed
185
+ // sheet.
186
+ React.useEffect(() => {
187
+ if (issues.length > 0) setSheetOpen(true);
188
+ }, [issues.length]);
189
+
190
+ const title = selected?.displayName ?? value.event ?? "Trigger";
191
+ const summary = summarizeTrigger(value);
192
+
193
+ const menuItems: ActionCardMenuItem[] = disabled
194
+ ? []
195
+ : [
196
+ { label: "Duplicate", icon: "Copy", onClick: onDuplicate },
197
+ {
198
+ label: "Delete",
199
+ icon: "Trash2",
200
+ onClick: onRemove,
201
+ variant: "destructive",
202
+ },
203
+ ];
163
204
 
164
205
  return (
165
- <Card
166
- className={
167
- issues.length > 0
168
- ? "border-destructive/60 bg-muted/30 ring-1 ring-destructive/30"
169
- : "border-border/60 bg-muted/30"
170
- }
171
- >
172
- <CardContent className="space-y-3 p-3">
173
- {issues.length > 0 && (
174
- <ul className="space-y-0.5">
175
- {issues.map((issue, i) => (
176
- <li key={i} className="text-[11px] font-mono text-destructive">
177
- {issue}
178
- </li>
179
- ))}
180
- </ul>
206
+ <>
207
+ <ActionCard
208
+ id={`trigger-${index}`}
209
+ title={title}
210
+ summary={summary}
211
+ category="Trigger"
212
+ icon="Zap"
213
+ onOpenSheet={() => setSheetOpen(true)}
214
+ actions={menuItems}
215
+ errors={issues}
216
+ />
217
+ <ItemSheet
218
+ open={sheetOpen}
219
+ onOpenChange={setSheetOpen}
220
+ title={title}
221
+ description="Trigger"
222
+ >
223
+ {selected && (
224
+ // Read-only context for the configured trigger — the event/kind is
225
+ // chosen up front in the Add dialog, so it isn't editable here.
226
+ <div className="flex items-center gap-2 text-[10px] text-muted-foreground">
227
+ <Badge variant="outline" className="text-[10px]">
228
+ {selected.ownerPluginId}
229
+ </Badge>
230
+ {selected.description}
231
+ </div>
232
+ )}
233
+ {selected?.configSchema && (
234
+ <div className="space-y-1">
235
+ <Label className="text-xs">Trigger configuration</Label>
236
+ <DynamicForm
237
+ schema={selected.configSchema}
238
+ value={value.config ?? {}}
239
+ onChange={(next) => onChange({ ...value, config: next })}
240
+ />
241
+ </div>
181
242
  )}
182
- <div className="flex items-start gap-2">
183
- <div className="flex-1 space-y-3">
243
+ <div className="rounded-lg border border-border/60 bg-muted/20 p-3 space-y-3">
244
+ <div className="grid gap-2 sm:grid-cols-2">
184
245
  <div className="space-y-1">
185
- <Label className="text-xs">Event</Label>
186
- <ItemPicker
187
- items={pickerItems}
188
- value={value.event}
189
- onSelect={(id) => onChange({ ...value, event: id })}
190
- placeholder="Pick a trigger event"
246
+ <Label className="text-xs" htmlFor={`trigger-id-${index}`}>
247
+ ID
248
+ </Label>
249
+ <Input
250
+ id={`trigger-id-${index}`}
251
+ value={value.id ?? ""}
252
+ onChange={(event) =>
253
+ onChange({
254
+ ...value,
255
+ id: event.target.value || undefined,
256
+ })
257
+ }
258
+ onBlur={() => {
259
+ // Never leave the id blank: re-fill a unique default
260
+ // so the trigger stays referenceable as `trigger.id`
261
+ // and is distinguishable from sibling triggers.
262
+ if (value.id) return;
263
+ onChange({
264
+ ...value,
265
+ id: defaultTriggerId(value, siblingIds),
266
+ });
267
+ }}
268
+ placeholder="Generated on blur"
191
269
  disabled={disabled}
270
+ className="font-mono text-xs"
192
271
  />
193
- {selected && (
194
- <div className="flex items-center gap-2 text-[10px] text-muted-foreground">
195
- <Badge variant="outline" className="text-[10px]">
196
- {selected.ownerPluginId}
197
- </Badge>
198
- {selected.description}
199
- </div>
200
- )}
201
272
  </div>
202
- <div className="grid gap-2 sm:grid-cols-2">
203
- <div className="space-y-1">
204
- <Label className="text-xs" htmlFor={`trigger-id-${index}`}>
205
- ID
206
- </Label>
207
- <Input
208
- id={`trigger-id-${index}`}
209
- value={value.id ?? ""}
210
- onChange={(event) =>
211
- onChange({
212
- ...value,
213
- id: event.target.value || undefined,
214
- })
215
- }
216
- onBlur={() => {
217
- // Never leave the id blank: re-fill a unique default so the
218
- // trigger stays referenceable as `trigger.id` and is
219
- // distinguishable from sibling triggers.
220
- if (value.id) return;
221
- onChange({ ...value, id: defaultTriggerId(value, siblingIds) });
222
- }}
223
- placeholder="Generated on blur"
224
- disabled={disabled}
225
- className="font-mono text-xs"
226
- />
227
- </div>
228
- <div className="space-y-1">
229
- <Label className="text-xs">Filter template</Label>
230
- <TemplateValueInput
231
- value={value.filter ?? ""}
232
- onChange={(next) =>
233
- onChange({ ...value, filter: next || undefined })
234
- }
235
- placeholder="{{ trigger.payload.severity == &quot;high&quot; }}"
236
- completionProvider={templateCompletion}
237
- disabled={disabled}
238
- />
239
- </div>
273
+ <div className="space-y-1">
274
+ <Label className="text-xs">Filter template</Label>
275
+ <TemplateValueInput
276
+ value={value.filter ?? ""}
277
+ onChange={(next) =>
278
+ onChange({ ...value, filter: next || undefined })
279
+ }
280
+ placeholder="{{ trigger.payload.severity == &quot;high&quot; }}"
281
+ completionProvider={templateCompletion}
282
+ disabled={disabled}
283
+ />
240
284
  </div>
241
- {selected?.configSchema && (
242
- <div className="space-y-1">
243
- <Label className="text-xs">Trigger configuration</Label>
244
- <DynamicForm
245
- schema={selected.configSchema}
246
- value={value.config ?? {}}
247
- onChange={(next) =>
248
- onChange({ ...value, config: next })
249
- }
250
- />
251
- </div>
285
+ </div>
286
+ <div className="space-y-1">
287
+ <div className="flex items-center gap-2">
288
+ <Toggle
289
+ checked={value.for !== undefined}
290
+ onCheckedChange={(checked) =>
291
+ onChange({
292
+ ...value,
293
+ for: checked ? { minutes: 30 } : undefined,
294
+ })
295
+ }
296
+ disabled={disabled}
297
+ />
298
+ <Label className="text-xs">
299
+ Dwell: fire only if the matched state still holds after
300
+ </Label>
301
+ </div>
302
+ {value.for !== undefined && (
303
+ <DurationInput
304
+ value={value.for as DurationValue}
305
+ onChange={(next) =>
306
+ onChange({
307
+ ...value,
308
+ for: (next as Duration) ?? undefined,
309
+ })
310
+ }
311
+ disabled={disabled}
312
+ />
252
313
  )}
253
314
  </div>
254
- <Button
255
- type="button"
256
- variant="ghost"
257
- size="icon"
258
- className="h-7 w-7 text-destructive hover:bg-destructive/10"
259
- onClick={onRemove}
315
+ <div className="space-y-1">
316
+ <div className="flex items-center gap-2">
317
+ <Toggle
318
+ checked={value.window !== undefined}
319
+ onCheckedChange={(checked) =>
320
+ onChange({
321
+ ...value,
322
+ window: checked
323
+ ? { count: 3, minutes: 60, refire: "every" }
324
+ : undefined,
325
+ })
326
+ }
327
+ disabled={disabled}
328
+ />
329
+ <Label className="text-xs">
330
+ Rate: fire only after N occurrences within a window
331
+ </Label>
332
+ </div>
333
+ {value.window !== undefined && (
334
+ <WindowInput
335
+ value={value.window}
336
+ onChange={(next) => onChange({ ...value, window: next })}
337
+ completionProvider={expressionCompletion}
338
+ variableNodes={variableNodes}
339
+ contextKeyLabel={selected?.contextKeyLabel}
340
+ disabled={disabled}
341
+ />
342
+ )}
343
+ </div>
344
+ </div>
345
+ </ItemSheet>
346
+ </>
347
+ );
348
+ };
349
+
350
+ /**
351
+ * Editor for a trigger's windowed-count / rate gate. Count + minutes number
352
+ * inputs, a re-fire mode select, and an optional "Partition by" expression
353
+ * that overrides the dimension the count is bucketed by. Matches the
354
+ * surrounding compact editor style.
355
+ */
356
+ const WindowInput: React.FC<{
357
+ value: Window;
358
+ onChange: (next: Window) => void;
359
+ /**
360
+ * BARE-expression completion provider (`expressionCompletion` from
361
+ * `useVariableScope`) — `partitionBy` is evaluated like a condition (no
362
+ * `{{ }}` wrapper), so it uses the same provider the condition expression
363
+ * field does, NOT the template provider used for `{{ }}` fields.
364
+ */
365
+ completionProvider: TemplateCompletionProvider;
366
+ /** Hierarchical scope for the "fx" VariablePicker insert affordance. */
367
+ variableNodes: VariableNode[];
368
+ /** Built-in partition dimension label (e.g. "system"); undefined ⇒ per automation. */
369
+ contextKeyLabel?: string;
370
+ disabled?: boolean;
371
+ }> = ({
372
+ value,
373
+ onChange,
374
+ completionProvider,
375
+ variableNodes,
376
+ contextKeyLabel,
377
+ disabled,
378
+ }) => {
379
+ const defaultPartition = contextKeyLabel
380
+ ? `per ${contextKeyLabel}`
381
+ : "per automation";
382
+ return (
383
+ <div className="space-y-2">
384
+ <div className="grid grid-cols-3 gap-2">
385
+ <div className="space-y-1">
386
+ <Label className="text-xs">Count</Label>
387
+ <Input
388
+ type="number"
389
+ min={1}
390
+ max={1000}
391
+ value={value.count}
392
+ onChange={(e) => {
393
+ const count = Number.parseInt(e.target.value, 10);
394
+ onChange({ ...value, count: Number.isFinite(count) ? count : 1 });
395
+ }}
396
+ disabled={disabled}
397
+ className="text-xs"
398
+ />
399
+ </div>
400
+ <div className="space-y-1">
401
+ <Label className="text-xs">Within (minutes)</Label>
402
+ <Input
403
+ type="number"
404
+ min={1}
405
+ max={1440}
406
+ value={value.minutes}
407
+ onChange={(e) => {
408
+ const minutes = Number.parseInt(e.target.value, 10);
409
+ onChange({
410
+ ...value,
411
+ minutes: Number.isFinite(minutes) ? minutes : 1,
412
+ });
413
+ }}
414
+ disabled={disabled}
415
+ className="text-xs"
416
+ />
417
+ </div>
418
+ <div className="space-y-1">
419
+ <Label className="text-xs">Re-fire</Label>
420
+ <Select
421
+ value={value.refire}
422
+ onValueChange={(next) =>
423
+ onChange({ ...value, refire: next === "once" ? "once" : "every" })
424
+ }
260
425
  disabled={disabled}
261
- aria-label="Remove trigger"
262
426
  >
263
- <Trash2 className="h-3 w-3" />
264
- </Button>
427
+ <SelectTrigger className="text-xs">
428
+ <SelectValue />
429
+ </SelectTrigger>
430
+ <SelectContent>
431
+ <SelectItem value="every">every occurrence</SelectItem>
432
+ <SelectItem value="once">once (crossing edge)</SelectItem>
433
+ </SelectContent>
434
+ </Select>
265
435
  </div>
266
- </CardContent>
267
- </Card>
436
+ </div>
437
+ <div className="space-y-1">
438
+ <div className="flex items-center justify-between gap-2">
439
+ <Label className="text-xs">Partition by</Label>
440
+ {!disabled && (
441
+ <VariablePicker
442
+ scope={variableNodes}
443
+ onSelect={(path) => {
444
+ // partitionBy is a bare expression — insert the raw path, not
445
+ // a `{{ … }}`-wrapped reference (matches the condition editor).
446
+ const before = value.partitionBy ?? "";
447
+ const sep =
448
+ before.length > 0 && !before.endsWith(" ") ? " " : "";
449
+ onChange({ ...value, partitionBy: `${before}${sep}${path}` });
450
+ }}
451
+ />
452
+ )}
453
+ </div>
454
+ <TemplateValueInput
455
+ value={value.partitionBy ?? ""}
456
+ onChange={(next) =>
457
+ // Store the RAW value (don't `.trim()` on change): trimming strips
458
+ // the trailing space the user must type to reach the operator
459
+ // stage, swallowing it mid-type. Only a blank / whitespace-only
460
+ // value clears the field. The gate trims when it evaluates.
461
+ onChange({
462
+ ...value,
463
+ partitionBy: next.trim() === "" ? undefined : next,
464
+ })
465
+ }
466
+ placeholder={`Leave blank to count ${defaultPartition}`}
467
+ completionProvider={completionProvider}
468
+ disabled={disabled}
469
+ />
470
+ <p className="text-muted-foreground text-xs">
471
+ Bare expression (no <code>{"{{ }}"}</code>) for the key the count is
472
+ bucketed by. Blank counts {defaultPartition}.
473
+ </p>
474
+ </div>
475
+ </div>
268
476
  );
269
477
  };
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import type {
3
+ ActionInput,
4
+ ChooseInput,
5
+ ParallelInput,
6
+ ProviderAction,
7
+ } from "@checkstack/automation-common";
8
+ import {
9
+ collectActionIds,
10
+ duplicateAction,
11
+ } from "./action-helpers";
12
+
13
+ describe("duplicateAction", () => {
14
+ it("clones a leaf action with a fresh, unique id", () => {
15
+ const original: ProviderAction = {
16
+ action: "automation.log",
17
+ config: { message: "hi" },
18
+ enabled: true,
19
+ continue_on_error: false,
20
+ id: "log",
21
+ };
22
+ const clone = duplicateAction(original, new Set(["log"]));
23
+ expect(clone.id).not.toBe("log");
24
+ expect(clone.id).toBe("log_2");
25
+ // Config is carried over verbatim.
26
+ expect((clone as ProviderAction).config).toEqual({ message: "hi" });
27
+ });
28
+
29
+ it("does not mutate the original action", () => {
30
+ const original: ProviderAction = {
31
+ action: "automation.log",
32
+ config: {},
33
+ enabled: true,
34
+ continue_on_error: false,
35
+ id: "log",
36
+ };
37
+ const before = structuredClone(original);
38
+ duplicateAction(original, new Set(["log"]));
39
+ expect(original).toEqual(before);
40
+ });
41
+
42
+ it("assigns fresh ids to every nested child of a composite", () => {
43
+ const original: ChooseInput = {
44
+ id: "branch",
45
+ enabled: true,
46
+ continue_on_error: false,
47
+ choose: [
48
+ {
49
+ when: "true",
50
+ sequence: [
51
+ { action: "automation.log", config: {}, enabled: true, continue_on_error: false, id: "a" },
52
+ ],
53
+ },
54
+ ],
55
+ else: [
56
+ { action: "automation.log", config: {}, enabled: true, continue_on_error: false, id: "b" },
57
+ ],
58
+ };
59
+ const taken = collectActionIds([original]);
60
+ const clone = duplicateAction(original, taken);
61
+
62
+ const cloneIds = collectActionIds([clone]);
63
+ const originalIds = collectActionIds([original]);
64
+ // No id from the clone collides with any id in the original tree.
65
+ for (const id of cloneIds) {
66
+ expect(originalIds.has(id)).toBe(false);
67
+ }
68
+ // Every nested step still carries an id (none left blank).
69
+ const choose = clone as ChooseInput;
70
+ expect(choose.choose[0]!.sequence[0]!.id).toBeTruthy();
71
+ expect(choose.else![0]!.id).toBeTruthy();
72
+ });
73
+
74
+ it("keeps ids unique across repeated duplication against one set", () => {
75
+ const original: ProviderAction = {
76
+ action: "automation.log",
77
+ config: {},
78
+ enabled: true,
79
+ continue_on_error: false,
80
+ id: "log",
81
+ };
82
+ const taken = collectActionIds([original]);
83
+ const first = duplicateAction(original, taken);
84
+ const second = duplicateAction(original, taken);
85
+ expect(first.id).not.toBe(second.id);
86
+ expect(taken.has(first.id!)).toBe(true);
87
+ expect(taken.has(second.id!)).toBe(true);
88
+ });
89
+
90
+ it("dedupes nested parallel children against the whole automation", () => {
91
+ const original: ParallelInput = {
92
+ id: "par",
93
+ enabled: true,
94
+ continue_on_error: false,
95
+ parallel: [
96
+ { action: "automation.log", config: {}, enabled: true, continue_on_error: false, id: "x" },
97
+ { action: "automation.log", config: {}, enabled: true, continue_on_error: false, id: "y" },
98
+ ],
99
+ };
100
+ const automation: ActionInput[] = [original];
101
+ const taken = collectActionIds(automation);
102
+ const clone = duplicateAction(original, taken);
103
+ const ids = [...collectActionIds([clone])];
104
+ // All fresh ids are distinct.
105
+ expect(new Set(ids).size).toBe(ids.length);
106
+ });
107
+ });