@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.
- package/CHANGELOG.md +352 -0
- package/package.json +13 -9
- package/src/components/AutomationGroupCombobox.tsx +133 -0
- package/src/editor/ActionEditor.tsx +180 -90
- package/src/editor/ActionListEditor.tsx +27 -1
- package/src/editor/AddActionDialog.tsx +15 -45
- package/src/editor/AddConditionDialog.tsx +86 -0
- package/src/editor/AddTriggerDialog.tsx +97 -0
- package/src/editor/AutomationDefinitionEditor.tsx +41 -2
- package/src/editor/ConditionEditor.tsx +359 -70
- package/src/editor/ConditionsEditor.tsx +113 -44
- package/src/editor/ItemSheet.tsx +51 -0
- package/src/editor/RunReplayPicker.tsx +97 -0
- package/src/editor/ScriptServicesBooter.tsx +53 -0
- package/src/editor/ScriptTestRenderer.tsx +150 -0
- package/src/editor/SystemEntityPicker.test.ts +37 -0
- package/src/editor/SystemEntityPicker.tsx +109 -0
- package/src/editor/TriggersEditor.tsx +345 -137
- package/src/editor/action-helpers.test.ts +107 -0
- package/src/editor/action-helpers.ts +72 -0
- package/src/editor/action-leaf-cards.tsx +98 -1
- package/src/editor/condition-kind.test.ts +126 -0
- package/src/editor/condition-kind.ts +130 -0
- package/src/editor/item-summary.test.ts +171 -0
- package/src/editor/item-summary.ts +210 -0
- package/src/editor/picker-dialog.tsx +156 -0
- package/src/editor/registry-context.tsx +9 -2
- package/src/editor/script-actions.test.ts +184 -0
- package/src/editor/script-actions.ts +146 -0
- package/src/editor/system-entity-picker.logic.ts +23 -0
- package/src/editor/template-completion.test.ts +22 -3
- package/src/editor/template-completion.ts +16 -8
- package/src/editor/template-helpers.ts +4 -0
- package/src/editor/trigger-helpers.test.ts +28 -0
- package/src/editor/trigger-helpers.ts +17 -0
- package/src/editor/useScriptDiagnostics.ts +108 -0
- package/src/index.tsx +2 -0
- package/src/pages/AutomationEditPage.tsx +95 -47
- package/src/pages/AutomationListPage.tsx +172 -123
- package/src/pages/automation-grouping.test.ts +86 -0
- package/src/pages/automation-grouping.ts +65 -0
- package/src/script-context.test.ts +142 -1
- package/src/script-context.ts +115 -0
- 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={
|
|
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
|
+
});
|