@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
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure summary-string derivation for the collapsed item cards (actions,
|
|
3
|
+
* triggers, conditions) in the visual editor. Each helper turns a piece of
|
|
4
|
+
* the definition into a single compact line shown under the card title in
|
|
5
|
+
* its collapsed Home-Assistant-style summary row.
|
|
6
|
+
*
|
|
7
|
+
* Kept free of any React / `@checkstack/ui` imports so the logic is
|
|
8
|
+
* unit-testable under bun (the UI barrel drags Monaco's vscode-only modules,
|
|
9
|
+
* which break bun's test runner).
|
|
10
|
+
*/
|
|
11
|
+
import type {
|
|
12
|
+
ActionInput,
|
|
13
|
+
ChooseInput,
|
|
14
|
+
ConditionInput,
|
|
15
|
+
DelayInput,
|
|
16
|
+
ParallelInput,
|
|
17
|
+
ProviderAction,
|
|
18
|
+
RepeatInput,
|
|
19
|
+
SequenceInput,
|
|
20
|
+
StopInput,
|
|
21
|
+
Trigger,
|
|
22
|
+
VariablesInput,
|
|
23
|
+
WaitForTriggerInput,
|
|
24
|
+
WaitUntilInput,
|
|
25
|
+
} from "@checkstack/automation-common";
|
|
26
|
+
import { actionKindOf } from "./action-helpers";
|
|
27
|
+
import { kindOf } from "./condition-kind";
|
|
28
|
+
|
|
29
|
+
/** Collapse internal whitespace and clip to `max` chars with an ellipsis. */
|
|
30
|
+
function clip(text: string, max = 80): string {
|
|
31
|
+
const normalized = text.replaceAll(/\s+/g, " ").trim();
|
|
32
|
+
if (normalized.length <= max) return normalized;
|
|
33
|
+
return `${normalized.slice(0, max - 1)}…`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Local (post-dot) name of a fully-qualified id, e.g. `plugin.foo` -> `foo`. */
|
|
37
|
+
function localName(qualified: string): string {
|
|
38
|
+
return qualified.includes(".")
|
|
39
|
+
? qualified.slice(qualified.lastIndexOf(".") + 1)
|
|
40
|
+
: qualified;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One-line summary for a trigger's collapsed card: the event id, plus a hint
|
|
45
|
+
* of the gating filter / dwell window when set. Returns `undefined` when there
|
|
46
|
+
* is nothing meaningful to show beyond the title (no event picked yet).
|
|
47
|
+
*/
|
|
48
|
+
export function summarizeTrigger(trigger: Trigger): string | undefined {
|
|
49
|
+
const parts: string[] = [];
|
|
50
|
+
if (trigger.event) parts.push(trigger.event);
|
|
51
|
+
if (trigger.filter) parts.push(`if ${trigger.filter}`);
|
|
52
|
+
if (trigger.for) parts.push("with dwell");
|
|
53
|
+
if (trigger.window) {
|
|
54
|
+
const rate = `${trigger.window.count}× / ${trigger.window.minutes}m`;
|
|
55
|
+
parts.push(
|
|
56
|
+
trigger.window.partitionBy
|
|
57
|
+
? `${rate} by ${trigger.window.partitionBy}`
|
|
58
|
+
: rate,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (parts.length === 0) return undefined;
|
|
62
|
+
return clip(parts.join(" · "));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** First few stops of a human time string for a duration-ish value. */
|
|
66
|
+
function summarizeDelay(value: DelayInput): string {
|
|
67
|
+
if ("template" in value.delay) {
|
|
68
|
+
return value.delay.template
|
|
69
|
+
? `delay ${value.delay.template}`
|
|
70
|
+
: "delay (template)";
|
|
71
|
+
}
|
|
72
|
+
return `delay ${value.delay.seconds}s`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function summarizeStop(value: StopInput): string {
|
|
76
|
+
const reason = value.stop.reason ? `: ${value.stop.reason}` : "";
|
|
77
|
+
return `${value.stop.error ? "fail run" : "stop run"}${reason}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function summarizeVariables(value: VariablesInput): string {
|
|
81
|
+
const keys = Object.keys(value.variables);
|
|
82
|
+
if (keys.length === 0) return "no variables";
|
|
83
|
+
return `set ${keys.join(", ")}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function summarizeChoose(value: ChooseInput): string {
|
|
87
|
+
const branches = value.choose.length;
|
|
88
|
+
const elseHint = value.else && value.else.length > 0 ? " + else" : "";
|
|
89
|
+
return `${branches} branch${branches === 1 ? "" : "es"}${elseHint}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function summarizeParallel(value: ParallelInput): string {
|
|
93
|
+
const n = value.parallel.length;
|
|
94
|
+
return `${n} branch${n === 1 ? "" : "es"}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function summarizeSequence(value: SequenceInput): string {
|
|
98
|
+
const n = value.sequence.length;
|
|
99
|
+
return `${n} step${n === 1 ? "" : "s"}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function summarizeRepeat(value: RepeatInput): string {
|
|
103
|
+
const mode = value.repeat;
|
|
104
|
+
if ("count" in mode) return `count ${mode.count}`;
|
|
105
|
+
if ("for_each" in mode) return `for each ${mode.for_each || "…"}`;
|
|
106
|
+
if ("while" in mode) return `while ${mode.while || "…"}`;
|
|
107
|
+
return `until ${mode.until || "…"}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function summarizeWaitForTrigger(value: WaitForTriggerInput): string {
|
|
111
|
+
const event = value.wait_for_trigger.event || "any event";
|
|
112
|
+
return `wait for ${event}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function summarizeWaitUntil(value: WaitUntilInput): string {
|
|
116
|
+
return `wait until ${summarizeCondition(value.wait_until.condition)}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* One-line summary for an action's collapsed card. Provider actions surface
|
|
121
|
+
* the local action name; structural kinds surface a compact shape hint
|
|
122
|
+
* (branch / step counts, repeat mode, delay length, …). Returns `undefined`
|
|
123
|
+
* when nothing beyond the kind label is meaningful.
|
|
124
|
+
*/
|
|
125
|
+
export function summarizeAction(action: ActionInput): string | undefined {
|
|
126
|
+
const kind = actionKindOf(action);
|
|
127
|
+
switch (kind) {
|
|
128
|
+
case "action": {
|
|
129
|
+
const provider = action as ProviderAction;
|
|
130
|
+
if (!provider.action) return undefined;
|
|
131
|
+
return clip(localName(provider.action));
|
|
132
|
+
}
|
|
133
|
+
case "choose": {
|
|
134
|
+
return clip(summarizeChoose(action as ChooseInput));
|
|
135
|
+
}
|
|
136
|
+
case "parallel": {
|
|
137
|
+
return clip(summarizeParallel(action as ParallelInput));
|
|
138
|
+
}
|
|
139
|
+
case "sequence": {
|
|
140
|
+
return clip(summarizeSequence(action as SequenceInput));
|
|
141
|
+
}
|
|
142
|
+
case "repeat": {
|
|
143
|
+
return clip(summarizeRepeat(action as RepeatInput));
|
|
144
|
+
}
|
|
145
|
+
case "delay": {
|
|
146
|
+
return clip(summarizeDelay(action as DelayInput));
|
|
147
|
+
}
|
|
148
|
+
case "stop": {
|
|
149
|
+
return clip(summarizeStop(action as StopInput));
|
|
150
|
+
}
|
|
151
|
+
case "variables": {
|
|
152
|
+
return clip(summarizeVariables(action as VariablesInput));
|
|
153
|
+
}
|
|
154
|
+
case "wait_for_trigger": {
|
|
155
|
+
return clip(summarizeWaitForTrigger(action as WaitForTriggerInput));
|
|
156
|
+
}
|
|
157
|
+
case "wait_until": {
|
|
158
|
+
return clip(summarizeWaitUntil(action as WaitUntilInput));
|
|
159
|
+
}
|
|
160
|
+
case "condition": {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* One-line summary for a condition's collapsed card. Expressions show their
|
|
168
|
+
* (clipped) source; structured kinds show a readable shape; combinators show
|
|
169
|
+
* a child count. Always returns a non-empty string (a bare empty expression
|
|
170
|
+
* reads as "empty expression").
|
|
171
|
+
*/
|
|
172
|
+
export function summarizeCondition(condition: ConditionInput): string {
|
|
173
|
+
const kind = kindOf(condition);
|
|
174
|
+
switch (kind) {
|
|
175
|
+
case "expr": {
|
|
176
|
+
const expr = typeof condition === "string" ? condition.trim() : "";
|
|
177
|
+
return expr ? clip(expr) : "empty expression";
|
|
178
|
+
}
|
|
179
|
+
case "and": {
|
|
180
|
+
const n = (condition as { and: ConditionInput[] }).and.length;
|
|
181
|
+
return `all of ${n}`;
|
|
182
|
+
}
|
|
183
|
+
case "or": {
|
|
184
|
+
const n = (condition as { or: ConditionInput[] }).or.length;
|
|
185
|
+
return `any of ${n}`;
|
|
186
|
+
}
|
|
187
|
+
case "not": {
|
|
188
|
+
return "not (…)";
|
|
189
|
+
}
|
|
190
|
+
case "numeric_state": {
|
|
191
|
+
const ns = (condition as { numeric_state: { value: string | number; above?: number; below?: number } })
|
|
192
|
+
.numeric_state;
|
|
193
|
+
const bounds: string[] = [];
|
|
194
|
+
if (ns.above !== undefined) bounds.push(`> ${ns.above}`);
|
|
195
|
+
if (ns.below !== undefined) bounds.push(`< ${ns.below}`);
|
|
196
|
+
return clip(`${ns.value} ${bounds.join(" and ")}`.trim());
|
|
197
|
+
}
|
|
198
|
+
case "time": {
|
|
199
|
+
const t = (condition as { time: { after?: string; before?: string } })
|
|
200
|
+
.time;
|
|
201
|
+
const range = [t.after, t.before].filter(Boolean).join(" – ");
|
|
202
|
+
return range ? `time ${range}` : "time of day";
|
|
203
|
+
}
|
|
204
|
+
case "state": {
|
|
205
|
+
const s = (condition as { state: { entity: string; status: string } })
|
|
206
|
+
.state;
|
|
207
|
+
return clip(`${s.entity || "system"} is ${s.status}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Plus, Search } from "lucide-react";
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogHeader,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
Input,
|
|
10
|
+
} from "@checkstack/ui";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Shared building blocks for the editor's Home-Assistant-style "type
|
|
14
|
+
* pickers" (add action / add trigger / add condition). Each picker opens a
|
|
15
|
+
* dialog with a single search box and a grouped, searchable list of rows;
|
|
16
|
+
* clicking a row creates the item and closes the dialog. The trigger and
|
|
17
|
+
* condition pickers are single-list; the action picker layers tabs on top of
|
|
18
|
+
* the same row + dialog shell.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A single picker row — icon, name, description, and a `+` affordance. Shared
|
|
23
|
+
* by every picker so the rows look identical across actions / triggers /
|
|
24
|
+
* conditions.
|
|
25
|
+
*/
|
|
26
|
+
export const PickerRow: React.FC<{
|
|
27
|
+
icon: React.ReactNode;
|
|
28
|
+
title: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
onClick: () => void;
|
|
31
|
+
}> = ({ icon, title, description, onClick }) => (
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
onClick={onClick}
|
|
35
|
+
className="flex w-full items-start gap-3 rounded-md border border-border/60 bg-card px-3 py-2.5 text-left transition-colors hover:border-border hover:bg-accent/50"
|
|
36
|
+
>
|
|
37
|
+
<span className="mt-0.5 shrink-0 text-muted-foreground">{icon}</span>
|
|
38
|
+
<div className="min-w-0 flex-1">
|
|
39
|
+
<div className="text-sm font-medium">{title}</div>
|
|
40
|
+
{description && (
|
|
41
|
+
<div className="text-xs text-muted-foreground">{description}</div>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
<Plus className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
|
45
|
+
</button>
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The "+ Add …" trigger button — outline, small, with a leading plus. Matches
|
|
50
|
+
* the Actions "Add step" button so every list's add affordance is identical.
|
|
51
|
+
*/
|
|
52
|
+
export const PickerAddButton: React.FC<{
|
|
53
|
+
label: string;
|
|
54
|
+
disabled?: boolean;
|
|
55
|
+
onClick: () => void;
|
|
56
|
+
}> = ({ label, disabled, onClick }) => (
|
|
57
|
+
<Button
|
|
58
|
+
type="button"
|
|
59
|
+
variant="outline"
|
|
60
|
+
size="sm"
|
|
61
|
+
disabled={disabled}
|
|
62
|
+
className="h-7 text-xs"
|
|
63
|
+
onClick={onClick}
|
|
64
|
+
>
|
|
65
|
+
<Plus className="mr-1 h-3 w-3" />
|
|
66
|
+
{label}
|
|
67
|
+
</Button>
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Search box used at the top of every picker dialog. Auto-focuses on open so
|
|
72
|
+
* the operator can type immediately.
|
|
73
|
+
*/
|
|
74
|
+
export const PickerSearchInput: React.FC<{
|
|
75
|
+
value: string;
|
|
76
|
+
onChange: (next: string) => void;
|
|
77
|
+
placeholder: string;
|
|
78
|
+
}> = ({ value, onChange, placeholder }) => (
|
|
79
|
+
<div className="relative">
|
|
80
|
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
81
|
+
<Input
|
|
82
|
+
autoFocus
|
|
83
|
+
value={value}
|
|
84
|
+
onChange={(event) => onChange(event.target.value)}
|
|
85
|
+
placeholder={placeholder}
|
|
86
|
+
className="pl-9"
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
export interface PickerDialogShellProps {
|
|
92
|
+
/** Label for the bottom "+ Add …" button (e.g. "Add trigger"). */
|
|
93
|
+
addLabel: string;
|
|
94
|
+
/** Title rendered in the dialog header. */
|
|
95
|
+
title: string;
|
|
96
|
+
/** Placeholder for the search box. */
|
|
97
|
+
searchPlaceholder: string;
|
|
98
|
+
disabled?: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Renders the dialog body given the current (lowercased, trimmed) query and a
|
|
101
|
+
* `close` callback the body calls after creating an item.
|
|
102
|
+
*/
|
|
103
|
+
children: (args: { query: string; close: () => void }) => React.ReactNode;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Single-list picker shell: a bottom "+ Add …" button that opens a dialog with
|
|
108
|
+
* a search box and a scrollable body. The body (a grouped list of
|
|
109
|
+
* {@link PickerRow}s) is supplied by the caller, which receives the live query
|
|
110
|
+
* and a `close` callback. Transient state resets on close so the dialog always
|
|
111
|
+
* reopens clean.
|
|
112
|
+
*/
|
|
113
|
+
export const PickerDialogShell: React.FC<PickerDialogShellProps> = ({
|
|
114
|
+
addLabel,
|
|
115
|
+
title,
|
|
116
|
+
searchPlaceholder,
|
|
117
|
+
disabled,
|
|
118
|
+
children,
|
|
119
|
+
}) => {
|
|
120
|
+
const [open, setOpen] = React.useState(false);
|
|
121
|
+
const [query, setQuery] = React.useState("");
|
|
122
|
+
|
|
123
|
+
React.useEffect(() => {
|
|
124
|
+
if (!open) setQuery("");
|
|
125
|
+
}, [open]);
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<>
|
|
129
|
+
<PickerAddButton
|
|
130
|
+
label={addLabel}
|
|
131
|
+
disabled={disabled}
|
|
132
|
+
onClick={() => setOpen(true)}
|
|
133
|
+
/>
|
|
134
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
135
|
+
<DialogContent size="lg">
|
|
136
|
+
<DialogHeader>
|
|
137
|
+
<DialogTitle>{title}</DialogTitle>
|
|
138
|
+
</DialogHeader>
|
|
139
|
+
<div className="space-y-3">
|
|
140
|
+
<PickerSearchInput
|
|
141
|
+
value={query}
|
|
142
|
+
onChange={setQuery}
|
|
143
|
+
placeholder={searchPlaceholder}
|
|
144
|
+
/>
|
|
145
|
+
<div className="max-h-[50vh] overflow-y-auto pr-1">
|
|
146
|
+
{children({
|
|
147
|
+
query: query.trim().toLowerCase(),
|
|
148
|
+
close: () => setOpen(false),
|
|
149
|
+
})}
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</DialogContent>
|
|
153
|
+
</Dialog>
|
|
154
|
+
</>
|
|
155
|
+
);
|
|
156
|
+
};
|
|
@@ -46,13 +46,19 @@ interface RegistryContextValue {
|
|
|
46
46
|
artifactTypes: ArtifactTypeInfo[];
|
|
47
47
|
/** True until all three queries have resolved. */
|
|
48
48
|
loading: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Id of the automation being edited, if saved. Undefined for a new,
|
|
51
|
+
* unsaved automation. Used by the script-test "Load from run" picker.
|
|
52
|
+
*/
|
|
53
|
+
automationId?: string;
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
const RegistryContext = React.createContext<RegistryContextValue | null>(null);
|
|
52
57
|
|
|
53
58
|
export const AutomationRegistryProvider: React.FC<{
|
|
54
59
|
children: React.ReactNode;
|
|
55
|
-
|
|
60
|
+
automationId?: string;
|
|
61
|
+
}> = ({ children, automationId }) => {
|
|
56
62
|
const client = usePluginClient(AutomationApi);
|
|
57
63
|
const triggers = client.listTriggers.useQuery();
|
|
58
64
|
const actions = client.listActions.useQuery();
|
|
@@ -65,8 +71,9 @@ export const AutomationRegistryProvider: React.FC<{
|
|
|
65
71
|
artifactTypes: artifactTypes.data?.items ?? [],
|
|
66
72
|
loading:
|
|
67
73
|
triggers.isLoading || actions.isLoading || artifactTypes.isLoading,
|
|
74
|
+
automationId,
|
|
68
75
|
}),
|
|
69
|
-
[triggers, actions, artifactTypes],
|
|
76
|
+
[triggers, actions, artifactTypes, automationId],
|
|
70
77
|
);
|
|
71
78
|
|
|
72
79
|
return (
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type {
|
|
3
|
+
ActionInfo,
|
|
4
|
+
AutomationDefinition,
|
|
5
|
+
} from "@checkstack/automation-common";
|
|
6
|
+
import type { ActionInput } from "@checkstack/automation-common";
|
|
7
|
+
import { collectScriptActions } from "./script-actions";
|
|
8
|
+
import { actionPathToDefPath, defPathKey } from "./editor-validation";
|
|
9
|
+
|
|
10
|
+
const BASE = { enabled: true, continue_on_error: false } as const;
|
|
11
|
+
|
|
12
|
+
/** Provider-action literal with the required base fields filled in. */
|
|
13
|
+
const pa = (action: string, config: Record<string, unknown>): ActionInput => ({
|
|
14
|
+
...BASE,
|
|
15
|
+
action,
|
|
16
|
+
config,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const RUN_SCRIPT: ActionInfo = {
|
|
20
|
+
qualifiedId: "integration-script.run_script",
|
|
21
|
+
displayName: "Run Script (TypeScript)",
|
|
22
|
+
description: undefined,
|
|
23
|
+
category: "Scripts",
|
|
24
|
+
ownerPluginId: "integration-script",
|
|
25
|
+
configSchema: {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
script: { type: "string", "x-editor-types": ["typescript"] },
|
|
29
|
+
secretEnv: { type: "object", "x-secret-env": true },
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
produces: undefined,
|
|
33
|
+
consumes: [],
|
|
34
|
+
connectionProviderId: undefined,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const LOG_ACTION: ActionInfo = {
|
|
38
|
+
qualifiedId: "core.log",
|
|
39
|
+
displayName: "Log",
|
|
40
|
+
description: undefined,
|
|
41
|
+
category: "Core",
|
|
42
|
+
ownerPluginId: "core",
|
|
43
|
+
configSchema: {
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: { message: { type: "string" } },
|
|
46
|
+
},
|
|
47
|
+
produces: undefined,
|
|
48
|
+
consumes: [],
|
|
49
|
+
connectionProviderId: undefined,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const ACTIONS = [RUN_SCRIPT, LOG_ACTION];
|
|
53
|
+
|
|
54
|
+
function def(overrides: Partial<AutomationDefinition>): AutomationDefinition {
|
|
55
|
+
return {
|
|
56
|
+
name: "Test",
|
|
57
|
+
triggers: [{ event: "incident.created" }],
|
|
58
|
+
conditions: [],
|
|
59
|
+
actions: [],
|
|
60
|
+
mode: "single",
|
|
61
|
+
concurrency_scope: "automation",
|
|
62
|
+
max_runs: 1,
|
|
63
|
+
...overrides,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("collectScriptActions", () => {
|
|
68
|
+
it("collects a root-level script action with its path, source and language", () => {
|
|
69
|
+
const refs = collectScriptActions({
|
|
70
|
+
actions: ACTIONS,
|
|
71
|
+
definition: def({
|
|
72
|
+
actions: [
|
|
73
|
+
pa("integration-script.run_script", {
|
|
74
|
+
script: "export default async () => context.trigger.payload;",
|
|
75
|
+
}),
|
|
76
|
+
],
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(refs).toHaveLength(1);
|
|
81
|
+
expect(refs[0]).toMatchObject({
|
|
82
|
+
path: [{ slot: "root", index: 0 }],
|
|
83
|
+
issuePath: ["actions", 0, "config", "script"],
|
|
84
|
+
language: "typescript",
|
|
85
|
+
source: "export default async () => context.trigger.payload;",
|
|
86
|
+
secretEnvNames: [],
|
|
87
|
+
});
|
|
88
|
+
expect(refs[0]?.id).toBe(defPathKey(["actions", 0, "config", "script"]));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("extracts declared secretEnv names from the action config", () => {
|
|
92
|
+
const refs = collectScriptActions({
|
|
93
|
+
actions: ACTIONS,
|
|
94
|
+
definition: def({
|
|
95
|
+
actions: [
|
|
96
|
+
pa("integration-script.run_script", {
|
|
97
|
+
script: "context;",
|
|
98
|
+
secretEnv: { API_TOKEN: "secret-1", DB_PASS: "secret-2" },
|
|
99
|
+
}),
|
|
100
|
+
],
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
expect(refs[0]?.secretEnvNames).toEqual(["API_TOKEN", "DB_PASS"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("skips empty scripts and non-script provider actions", () => {
|
|
107
|
+
const refs = collectScriptActions({
|
|
108
|
+
actions: ACTIONS,
|
|
109
|
+
definition: def({
|
|
110
|
+
actions: [
|
|
111
|
+
pa("integration-script.run_script", { script: " " }),
|
|
112
|
+
pa("core.log", { message: "hi" }),
|
|
113
|
+
pa("integration-script.run_script", {}),
|
|
114
|
+
],
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
expect(refs).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("finds scripts nested in choose / else / parallel / repeat / sequence", () => {
|
|
121
|
+
const refs = collectScriptActions({
|
|
122
|
+
actions: ACTIONS,
|
|
123
|
+
definition: def({
|
|
124
|
+
actions: [
|
|
125
|
+
{
|
|
126
|
+
...BASE,
|
|
127
|
+
choose: [
|
|
128
|
+
{
|
|
129
|
+
when: "true",
|
|
130
|
+
sequence: [pa("integration-script.run_script", { script: "a;" })],
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
else: [pa("integration-script.run_script", { script: "b;" })],
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
...BASE,
|
|
137
|
+
parallel: [
|
|
138
|
+
{
|
|
139
|
+
...BASE,
|
|
140
|
+
repeat: {
|
|
141
|
+
count: 2,
|
|
142
|
+
sequence: [
|
|
143
|
+
pa("integration-script.run_script", { script: "c;" }),
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
}),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const issueKeys = refs.map((r) => r.issuePath.join("."));
|
|
154
|
+
expect(refs).toHaveLength(3);
|
|
155
|
+
// choose-when[0].sequence[0]
|
|
156
|
+
expect(issueKeys).toContain("actions.0.choose.0.sequence.0.config.script");
|
|
157
|
+
// choose else[0]
|
|
158
|
+
expect(issueKeys).toContain("actions.0.else.0.config.script");
|
|
159
|
+
// parallel[0] -> repeat.sequence[0]
|
|
160
|
+
expect(issueKeys).toContain(
|
|
161
|
+
"actions.1.parallel.0.repeat.sequence.0.config.script",
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("derives issuePath via the same actionPathToDefPath the cards use", () => {
|
|
166
|
+
const refs = collectScriptActions({
|
|
167
|
+
actions: ACTIONS,
|
|
168
|
+
definition: def({
|
|
169
|
+
actions: [
|
|
170
|
+
{
|
|
171
|
+
...BASE,
|
|
172
|
+
sequence: [pa("integration-script.run_script", { script: "x;" })],
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
}),
|
|
176
|
+
});
|
|
177
|
+
const path = refs[0]!.path;
|
|
178
|
+
expect(refs[0]!.issuePath).toEqual([
|
|
179
|
+
...actionPathToDefPath(path),
|
|
180
|
+
"config",
|
|
181
|
+
"script",
|
|
182
|
+
]);
|
|
183
|
+
});
|
|
184
|
+
});
|