@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
@@ -10,6 +10,7 @@ import type {
10
10
  StopInput,
11
11
  VariablesInput,
12
12
  WaitForTriggerInput,
13
+ WaitUntilInput,
13
14
  } from "@checkstack/automation-common";
14
15
  import type { LucideIconName } from "@checkstack/ui";
15
16
 
@@ -27,6 +28,7 @@ export type ActionKind =
27
28
  | "condition"
28
29
  | "stop"
29
30
  | "wait_for_trigger"
31
+ | "wait_until"
30
32
  | "sequence"
31
33
  | "delay";
32
34
 
@@ -39,6 +41,7 @@ export const ACTION_KINDS: ActionKind[] = [
39
41
  "condition",
40
42
  "stop",
41
43
  "wait_for_trigger",
44
+ "wait_until",
42
45
  "sequence",
43
46
  "delay",
44
47
  ];
@@ -103,6 +106,12 @@ export const ACTION_KIND_META: Record<ActionKind, ActionKindMeta> = {
103
106
  description: "Suspend until a matching trigger event arrives.",
104
107
  icon: "Hourglass",
105
108
  },
109
+ wait_until: {
110
+ kind: "wait_until",
111
+ label: "Wait until",
112
+ description: "Suspend until a condition becomes true, with optional timeout.",
113
+ icon: "TimerReset",
114
+ },
106
115
  sequence: {
107
116
  kind: "sequence",
108
117
  label: "Sequence",
@@ -132,6 +141,7 @@ export function actionKindOf(action: ActionInput): ActionKind {
132
141
  if ("condition" in action) return "condition";
133
142
  if ("stop" in action) return "stop";
134
143
  if ("wait_for_trigger" in action) return "wait_for_trigger";
144
+ if ("wait_until" in action) return "wait_until";
135
145
  if ("sequence" in action) return "sequence";
136
146
  return "delay";
137
147
  }
@@ -190,6 +200,15 @@ export function makeEmptyAction(kind: ActionKind): ActionInput {
190
200
  wait_for_trigger: { event: "" },
191
201
  } satisfies WaitForTriggerInput;
192
202
  }
203
+ case "wait_until": {
204
+ return {
205
+ ...BASE,
206
+ wait_until: {
207
+ condition: "",
208
+ continue_on_timeout: true,
209
+ },
210
+ } satisfies WaitUntilInput;
211
+ }
193
212
  case "sequence": {
194
213
  return {
195
214
  ...BASE,
@@ -344,6 +363,59 @@ export function assignDefaultIds(
344
363
  });
345
364
  }
346
365
 
366
+ /**
367
+ * Produce a duplicate of `action` (and every nested child) with FRESH, unique,
368
+ * identifier-safe ids, deduped against `taken`. Unlike {@link assignDefaultIds}
369
+ * - which preserves existing ids and only fills blanks - this strips every id
370
+ * so a duplicated step never collides with its original. `taken` is mutated as
371
+ * ids are assigned, so callers can clone several actions against one set.
372
+ *
373
+ * Used by the per-card "Duplicate" command: clone the item, insert it directly
374
+ * after the original, and keep the editor's parallel `ids` array in sync.
375
+ */
376
+ export function duplicateAction(
377
+ action: ActionInput,
378
+ taken: Set<string>,
379
+ ): ActionInput {
380
+ // Strip ids top-down, then run the standard default-id assignment so the
381
+ // clone (and its children) get brand-new unique ids rather than the
382
+ // originals'.
383
+ const stripIds = (input: ActionInput): ActionInput => {
384
+ const { id: _id, ...rest } = input;
385
+ const next = rest as ActionInput;
386
+ if ("choose" in next) {
387
+ return {
388
+ ...next,
389
+ choose: next.choose.map((branch) => ({
390
+ ...branch,
391
+ sequence: branch.sequence.map((child) => stripIds(child)),
392
+ })),
393
+ else: next.else
394
+ ? next.else.map((child) => stripIds(child))
395
+ : undefined,
396
+ };
397
+ }
398
+ if ("parallel" in next) {
399
+ return { ...next, parallel: next.parallel.map((child) => stripIds(child)) };
400
+ }
401
+ if ("repeat" in next) {
402
+ return {
403
+ ...next,
404
+ repeat: {
405
+ ...next.repeat,
406
+ sequence: next.repeat.sequence.map((child) => stripIds(child)),
407
+ },
408
+ };
409
+ }
410
+ if ("sequence" in next) {
411
+ return { ...next, sequence: next.sequence.map((child) => stripIds(child)) };
412
+ }
413
+ return next;
414
+ };
415
+ const [cloned] = assignDefaultIds([stripIds(action)], taken);
416
+ return cloned!;
417
+ }
418
+
347
419
  /**
348
420
  * Display-name for an action card's header. For provider actions we
349
421
  * fall back to the namespaced id when the registry doesn't know the
@@ -27,11 +27,16 @@ import type {
27
27
  StopInput,
28
28
  VariablesInput,
29
29
  WaitForTriggerInput,
30
+ WaitUntilInput,
30
31
  } from "@checkstack/automation-common";
31
32
  import { useAutomationRegistry } from "./registry-context";
32
33
  import { ItemPicker } from "./ItemPicker";
33
34
  import { ConditionEditor } from "./ConditionEditor";
34
35
  import { useConnectionOptionResolvers } from "./useConnectionOptionResolvers";
36
+ import { automationScriptTestRenderer } from "./ScriptTestRenderer";
37
+ import { useScriptPackageTypeAcquisition } from "@checkstack/script-packages-frontend";
38
+ import { useSecretNames } from "@checkstack/secrets-frontend";
39
+ import { generateSecretEnvTypes, secretEnvEnvNames } from "../script-context";
35
40
 
36
41
  /**
37
42
  * Provider action body. Picks an action id from `listActions()` then
@@ -72,6 +77,17 @@ export const ProviderActionBody: React.FC<{
72
77
  shellEnvVars,
73
78
  }) => {
74
79
  const { actions, loading } = useAutomationRegistry();
80
+ // Lazy ATA: the editor fetches + registers the `.d.ts` of any npm package
81
+ // the script imports (incl. its `@types/*` companion), so
82
+ // `import { debounce } from "lodash"` autocompletes. Coexists with the
83
+ // scope-derived `context` ambient types already threaded down.
84
+ // `importablePackages` drives import-specifier name completion (the package
85
+ // name itself) before any module is registered.
86
+ const { acquireTypes, acquireResetKey, importablePackages } =
87
+ useScriptPackageTypeAcquisition();
88
+ // Secret names (never values) for the secret -> env mapping editor's
89
+ // ${{ secrets.* }} autocomplete.
90
+ const { secretNames } = useSecretNames();
75
91
  // The shell script action exposes a user-editable `env` field; surface
76
92
  // its keys as `$`-completions alongside the run-scope `$CHECKSTACK_*`
77
93
  // vars (memoised so the editor's completion provider isn't re-registered
@@ -87,6 +103,21 @@ export const ProviderActionBody: React.FC<{
87
103
  // this one. So the card just resolves the registry entry and renders its
88
104
  // config; there is no in-card action switcher.
89
105
  const selected = actions.find((action) => action.qualifiedId === value.action);
106
+ // Type the inline TS/JS editor's `process.env.<ENV_NAME>` from the action's
107
+ // own `secretEnv` mapping: the declared env-var keys (located by the
108
+ // `x-secret-env` schema annotation, never a hard-coded field name) become
109
+ // ambient `ProcessEnv` members so they autocomplete and are typed `string`.
110
+ // Merged onto the scope-derived `context` types; re-derived when the
111
+ // secretEnv keys change (or the action's schema does). Coexists with
112
+ // @types/node's existing index signature — this only adds known keys.
113
+ const mergedTypeDefinitions = React.useMemo(() => {
114
+ const envNames = secretEnvEnvNames({
115
+ configSchema: selected?.configSchema,
116
+ config: value.config,
117
+ });
118
+ const secretEnvLib = generateSecretEnvTypes({ envNames });
119
+ return [typeDefinitions, secretEnvLib].filter(Boolean).join("\n\n");
120
+ }, [typeDefinitions, selected?.configSchema, value.config]);
90
121
  // Connection-backed actions (Jira / Teams / Webex) declare a
91
122
  // `connectionProviderId`; the bridge turns their `x-options-resolver`
92
123
  // fields into a live connection picker + cascading provider dropdowns.
@@ -142,8 +173,13 @@ export const ProviderActionBody: React.FC<{
142
173
  optionsResolvers={optionsResolvers}
143
174
  templateProperties={templateProperties}
144
175
  templateCompletionProvider={completionProvider}
145
- typeDefinitions={typeDefinitions}
176
+ typeDefinitions={mergedTypeDefinitions}
146
177
  shellEnvVars={mergedShellEnvVars}
178
+ scriptTestRenderer={automationScriptTestRenderer}
179
+ secretNames={secretNames}
180
+ acquireTypes={acquireTypes}
181
+ acquireResetKey={acquireResetKey}
182
+ importablePackages={importablePackages}
147
183
  />
148
184
  </div>
149
185
  );
@@ -424,3 +460,64 @@ export const ConditionGuardActionBody: React.FC<{
424
460
  />
425
461
  </div>
426
462
  );
463
+
464
+ /**
465
+ * `wait_until` card — mirrors {@link WaitForTriggerActionBody} but waits on
466
+ * a CONDITION (re-checked on a poll interval) rather than an event. Reuses
467
+ * the expression-based {@link ConditionEditor}; the structured
468
+ * numeric/time/state condition branches arrive with the rest of the
469
+ * sensing-layer editor work.
470
+ */
471
+ export const WaitUntilActionBody: React.FC<{
472
+ value: WaitUntilInput;
473
+ onChange: (next: WaitUntilInput) => void;
474
+ variableNodes: VariableNode[];
475
+ completionProvider: TemplateCompletionProvider;
476
+ disabled?: boolean;
477
+ }> = ({ value, onChange, variableNodes, completionProvider, disabled }) => {
478
+ const wu = value.wait_until;
479
+ const patch = (next: Partial<WaitUntilInput["wait_until"]>) =>
480
+ onChange({ ...value, wait_until: { ...wu, ...next } });
481
+
482
+ return (
483
+ <div className="space-y-3">
484
+ <div className="space-y-1">
485
+ <Label className="text-xs">Wait until condition</Label>
486
+ <ConditionEditor
487
+ value={wu.condition}
488
+ onChange={(next: ConditionInput) => patch({ condition: next })}
489
+ variableNodes={variableNodes}
490
+ completionProvider={completionProvider}
491
+ bare
492
+ />
493
+ </div>
494
+ <div className="space-y-1">
495
+ <Label className="text-xs">Timeout (seconds)</Label>
496
+ <Input
497
+ type="number"
498
+ min={1}
499
+ value={wu.timeout_seconds ?? ""}
500
+ onChange={(event) =>
501
+ patch({
502
+ timeout_seconds: event.target.value
503
+ ? Number(event.target.value)
504
+ : undefined,
505
+ })
506
+ }
507
+ disabled={disabled}
508
+ placeholder="No timeout"
509
+ />
510
+ </div>
511
+ <div className="flex items-center justify-between">
512
+ <Label className="text-xs">
513
+ Continue on timeout (off = fail the run on timeout)
514
+ </Label>
515
+ <Toggle
516
+ checked={wu.continue_on_timeout ?? true}
517
+ onCheckedChange={(checked) => patch({ continue_on_timeout: checked })}
518
+ disabled={disabled}
519
+ />
520
+ </div>
521
+ </div>
522
+ );
523
+ };
@@ -0,0 +1,126 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ ConditionSchema,
4
+ type ConditionInput,
5
+ } from "@checkstack/automation-common";
6
+ import {
7
+ CONDITION_KIND_META,
8
+ defaultForKind,
9
+ kindOf,
10
+ type ConditionKind,
11
+ type ConditionKindGroup,
12
+ } from "./condition-kind";
13
+
14
+ describe("defaultForKind", () => {
15
+ const structured: ConditionKind[] = ["numeric_state", "time", "state"];
16
+
17
+ // Every structured seed must classify back to its own kind so the
18
+ // editor's kind selector stays consistent after a switch.
19
+ for (const kind of structured) {
20
+ it(`kindOf round-trips its own ${kind} default`, () => {
21
+ expect(kindOf(defaultForKind(kind))).toBe(kind);
22
+ });
23
+ }
24
+
25
+ // The `time` seed is fully valid as-is (both bounds are real HH:mm).
26
+ it("seeds a schema-valid time condition", () => {
27
+ expect(ConditionSchema.safeParse(defaultForKind("time")).success).toBe(
28
+ true,
29
+ );
30
+ });
31
+
32
+ // numeric_state / state seed an empty required text field (value /
33
+ // entity) the operator fills in - like the bare `expr` empty string.
34
+ // Once filled, they round-trip through zod (the lossless contract).
35
+ it("numeric_state becomes schema-valid once `value` is filled", () => {
36
+ const seed = defaultForKind("numeric_state") as {
37
+ numeric_state: { value: string; above?: number };
38
+ };
39
+ expect(ConditionSchema.safeParse(seed).success).toBe(false);
40
+ seed.numeric_state.value = "health.system.p95_latency_ms";
41
+ expect(ConditionSchema.safeParse(seed).success).toBe(true);
42
+ });
43
+
44
+ it("state becomes schema-valid once `entity` is filled", () => {
45
+ const seed = defaultForKind("state") as {
46
+ state: { entity: string; status: string };
47
+ };
48
+ expect(ConditionSchema.safeParse(seed).success).toBe(false);
49
+ seed.state.entity = "payments-api";
50
+ expect(ConditionSchema.safeParse(seed).success).toBe(true);
51
+ });
52
+
53
+ it("combinator defaults discriminate correctly", () => {
54
+ expect(kindOf(defaultForKind("and"))).toBe("and");
55
+ expect(kindOf(defaultForKind("or"))).toBe("or");
56
+ expect(kindOf(defaultForKind("not"))).toBe("not");
57
+ expect(kindOf(defaultForKind("expr"))).toBe("expr");
58
+ });
59
+ });
60
+
61
+ describe("CONDITION_KIND_META", () => {
62
+ // The full kind union — the picker groups every one of these, so a missing
63
+ // entry would silently drop a condition type from the type picker.
64
+ const ALL_KINDS: ConditionKind[] = [
65
+ "expr",
66
+ "and",
67
+ "or",
68
+ "not",
69
+ "numeric_state",
70
+ "time",
71
+ "state",
72
+ ];
73
+ const VALID_GROUPS: ConditionKindGroup[] = [
74
+ "Structured",
75
+ "Logical",
76
+ "Advanced",
77
+ ];
78
+
79
+ it("has exactly one entry per ConditionKind (no missing/extra keys)", () => {
80
+ expect(Object.keys(CONDITION_KIND_META).toSorted()).toEqual(
81
+ [...ALL_KINDS].toSorted(),
82
+ );
83
+ });
84
+
85
+ // Every kind reachable from the editor's seed switch must have meta so the
86
+ // picker can render a row for it.
87
+ for (const kind of ALL_KINDS) {
88
+ it(`describes ${kind} with a non-empty label/description and valid group`, () => {
89
+ const meta = CONDITION_KIND_META[kind];
90
+ expect(meta.kind).toBe(kind);
91
+ expect(meta.label.length).toBeGreaterThan(0);
92
+ expect(meta.description.length).toBeGreaterThan(0);
93
+ expect(meta.icon.length).toBeGreaterThan(0);
94
+ expect(VALID_GROUPS).toContain(meta.group);
95
+ });
96
+ }
97
+
98
+ // defaultForKind is the editor's source of truth for which kinds exist;
99
+ // assert the meta map covers exactly the same set so they can never drift.
100
+ it("covers every kind defaultForKind can seed", () => {
101
+ for (const kind of ALL_KINDS) {
102
+ expect(defaultForKind(kind)).toBeDefined();
103
+ expect(CONDITION_KIND_META[kind]).toBeDefined();
104
+ }
105
+ });
106
+ });
107
+
108
+ describe("kindOf", () => {
109
+ it("classifies a raw expression string as expr", () => {
110
+ expect(kindOf("trigger.payload.x == 1")).toBe("expr");
111
+ });
112
+
113
+ it("classifies each structured variant", () => {
114
+ const cases: Array<[ConditionInput, ConditionKind]> = [
115
+ [{ numeric_state: { value: "v", above: 1 } }, "numeric_state"],
116
+ [{ time: { after: "09:00" } }, "time"],
117
+ [{ state: { entity: "s", status: "unhealthy" } }, "state"],
118
+ [{ and: ["a"] }, "and"],
119
+ [{ or: ["a"] }, "or"],
120
+ [{ not: "a" }, "not"],
121
+ ];
122
+ for (const [condition, expected] of cases) {
123
+ expect(kindOf(condition)).toBe(expected);
124
+ }
125
+ });
126
+ });
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Pure helpers for the structured `ConditionEditor` - kept free of any
3
+ * React / `@checkstack/ui` imports so they can be unit-tested under bun
4
+ * (the UI barrel drags Monaco's vscode-only modules, which break bun's
5
+ * test runner).
6
+ */
7
+ import type { ConditionInput } from "@checkstack/automation-common";
8
+ import type { LucideIconName } from "@checkstack/ui";
9
+
10
+ export type ConditionKind =
11
+ | "expr"
12
+ | "and"
13
+ | "or"
14
+ | "not"
15
+ | "numeric_state"
16
+ | "time"
17
+ | "state";
18
+
19
+ /** Picker groups, mirroring the structured / logical / advanced split. */
20
+ export type ConditionKindGroup = "Structured" | "Logical" | "Advanced";
21
+
22
+ export interface ConditionKindMeta {
23
+ kind: ConditionKind;
24
+ label: string;
25
+ description: string;
26
+ icon: LucideIconName;
27
+ group: ConditionKindGroup;
28
+ }
29
+
30
+ /**
31
+ * The only "registry" conditions have: per-kind labels, descriptions, icons
32
+ * and picker groups. Single source of truth for the condition type picker
33
+ * (and the labels mirror the `ConditionEditor` kind selector). Every
34
+ * {@link ConditionKind} has exactly one entry.
35
+ */
36
+ export const CONDITION_KIND_META: Record<ConditionKind, ConditionKindMeta> = {
37
+ numeric_state: {
38
+ kind: "numeric_state",
39
+ label: "numeric state",
40
+ description: "Compare a numeric value (path or template) to a threshold.",
41
+ icon: "Gauge",
42
+ group: "Structured",
43
+ },
44
+ time: {
45
+ kind: "time",
46
+ label: "time of day",
47
+ description: "Gate on a time window, weekdays and timezone.",
48
+ icon: "Clock",
49
+ group: "Structured",
50
+ },
51
+ state: {
52
+ kind: "state",
53
+ label: "system state",
54
+ description: "Require a system's status (with optional dwell).",
55
+ icon: "Activity",
56
+ group: "Structured",
57
+ },
58
+ and: {
59
+ kind: "and",
60
+ label: "and",
61
+ description: "All child conditions must hold.",
62
+ icon: "Ampersand",
63
+ group: "Logical",
64
+ },
65
+ or: {
66
+ kind: "or",
67
+ label: "or",
68
+ description: "At least one child condition must hold.",
69
+ icon: "GitBranch",
70
+ group: "Logical",
71
+ },
72
+ not: {
73
+ kind: "not",
74
+ label: "not",
75
+ description: "Negate a single child condition.",
76
+ icon: "Ban",
77
+ group: "Logical",
78
+ },
79
+ expr: {
80
+ kind: "expr",
81
+ label: "expression",
82
+ description: "Raw boolean expression — the escape hatch.",
83
+ icon: "Braces",
84
+ group: "Advanced",
85
+ },
86
+ };
87
+
88
+ /** Discriminate a condition into its editor kind. */
89
+ export function kindOf(condition: ConditionInput): ConditionKind {
90
+ if (typeof condition === "string") return "expr";
91
+ if ("and" in condition) return "and";
92
+ if ("or" in condition) return "or";
93
+ if ("not" in condition) return "not";
94
+ if ("numeric_state" in condition) return "numeric_state";
95
+ if ("time" in condition) return "time";
96
+ return "state";
97
+ }
98
+
99
+ /**
100
+ * Seed value when the operator switches a condition to a given kind.
101
+ * Structured kinds seed schema-valid defaults so a freshly-added
102
+ * structured condition round-trips through zod / YAML without error;
103
+ * the bare `expr` / combinator seeds use empty strings the operator
104
+ * fills in (the editor surfaces a validation hint until then).
105
+ */
106
+ export function defaultForKind(kind: ConditionKind): ConditionInput {
107
+ switch (kind) {
108
+ case "expr": {
109
+ return "";
110
+ }
111
+ case "and": {
112
+ return { and: [""] };
113
+ }
114
+ case "or": {
115
+ return { or: [""] };
116
+ }
117
+ case "not": {
118
+ return { not: "" };
119
+ }
120
+ case "numeric_state": {
121
+ return { numeric_state: { value: "", above: 0 } };
122
+ }
123
+ case "time": {
124
+ return { time: { after: "09:00", before: "17:00" } };
125
+ }
126
+ case "state": {
127
+ return { state: { entity: "", status: "unhealthy" } };
128
+ }
129
+ }
130
+ }
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import type {
3
+ ActionInput,
4
+ ConditionInput,
5
+ } from "@checkstack/automation-common";
6
+ import {
7
+ summarizeAction,
8
+ summarizeCondition,
9
+ summarizeTrigger,
10
+ } from "./item-summary";
11
+
12
+ describe("summarizeTrigger", () => {
13
+ it("returns the event id when no advanced fields are set", () => {
14
+ expect(summarizeTrigger({ event: "incident.created" })).toBe(
15
+ "incident.created",
16
+ );
17
+ });
18
+
19
+ it("appends the filter and dwell hints", () => {
20
+ expect(
21
+ summarizeTrigger({
22
+ event: "incident.created",
23
+ filter: "{{ trigger.payload.sev == 'high' }}",
24
+ for: { minutes: 5 },
25
+ }),
26
+ ).toBe("incident.created · if {{ trigger.payload.sev == 'high' }} · with dwell");
27
+ });
28
+
29
+ it("appends the windowed-count rate hint", () => {
30
+ expect(
31
+ summarizeTrigger({
32
+ event: "healthcheck.check_failed",
33
+ window: { count: 3, minutes: 60, refire: "once" },
34
+ }),
35
+ ).toBe("healthcheck.check_failed · 3× / 60m");
36
+ });
37
+
38
+ it("includes the partition expression in the rate hint when set", () => {
39
+ expect(
40
+ summarizeTrigger({
41
+ event: "healthcheck.check_failed",
42
+ window: {
43
+ count: 3,
44
+ minutes: 60,
45
+ refire: "once",
46
+ partitionBy: "trigger.payload.severity",
47
+ },
48
+ }),
49
+ ).toBe("healthcheck.check_failed · 3× / 60m by trigger.payload.severity");
50
+ });
51
+
52
+ it("returns undefined for an event-less trigger", () => {
53
+ expect(summarizeTrigger({ event: "" })).toBeUndefined();
54
+ });
55
+ });
56
+
57
+ describe("summarizeAction", () => {
58
+ it("uses the local name for a provider action", () => {
59
+ const action: ActionInput = {
60
+ action: "integration-jira.create_issue",
61
+ config: {},
62
+ enabled: true,
63
+ continue_on_error: false,
64
+ };
65
+ expect(summarizeAction(action)).toBe("create_issue");
66
+ });
67
+
68
+ it("returns undefined for an unpicked provider action", () => {
69
+ const action: ActionInput = {
70
+ action: "",
71
+ config: {},
72
+ enabled: true,
73
+ continue_on_error: false,
74
+ };
75
+ expect(summarizeAction(action)).toBeUndefined();
76
+ });
77
+
78
+ it("summarizes a choose by branch count and else", () => {
79
+ const action: ActionInput = {
80
+ enabled: true,
81
+ continue_on_error: false,
82
+ choose: [
83
+ { when: "a", sequence: [] },
84
+ { when: "b", sequence: [] },
85
+ ],
86
+ else: [
87
+ {
88
+ action: "automation.log",
89
+ config: {},
90
+ enabled: true,
91
+ continue_on_error: false,
92
+ },
93
+ ],
94
+ };
95
+ expect(summarizeAction(action)).toBe("2 branches + else");
96
+ });
97
+
98
+ it("summarizes a single-branch choose without pluralizing", () => {
99
+ const action: ActionInput = {
100
+ enabled: true,
101
+ continue_on_error: false,
102
+ choose: [{ when: "a", sequence: [] }],
103
+ };
104
+ expect(summarizeAction(action)).toBe("1 branch");
105
+ });
106
+
107
+ it("summarizes a delay in seconds", () => {
108
+ const action: ActionInput = {
109
+ enabled: true,
110
+ continue_on_error: false,
111
+ delay: { seconds: 30 },
112
+ };
113
+ expect(summarizeAction(action)).toBe("delay 30s");
114
+ });
115
+
116
+ it("summarizes a repeat count", () => {
117
+ const action: ActionInput = {
118
+ enabled: true,
119
+ continue_on_error: false,
120
+ repeat: { count: 5, sequence: [] },
121
+ };
122
+ expect(summarizeAction(action)).toBe("count 5");
123
+ });
124
+
125
+ it("summarizes variables by name", () => {
126
+ const action: ActionInput = {
127
+ enabled: true,
128
+ continue_on_error: false,
129
+ variables: { foo: "1", bar: "2" },
130
+ };
131
+ expect(summarizeAction(action)).toBe("set foo, bar");
132
+ });
133
+ });
134
+
135
+ describe("summarizeCondition", () => {
136
+ it("returns the trimmed expression source", () => {
137
+ expect(summarizeCondition(" trigger.payload.x == 1 ")).toBe(
138
+ "trigger.payload.x == 1",
139
+ );
140
+ });
141
+
142
+ it("labels an empty expression", () => {
143
+ expect(summarizeCondition("")).toBe("empty expression");
144
+ });
145
+
146
+ it("summarizes combinators by child count", () => {
147
+ expect(summarizeCondition({ and: ["a", "b"] })).toBe("all of 2");
148
+ expect(summarizeCondition({ or: ["a"] })).toBe("any of 1");
149
+ });
150
+
151
+ it("summarizes a numeric_state with bounds", () => {
152
+ const condition: ConditionInput = {
153
+ numeric_state: { value: "health.p95", above: 100, below: 500 },
154
+ };
155
+ expect(summarizeCondition(condition)).toBe("health.p95 > 100 and < 500");
156
+ });
157
+
158
+ it("summarizes a system state condition", () => {
159
+ const condition: ConditionInput = {
160
+ state: { entity: "api", status: "unhealthy" },
161
+ };
162
+ expect(summarizeCondition(condition)).toBe("api is unhealthy");
163
+ });
164
+
165
+ it("clips very long expressions", () => {
166
+ const long = "x".repeat(200);
167
+ const out = summarizeCondition(long);
168
+ expect(out.length).toBeLessThanOrEqual(80);
169
+ expect(out.endsWith("…")).toBe(true);
170
+ });
171
+ });