@checkstack/automation-frontend 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +664 -0
- package/package.json +38 -0
- package/src/components/AutomationMenuItems.tsx +37 -0
- package/src/editor/ActionEditor.tsx +367 -0
- package/src/editor/ActionListEditor.tsx +203 -0
- package/src/editor/AddActionDialog.tsx +225 -0
- package/src/editor/AutomationDefinitionContext.tsx +37 -0
- package/src/editor/AutomationDefinitionEditor.tsx +99 -0
- package/src/editor/ConditionEditor.tsx +218 -0
- package/src/editor/ConditionsEditor.tsx +89 -0
- package/src/editor/ItemPicker.tsx +147 -0
- package/src/editor/TriggersEditor.tsx +269 -0
- package/src/editor/action-composite-cards.tsx +390 -0
- package/src/editor/action-helpers.ts +365 -0
- package/src/editor/action-leaf-cards.tsx +426 -0
- package/src/editor/editor-validation.test.ts +95 -0
- package/src/editor/editor-validation.tsx +200 -0
- package/src/editor/registry-context.tsx +192 -0
- package/src/editor/template-completion.test.ts +412 -0
- package/src/editor/template-completion.ts +664 -0
- package/src/editor/template-helpers.test.ts +145 -0
- package/src/editor/template-helpers.ts +95 -0
- package/src/editor/trigger-helpers.test.ts +58 -0
- package/src/editor/trigger-helpers.ts +67 -0
- package/src/editor/useConnectionOptionResolvers.ts +80 -0
- package/src/editor/yaml-markers.ts +116 -0
- package/src/index.tsx +95 -0
- package/src/pages/AutomationEditPage.tsx +567 -0
- package/src/pages/AutomationListPage.tsx +304 -0
- package/src/pages/RunDetailPage.tsx +333 -0
- package/src/pages/RunsPage.tsx +233 -0
- package/src/pages/TemplatePlaygroundPage.tsx +224 -0
- package/src/script-context.test.ts +247 -0
- package/src/script-context.ts +218 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link } from "react-router-dom";
|
|
3
|
+
import { resolveRoute } from "@checkstack/common";
|
|
4
|
+
import { integrationRoutes } from "@checkstack/integration-common";
|
|
5
|
+
import {
|
|
6
|
+
DynamicForm,
|
|
7
|
+
Input,
|
|
8
|
+
Label,
|
|
9
|
+
Select,
|
|
10
|
+
SelectContent,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectTrigger,
|
|
13
|
+
SelectValue,
|
|
14
|
+
TemplateValueInput,
|
|
15
|
+
Toggle,
|
|
16
|
+
KeyValueEditor,
|
|
17
|
+
customShellEnvVars,
|
|
18
|
+
type ShellEnvVar,
|
|
19
|
+
type TemplateCompletionProvider,
|
|
20
|
+
type VariableNode,
|
|
21
|
+
} from "@checkstack/ui";
|
|
22
|
+
import type {
|
|
23
|
+
ConditionGuardInput,
|
|
24
|
+
ConditionInput,
|
|
25
|
+
DelayInput,
|
|
26
|
+
ProviderAction,
|
|
27
|
+
StopInput,
|
|
28
|
+
VariablesInput,
|
|
29
|
+
WaitForTriggerInput,
|
|
30
|
+
} from "@checkstack/automation-common";
|
|
31
|
+
import { useAutomationRegistry } from "./registry-context";
|
|
32
|
+
import { ItemPicker } from "./ItemPicker";
|
|
33
|
+
import { ConditionEditor } from "./ConditionEditor";
|
|
34
|
+
import { useConnectionOptionResolvers } from "./useConnectionOptionResolvers";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Provider action body. Picks an action id from `listActions()` then
|
|
38
|
+
* renders the action's `configJsonSchema` via `DynamicForm`. The staged
|
|
39
|
+
* `completionProvider` (template mode) is handed to DynamicForm so every
|
|
40
|
+
* plain string config field (e.g. a log action's `message`) gets inline
|
|
41
|
+
* `{{ … }}` field / comparator / value / filter autocomplete;
|
|
42
|
+
* `templateProperties` still feeds the Monaco-backed multi-type editor
|
|
43
|
+
* fields.
|
|
44
|
+
*/
|
|
45
|
+
export const ProviderActionBody: React.FC<{
|
|
46
|
+
value: ProviderAction;
|
|
47
|
+
onChange: (next: ProviderAction) => void;
|
|
48
|
+
templateProperties: Array<{ path: string; type: string; description?: string }>;
|
|
49
|
+
completionProvider: TemplateCompletionProvider;
|
|
50
|
+
/**
|
|
51
|
+
* Monaco `declare const context: …` lib for inline-script action
|
|
52
|
+
* editors (`Run Script (TypeScript)`), so `context.trigger.payload`
|
|
53
|
+
* is typed as the automation's trigger union instead of an untyped
|
|
54
|
+
* default.
|
|
55
|
+
*/
|
|
56
|
+
typeDefinitions: string;
|
|
57
|
+
/**
|
|
58
|
+
* `$CHECKSTACK_*` env var names for the shell script editor's `$`
|
|
59
|
+
* autocomplete — the names the backend injects from the run scope.
|
|
60
|
+
*/
|
|
61
|
+
shellEnvVars: ShellEnvVar[];
|
|
62
|
+
// Accepted for a uniform body signature, but unused: the action is fixed
|
|
63
|
+
// once created (no switcher), and `DynamicForm` has no read-only mode yet,
|
|
64
|
+
// so there is nothing here to disable.
|
|
65
|
+
disabled?: boolean;
|
|
66
|
+
}> = ({
|
|
67
|
+
value,
|
|
68
|
+
onChange,
|
|
69
|
+
templateProperties,
|
|
70
|
+
completionProvider,
|
|
71
|
+
typeDefinitions,
|
|
72
|
+
shellEnvVars,
|
|
73
|
+
}) => {
|
|
74
|
+
const { actions, loading } = useAutomationRegistry();
|
|
75
|
+
// The shell script action exposes a user-editable `env` field; surface
|
|
76
|
+
// its keys as `$`-completions alongside the run-scope `$CHECKSTACK_*`
|
|
77
|
+
// vars (memoised so the editor's completion provider isn't re-registered
|
|
78
|
+
// on every keystroke).
|
|
79
|
+
const mergedShellEnvVars = React.useMemo(
|
|
80
|
+
// User's own declared `env` keys first (most relevant), then the
|
|
81
|
+
// run-scope `$CHECKSTACK_*` vars — the suggest list orders by insertion.
|
|
82
|
+
() => [...customShellEnvVars(value.config.env), ...shellEnvVars],
|
|
83
|
+
[shellEnvVars, value.config.env],
|
|
84
|
+
);
|
|
85
|
+
// The action is chosen up front in the add-step picker and fixed for the
|
|
86
|
+
// life of the step — to use a different action, add a new step and delete
|
|
87
|
+
// this one. So the card just resolves the registry entry and renders its
|
|
88
|
+
// config; there is no in-card action switcher.
|
|
89
|
+
const selected = actions.find((action) => action.qualifiedId === value.action);
|
|
90
|
+
// Connection-backed actions (Jira / Teams / Webex) declare a
|
|
91
|
+
// `connectionProviderId`; the bridge turns their `x-options-resolver`
|
|
92
|
+
// fields into a live connection picker + cascading provider dropdowns.
|
|
93
|
+
// Non-connection actions get an empty map (no-op) since the id is absent.
|
|
94
|
+
const optionsResolvers = useConnectionOptionResolvers(
|
|
95
|
+
selected?.connectionProviderId,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
if (!selected) {
|
|
99
|
+
// Registry still loading vs. a genuinely unresolvable action id. The
|
|
100
|
+
// latter can't be fixed in place (no switcher), so point the operator at
|
|
101
|
+
// the add-a-new-step path.
|
|
102
|
+
return loading ? (
|
|
103
|
+
<p className="text-xs italic text-muted-foreground">Loading action…</p>
|
|
104
|
+
) : (
|
|
105
|
+
<p className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
|
106
|
+
Unknown action{" "}
|
|
107
|
+
<code className="font-mono">{value.action || "(none)"}</code>. Delete
|
|
108
|
+
this step and add a new one.
|
|
109
|
+
</p>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className="space-y-3">
|
|
115
|
+
{selected.consumes && selected.consumes.length > 0 && (
|
|
116
|
+
<p className="text-[10px] text-muted-foreground">
|
|
117
|
+
Consumes:{" "}
|
|
118
|
+
<code className="font-mono">{selected.consumes.join(", ")}</code>
|
|
119
|
+
</p>
|
|
120
|
+
)}
|
|
121
|
+
<div className="space-y-1">
|
|
122
|
+
<span className="block text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
123
|
+
Configuration
|
|
124
|
+
</span>
|
|
125
|
+
{selected.connectionProviderId && (
|
|
126
|
+
<p className="text-[10px] text-muted-foreground">
|
|
127
|
+
Connections are managed under{" "}
|
|
128
|
+
<Link
|
|
129
|
+
to={resolveRoute(integrationRoutes.routes.list)}
|
|
130
|
+
className="underline underline-offset-2 hover:text-foreground"
|
|
131
|
+
>
|
|
132
|
+
Integrations
|
|
133
|
+
</Link>
|
|
134
|
+
. Create one there if the picker is empty.
|
|
135
|
+
</p>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
<DynamicForm
|
|
139
|
+
schema={selected.configSchema}
|
|
140
|
+
value={value.config}
|
|
141
|
+
onChange={(next) => onChange({ ...value, config: next })}
|
|
142
|
+
optionsResolvers={optionsResolvers}
|
|
143
|
+
templateProperties={templateProperties}
|
|
144
|
+
templateCompletionProvider={completionProvider}
|
|
145
|
+
typeDefinitions={typeDefinitions}
|
|
146
|
+
shellEnvVars={mergedShellEnvVars}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export const VariablesActionBody: React.FC<{
|
|
153
|
+
value: VariablesInput;
|
|
154
|
+
onChange: (next: VariablesInput) => void;
|
|
155
|
+
templateProperties: Array<{ path: string; type: string; description?: string }>;
|
|
156
|
+
disabled?: boolean;
|
|
157
|
+
}> = ({ value, onChange, templateProperties, disabled }) => {
|
|
158
|
+
const pairs = React.useMemo(
|
|
159
|
+
() =>
|
|
160
|
+
Object.entries(value.variables).map(([key, raw]) => ({
|
|
161
|
+
key,
|
|
162
|
+
value: typeof raw === "string" ? raw : JSON.stringify(raw),
|
|
163
|
+
})),
|
|
164
|
+
[value.variables],
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const handleChange = (
|
|
168
|
+
next: Array<{ key: string; value: string }>,
|
|
169
|
+
): void => {
|
|
170
|
+
const record: Record<string, unknown> = {};
|
|
171
|
+
for (const pair of next) {
|
|
172
|
+
if (!pair.key) continue;
|
|
173
|
+
// Try parsing as JSON; fall back to raw string. Lets the operator
|
|
174
|
+
// write either a literal template `"{{ trigger.payload.x }}"` or
|
|
175
|
+
// a structured value like `42` / `true` / `{...}`.
|
|
176
|
+
try {
|
|
177
|
+
record[pair.key] = JSON.parse(pair.value);
|
|
178
|
+
} catch {
|
|
179
|
+
record[pair.key] = pair.value;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
onChange({ ...value, variables: record });
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<div className="space-y-1">
|
|
187
|
+
<Label className="text-xs">Variables</Label>
|
|
188
|
+
<KeyValueEditor
|
|
189
|
+
id="vars"
|
|
190
|
+
value={pairs}
|
|
191
|
+
onChange={handleChange}
|
|
192
|
+
keyPlaceholder="name"
|
|
193
|
+
valuePlaceholder='"literal" or {{ template }}'
|
|
194
|
+
templateProperties={templateProperties}
|
|
195
|
+
/>
|
|
196
|
+
<p className="text-[10px] text-muted-foreground">
|
|
197
|
+
Values parsed as JSON when possible; otherwise treated as a string
|
|
198
|
+
/ template. Disabled state from the parent card is honoured.
|
|
199
|
+
</p>
|
|
200
|
+
{disabled && (
|
|
201
|
+
<p className="text-[10px] italic text-muted-foreground">
|
|
202
|
+
Read-only.
|
|
203
|
+
</p>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export const StopActionBody: React.FC<{
|
|
210
|
+
value: StopInput;
|
|
211
|
+
onChange: (next: StopInput) => void;
|
|
212
|
+
disabled?: boolean;
|
|
213
|
+
}> = ({ value, onChange, disabled }) => (
|
|
214
|
+
<div className="space-y-3">
|
|
215
|
+
<div className="space-y-1">
|
|
216
|
+
<Label className="text-xs">Reason</Label>
|
|
217
|
+
<Input
|
|
218
|
+
value={value.stop.reason ?? ""}
|
|
219
|
+
onChange={(event) =>
|
|
220
|
+
onChange({
|
|
221
|
+
...value,
|
|
222
|
+
stop: { ...value.stop, reason: event.target.value || undefined },
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
placeholder="Stopping because…"
|
|
226
|
+
disabled={disabled}
|
|
227
|
+
/>
|
|
228
|
+
</div>
|
|
229
|
+
<div className="flex items-center justify-between">
|
|
230
|
+
<Label className="text-xs">Mark run as failed</Label>
|
|
231
|
+
<Toggle
|
|
232
|
+
checked={value.stop.error === true}
|
|
233
|
+
onCheckedChange={(error) =>
|
|
234
|
+
onChange({ ...value, stop: { ...value.stop, error } })
|
|
235
|
+
}
|
|
236
|
+
disabled={disabled}
|
|
237
|
+
/>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
export const DelayActionBody: React.FC<{
|
|
243
|
+
value: DelayInput;
|
|
244
|
+
onChange: (next: DelayInput) => void;
|
|
245
|
+
completionProvider: TemplateCompletionProvider;
|
|
246
|
+
disabled?: boolean;
|
|
247
|
+
}> = ({ value, onChange, completionProvider, disabled }) => {
|
|
248
|
+
const useTemplate = "template" in value.delay;
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<div className="space-y-3">
|
|
252
|
+
<div className="flex items-center justify-between">
|
|
253
|
+
<Label className="text-xs">Source</Label>
|
|
254
|
+
<Select
|
|
255
|
+
value={useTemplate ? "template" : "seconds"}
|
|
256
|
+
onValueChange={(next) => {
|
|
257
|
+
if (next === "seconds") {
|
|
258
|
+
onChange({ ...value, delay: { seconds: 30 } });
|
|
259
|
+
} else {
|
|
260
|
+
onChange({ ...value, delay: { template: "" } });
|
|
261
|
+
}
|
|
262
|
+
}}
|
|
263
|
+
disabled={disabled}
|
|
264
|
+
>
|
|
265
|
+
<SelectTrigger className="h-7 w-32 text-xs">
|
|
266
|
+
<SelectValue />
|
|
267
|
+
</SelectTrigger>
|
|
268
|
+
<SelectContent>
|
|
269
|
+
<SelectItem value="seconds">Seconds</SelectItem>
|
|
270
|
+
<SelectItem value="template">Template</SelectItem>
|
|
271
|
+
</SelectContent>
|
|
272
|
+
</Select>
|
|
273
|
+
</div>
|
|
274
|
+
{useTemplate ? (
|
|
275
|
+
<div className="space-y-1">
|
|
276
|
+
<Label className="text-xs">Template (renders to seconds)</Label>
|
|
277
|
+
<TemplateValueInput
|
|
278
|
+
value={(value.delay as { template: string }).template}
|
|
279
|
+
onChange={(next) =>
|
|
280
|
+
onChange({ ...value, delay: { template: next } })
|
|
281
|
+
}
|
|
282
|
+
placeholder="{{ trigger.payload.delaySeconds }}"
|
|
283
|
+
completionProvider={completionProvider}
|
|
284
|
+
disabled={disabled}
|
|
285
|
+
/>
|
|
286
|
+
</div>
|
|
287
|
+
) : (
|
|
288
|
+
<div className="space-y-1">
|
|
289
|
+
<Label className="text-xs">Seconds</Label>
|
|
290
|
+
<Input
|
|
291
|
+
type="number"
|
|
292
|
+
min={0}
|
|
293
|
+
max={86_400}
|
|
294
|
+
value={(value.delay as { seconds: number }).seconds}
|
|
295
|
+
onChange={(event) =>
|
|
296
|
+
onChange({
|
|
297
|
+
...value,
|
|
298
|
+
delay: { seconds: Math.max(0, Number(event.target.value)) },
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
disabled={disabled}
|
|
302
|
+
/>
|
|
303
|
+
</div>
|
|
304
|
+
)}
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
export const WaitForTriggerActionBody: React.FC<{
|
|
310
|
+
value: WaitForTriggerInput;
|
|
311
|
+
onChange: (next: WaitForTriggerInput) => void;
|
|
312
|
+
completionProvider: TemplateCompletionProvider;
|
|
313
|
+
disabled?: boolean;
|
|
314
|
+
}> = ({ value, onChange, completionProvider, disabled }) => {
|
|
315
|
+
const { triggers } = useAutomationRegistry();
|
|
316
|
+
const pickerItems = React.useMemo(
|
|
317
|
+
() =>
|
|
318
|
+
triggers.map((trigger) => ({
|
|
319
|
+
id: trigger.qualifiedId,
|
|
320
|
+
label: trigger.displayName,
|
|
321
|
+
description: trigger.description,
|
|
322
|
+
category: trigger.category,
|
|
323
|
+
})),
|
|
324
|
+
[triggers],
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<div className="space-y-3">
|
|
329
|
+
<div className="space-y-1">
|
|
330
|
+
<Label className="text-xs">Event to wait for</Label>
|
|
331
|
+
<ItemPicker
|
|
332
|
+
items={pickerItems}
|
|
333
|
+
value={value.wait_for_trigger.event}
|
|
334
|
+
onSelect={(id) =>
|
|
335
|
+
onChange({
|
|
336
|
+
...value,
|
|
337
|
+
wait_for_trigger: { ...value.wait_for_trigger, event: id },
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
placeholder="Pick a trigger event"
|
|
341
|
+
disabled={disabled}
|
|
342
|
+
/>
|
|
343
|
+
</div>
|
|
344
|
+
<div className="grid gap-2 sm:grid-cols-2">
|
|
345
|
+
<div className="space-y-1">
|
|
346
|
+
<Label className="text-xs">Filter template</Label>
|
|
347
|
+
<TemplateValueInput
|
|
348
|
+
value={value.wait_for_trigger.filter ?? ""}
|
|
349
|
+
onChange={(next) =>
|
|
350
|
+
onChange({
|
|
351
|
+
...value,
|
|
352
|
+
wait_for_trigger: {
|
|
353
|
+
...value.wait_for_trigger,
|
|
354
|
+
filter: next || undefined,
|
|
355
|
+
},
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
placeholder="{{ trigger.payload.id == var.targetId }}"
|
|
359
|
+
completionProvider={completionProvider}
|
|
360
|
+
disabled={disabled}
|
|
361
|
+
/>
|
|
362
|
+
</div>
|
|
363
|
+
<div className="space-y-1">
|
|
364
|
+
<Label className="text-xs">Timeout (seconds)</Label>
|
|
365
|
+
<Input
|
|
366
|
+
type="number"
|
|
367
|
+
min={1}
|
|
368
|
+
value={value.wait_for_trigger.timeout_seconds ?? ""}
|
|
369
|
+
onChange={(event) =>
|
|
370
|
+
onChange({
|
|
371
|
+
...value,
|
|
372
|
+
wait_for_trigger: {
|
|
373
|
+
...value.wait_for_trigger,
|
|
374
|
+
timeout_seconds: event.target.value
|
|
375
|
+
? Number(event.target.value)
|
|
376
|
+
: undefined,
|
|
377
|
+
},
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
disabled={disabled}
|
|
381
|
+
placeholder="No timeout"
|
|
382
|
+
/>
|
|
383
|
+
</div>
|
|
384
|
+
</div>
|
|
385
|
+
<div className="space-y-1">
|
|
386
|
+
<Label className="text-xs">Context key (optional)</Label>
|
|
387
|
+
<Input
|
|
388
|
+
value={value.wait_for_trigger.context_key ?? ""}
|
|
389
|
+
onChange={(event) =>
|
|
390
|
+
onChange({
|
|
391
|
+
...value,
|
|
392
|
+
wait_for_trigger: {
|
|
393
|
+
...value.wait_for_trigger,
|
|
394
|
+
context_key: event.target.value || undefined,
|
|
395
|
+
},
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
placeholder="Defaults to the triggering event's contextKey"
|
|
399
|
+
disabled={disabled}
|
|
400
|
+
className="font-mono text-xs"
|
|
401
|
+
/>
|
|
402
|
+
</div>
|
|
403
|
+
</div>
|
|
404
|
+
);
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
export const ConditionGuardActionBody: React.FC<{
|
|
408
|
+
value: ConditionGuardInput;
|
|
409
|
+
onChange: (next: ConditionGuardInput) => void;
|
|
410
|
+
variableNodes: VariableNode[];
|
|
411
|
+
completionProvider: TemplateCompletionProvider;
|
|
412
|
+
disabled?: boolean;
|
|
413
|
+
}> = ({ value, onChange, variableNodes, completionProvider }) => (
|
|
414
|
+
<div className="space-y-1">
|
|
415
|
+
<Label className="text-xs">Condition</Label>
|
|
416
|
+
<ConditionEditor
|
|
417
|
+
value={value.condition}
|
|
418
|
+
onChange={(next: ConditionInput) =>
|
|
419
|
+
onChange({ ...value, condition: next })
|
|
420
|
+
}
|
|
421
|
+
variableNodes={variableNodes}
|
|
422
|
+
completionProvider={completionProvider}
|
|
423
|
+
bare
|
|
424
|
+
/>
|
|
425
|
+
</div>
|
|
426
|
+
);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the issue-ownership grouping that drives per-card error
|
|
3
|
+
* marking in the visual editor.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from "bun:test";
|
|
6
|
+
import {
|
|
7
|
+
actionPathToDefPath,
|
|
8
|
+
defPathKey,
|
|
9
|
+
partitionIssues,
|
|
10
|
+
} from "./editor-validation";
|
|
11
|
+
|
|
12
|
+
describe("actionPathToDefPath", () => {
|
|
13
|
+
it("maps a root action", () => {
|
|
14
|
+
expect(actionPathToDefPath([{ slot: "root", index: 2 }])).toEqual([
|
|
15
|
+
"actions",
|
|
16
|
+
2,
|
|
17
|
+
]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("maps a nested choose-when action", () => {
|
|
21
|
+
expect(
|
|
22
|
+
actionPathToDefPath([
|
|
23
|
+
{ slot: "root", index: 0 },
|
|
24
|
+
{ slot: "choose-when", whenIndex: 1, index: 3 },
|
|
25
|
+
]),
|
|
26
|
+
).toEqual(["actions", 0, "choose", 1, "sequence", 3]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("maps repeat + sequence nesting", () => {
|
|
30
|
+
expect(
|
|
31
|
+
actionPathToDefPath([
|
|
32
|
+
{ slot: "root", index: 0 },
|
|
33
|
+
{ slot: "repeat", index: 1 },
|
|
34
|
+
]),
|
|
35
|
+
).toEqual(["actions", 0, "repeat", "sequence", 1]);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("partitionIssues", () => {
|
|
40
|
+
it("attaches a provider config issue to its action's defPath key", () => {
|
|
41
|
+
const { actions } = partitionIssues([
|
|
42
|
+
{ path: ["actions", 0, "config", "level"], message: "Invalid enum" },
|
|
43
|
+
]);
|
|
44
|
+
// Key is the space-joined defPath of the owning action.
|
|
45
|
+
expect(actions.get(defPathKey(["actions", 0]))).toEqual(["config.level: Invalid enum"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("attaches a nested action's config to the nested card, not the parent", () => {
|
|
49
|
+
const { actions } = partitionIssues([
|
|
50
|
+
{
|
|
51
|
+
path: ["actions", 0, "choose", 1, "sequence", 2, "config", "x"],
|
|
52
|
+
message: "bad",
|
|
53
|
+
},
|
|
54
|
+
]);
|
|
55
|
+
expect(actions.get(defPathKey(["actions", 0, "choose", 1, "sequence", 2]))).toEqual([
|
|
56
|
+
"config.x: bad",
|
|
57
|
+
]);
|
|
58
|
+
expect(actions.has(defPathKey(["actions", 0]))).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("attaches a choose's own `when` to the choose card", () => {
|
|
62
|
+
const { actions } = partitionIssues([
|
|
63
|
+
{ path: ["actions", 0, "choose", 0, "when"], message: "required" },
|
|
64
|
+
]);
|
|
65
|
+
expect(actions.get(defPathKey(["actions", 0]))).toEqual(["choose.0.when: required"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("routes trigger + condition issues to their own buckets", () => {
|
|
69
|
+
const { triggers, conditions } = partitionIssues([
|
|
70
|
+
{
|
|
71
|
+
path: ["triggers", 1, "config", "intervalSeconds"],
|
|
72
|
+
message: "too small",
|
|
73
|
+
},
|
|
74
|
+
{ path: ["conditions", 0], message: "bad template" },
|
|
75
|
+
]);
|
|
76
|
+
expect(triggers.get(1)).toEqual(["config.intervalSeconds: too small"]);
|
|
77
|
+
expect(conditions.get(0)).toEqual(["bad template"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("collects unattributable top-level issues under `other`", () => {
|
|
81
|
+
const { other } = partitionIssues([
|
|
82
|
+
{ path: ["name"], message: "Required" },
|
|
83
|
+
{ path: ["max_runs"], message: "Too big" },
|
|
84
|
+
]);
|
|
85
|
+
expect(other).toEqual(["name: Required", "max_runs: Too big"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("groups multiple issues on the same action", () => {
|
|
89
|
+
const { actions } = partitionIssues([
|
|
90
|
+
{ path: ["actions", 0, "config", "a"], message: "x" },
|
|
91
|
+
{ path: ["actions", 0, "config", "b"], message: "y" },
|
|
92
|
+
]);
|
|
93
|
+
expect(actions.get(defPathKey(["actions", 0]))).toEqual(["config.a: x", "config.b: y"]);
|
|
94
|
+
});
|
|
95
|
+
});
|