@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,233 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link, useParams } from "react-router-dom";
|
|
3
|
+
import { History, ChevronLeft } from "lucide-react";
|
|
4
|
+
import {
|
|
5
|
+
usePluginClient,
|
|
6
|
+
accessApiRef,
|
|
7
|
+
useApi,
|
|
8
|
+
wrapInSuspense,
|
|
9
|
+
} from "@checkstack/frontend-api";
|
|
10
|
+
import {
|
|
11
|
+
AutomationApi,
|
|
12
|
+
automationAccess,
|
|
13
|
+
automationRoutes,
|
|
14
|
+
} from "@checkstack/automation-common";
|
|
15
|
+
import type { RunStatus } from "@checkstack/automation-common";
|
|
16
|
+
import {
|
|
17
|
+
PageLayout,
|
|
18
|
+
Card,
|
|
19
|
+
CardContent,
|
|
20
|
+
CardHeader,
|
|
21
|
+
CardTitle,
|
|
22
|
+
Badge,
|
|
23
|
+
Button,
|
|
24
|
+
Table,
|
|
25
|
+
TableHeader,
|
|
26
|
+
TableRow,
|
|
27
|
+
TableHead,
|
|
28
|
+
TableBody,
|
|
29
|
+
TableCell,
|
|
30
|
+
LoadingSpinner,
|
|
31
|
+
QueryErrorState,
|
|
32
|
+
EmptyState,
|
|
33
|
+
} from "@checkstack/ui";
|
|
34
|
+
import { resolveRoute } from "@checkstack/common";
|
|
35
|
+
import { formatDistanceToNow } from "date-fns";
|
|
36
|
+
|
|
37
|
+
const RUN_STATUS_VARIANT: Record<
|
|
38
|
+
RunStatus,
|
|
39
|
+
"default" | "secondary" | "outline" | "destructive" | "success" | "warning"
|
|
40
|
+
> = {
|
|
41
|
+
pending: "outline",
|
|
42
|
+
running: "secondary",
|
|
43
|
+
waiting: "warning",
|
|
44
|
+
success: "success",
|
|
45
|
+
failed: "destructive",
|
|
46
|
+
cancelled: "outline",
|
|
47
|
+
skipped: "outline",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Run history for a single automation. Status filter pinned to the top;
|
|
52
|
+
* rows link to the run detail page. We also surface a `← Back to
|
|
53
|
+
* automation` link in the header — the most common navigation from this
|
|
54
|
+
* page is back to the parent edit page, not back to the list.
|
|
55
|
+
*/
|
|
56
|
+
const RunsPageContent: React.FC = () => {
|
|
57
|
+
const { automationId } = useParams<{ automationId: string }>();
|
|
58
|
+
const client = usePluginClient(AutomationApi);
|
|
59
|
+
const accessApi = useApi(accessApiRef);
|
|
60
|
+
const { allowed, loading: accessLoading } = accessApi.useAccess(
|
|
61
|
+
automationAccess.read,
|
|
62
|
+
);
|
|
63
|
+
const [statusFilter, setStatusFilter] = React.useState<RunStatus | "all">(
|
|
64
|
+
"all",
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const automationQuery = client.getAutomation.useQuery(
|
|
68
|
+
{ id: automationId ?? "" },
|
|
69
|
+
// Drop the cache entry as soon as this page unmounts: the editor seeds its
|
|
70
|
+
// form from this same `getAutomation` cache key once, and a lingering entry
|
|
71
|
+
// here would let it seed pre-edit (stale) data. See AutomationEditPage.
|
|
72
|
+
{ enabled: Boolean(automationId), gcTime: 0 },
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const runsQuery = client.listRuns.useQuery(
|
|
76
|
+
{
|
|
77
|
+
automationId: automationId ?? "",
|
|
78
|
+
limit: 50,
|
|
79
|
+
...(statusFilter === "all" ? {} : { status: statusFilter }),
|
|
80
|
+
},
|
|
81
|
+
{ enabled: Boolean(automationId) },
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const runs = runsQuery.data?.items ?? [];
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<PageLayout
|
|
88
|
+
title={
|
|
89
|
+
automationQuery.data
|
|
90
|
+
? `${automationQuery.data.name} — runs`
|
|
91
|
+
: "Run history"
|
|
92
|
+
}
|
|
93
|
+
subtitle="Past executions of this automation"
|
|
94
|
+
icon={History}
|
|
95
|
+
loading={accessLoading}
|
|
96
|
+
allowed={allowed}
|
|
97
|
+
actions={
|
|
98
|
+
automationId && (
|
|
99
|
+
<Link
|
|
100
|
+
to={resolveRoute(automationRoutes.routes.edit, { automationId })}
|
|
101
|
+
>
|
|
102
|
+
<Button variant="outline" size="sm">
|
|
103
|
+
<ChevronLeft className="mr-1 h-4 w-4" />
|
|
104
|
+
Back to automation
|
|
105
|
+
</Button>
|
|
106
|
+
</Link>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
>
|
|
110
|
+
<Card>
|
|
111
|
+
<CardHeader className="border-b">
|
|
112
|
+
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
113
|
+
<CardTitle className="text-base">Runs</CardTitle>
|
|
114
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
115
|
+
{(
|
|
116
|
+
[
|
|
117
|
+
"all",
|
|
118
|
+
"running",
|
|
119
|
+
"success",
|
|
120
|
+
"failed",
|
|
121
|
+
"cancelled",
|
|
122
|
+
"waiting",
|
|
123
|
+
] as const
|
|
124
|
+
).map((option) => (
|
|
125
|
+
<Button
|
|
126
|
+
key={option}
|
|
127
|
+
size="sm"
|
|
128
|
+
variant={statusFilter === option ? "primary" : "outline"}
|
|
129
|
+
onClick={() => setStatusFilter(option)}
|
|
130
|
+
className="capitalize"
|
|
131
|
+
>
|
|
132
|
+
{option}
|
|
133
|
+
</Button>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</CardHeader>
|
|
138
|
+
<CardContent className="p-0">
|
|
139
|
+
{runsQuery.isLoading ? (
|
|
140
|
+
<div className="p-6">
|
|
141
|
+
<LoadingSpinner />
|
|
142
|
+
</div>
|
|
143
|
+
) : runsQuery.isError ? (
|
|
144
|
+
<QueryErrorState
|
|
145
|
+
error={runsQuery.error}
|
|
146
|
+
onRetry={() => runsQuery.refetch()}
|
|
147
|
+
/>
|
|
148
|
+
) : runs.length === 0 ? (
|
|
149
|
+
<EmptyState
|
|
150
|
+
icon={<History className="h-8 w-8 text-muted-foreground" />}
|
|
151
|
+
title="No runs match this filter"
|
|
152
|
+
description="Manually trigger the automation from the edit page to generate a run."
|
|
153
|
+
/>
|
|
154
|
+
) : (
|
|
155
|
+
<Table>
|
|
156
|
+
<TableHeader>
|
|
157
|
+
<TableRow>
|
|
158
|
+
<TableHead>Status</TableHead>
|
|
159
|
+
<TableHead>Trigger</TableHead>
|
|
160
|
+
<TableHead>Started</TableHead>
|
|
161
|
+
<TableHead>Duration</TableHead>
|
|
162
|
+
<TableHead className="w-24 text-right" />
|
|
163
|
+
</TableRow>
|
|
164
|
+
</TableHeader>
|
|
165
|
+
<TableBody>
|
|
166
|
+
{runs.map((run) => (
|
|
167
|
+
<TableRow key={run.id}>
|
|
168
|
+
<TableCell>
|
|
169
|
+
<Badge
|
|
170
|
+
variant={RUN_STATUS_VARIANT[run.status] ?? "outline"}
|
|
171
|
+
className="capitalize"
|
|
172
|
+
>
|
|
173
|
+
{run.status}
|
|
174
|
+
</Badge>
|
|
175
|
+
</TableCell>
|
|
176
|
+
<TableCell>
|
|
177
|
+
<code className="font-mono text-xs">
|
|
178
|
+
{run.triggerEventId || "manual"}
|
|
179
|
+
</code>
|
|
180
|
+
</TableCell>
|
|
181
|
+
<TableCell>
|
|
182
|
+
<span className="text-xs text-muted-foreground">
|
|
183
|
+
{formatDistanceToNow(new Date(run.startedAt), {
|
|
184
|
+
addSuffix: true,
|
|
185
|
+
})}
|
|
186
|
+
</span>
|
|
187
|
+
</TableCell>
|
|
188
|
+
<TableCell>
|
|
189
|
+
<span className="text-xs text-muted-foreground">
|
|
190
|
+
{formatDuration(run.startedAt, run.finishedAt)}
|
|
191
|
+
</span>
|
|
192
|
+
</TableCell>
|
|
193
|
+
<TableCell className="text-right">
|
|
194
|
+
{automationId && (
|
|
195
|
+
<Link
|
|
196
|
+
to={resolveRoute(
|
|
197
|
+
automationRoutes.routes.runDetail,
|
|
198
|
+
{
|
|
199
|
+
automationId,
|
|
200
|
+
runId: run.id,
|
|
201
|
+
},
|
|
202
|
+
)}
|
|
203
|
+
>
|
|
204
|
+
<Button variant="ghost" size="sm">
|
|
205
|
+
Open
|
|
206
|
+
</Button>
|
|
207
|
+
</Link>
|
|
208
|
+
)}
|
|
209
|
+
</TableCell>
|
|
210
|
+
</TableRow>
|
|
211
|
+
))}
|
|
212
|
+
</TableBody>
|
|
213
|
+
</Table>
|
|
214
|
+
)}
|
|
215
|
+
</CardContent>
|
|
216
|
+
</Card>
|
|
217
|
+
</PageLayout>
|
|
218
|
+
);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
function formatDuration(
|
|
222
|
+
startedAt: Date | string,
|
|
223
|
+
finishedAt: Date | string | null | undefined,
|
|
224
|
+
): string {
|
|
225
|
+
if (!finishedAt) return "—";
|
|
226
|
+
const ms =
|
|
227
|
+
new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
228
|
+
if (ms < 1000) return `${ms}ms`;
|
|
229
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
230
|
+
return `${Math.round(ms / 60_000)}m`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export const RunsPage = wrapInSuspense(RunsPageContent);
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { FlaskConical } from "lucide-react";
|
|
3
|
+
import {
|
|
4
|
+
usePluginClient,
|
|
5
|
+
accessApiRef,
|
|
6
|
+
useApi,
|
|
7
|
+
wrapInSuspense,
|
|
8
|
+
} from "@checkstack/frontend-api";
|
|
9
|
+
import {
|
|
10
|
+
AutomationApi,
|
|
11
|
+
automationAccess,
|
|
12
|
+
} from "@checkstack/automation-common";
|
|
13
|
+
import {
|
|
14
|
+
Card,
|
|
15
|
+
CardHeader,
|
|
16
|
+
CardTitle,
|
|
17
|
+
CardContent,
|
|
18
|
+
Button,
|
|
19
|
+
Select,
|
|
20
|
+
SelectContent,
|
|
21
|
+
SelectItem,
|
|
22
|
+
SelectTrigger,
|
|
23
|
+
SelectValue,
|
|
24
|
+
CodeEditor,
|
|
25
|
+
PageLayout,
|
|
26
|
+
Alert,
|
|
27
|
+
AlertTitle,
|
|
28
|
+
AlertDescription,
|
|
29
|
+
Label,
|
|
30
|
+
} from "@checkstack/ui";
|
|
31
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
32
|
+
|
|
33
|
+
const DEFAULT_TEMPLATE = `Hello {{ trigger.payload.title }}!
|
|
34
|
+
|
|
35
|
+
The incident severity is {{ trigger.payload.severity | upper }}.`;
|
|
36
|
+
|
|
37
|
+
const DEFAULT_CONTEXT = `{
|
|
38
|
+
"trigger": {
|
|
39
|
+
"event": "incident.created",
|
|
40
|
+
"payload": {
|
|
41
|
+
"incidentId": "INC-42",
|
|
42
|
+
"title": "API latency spike",
|
|
43
|
+
"severity": "high"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}`;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Live template-engine playground. Two editors on top — template body
|
|
50
|
+
* (with `{{ }}` syntax) and sample JSON context — and a button that
|
|
51
|
+
* sends both to the `renderTemplate` RPC; the result drops into the
|
|
52
|
+
* output card at the bottom.
|
|
53
|
+
*
|
|
54
|
+
* Switching the mode between `template` and `condition` swaps which
|
|
55
|
+
* field on the response is rendered (`output` for templates,
|
|
56
|
+
* `booleanResult` for conditions). Parse errors come back with a
|
|
57
|
+
* `line` / `column` so the alert can point the operator at the right
|
|
58
|
+
* spot — Monaco doesn't get inline markers yet (that's a Phase 12.x
|
|
59
|
+
* polish), but the message text is precise.
|
|
60
|
+
*/
|
|
61
|
+
const TemplatePlaygroundContent: React.FC = () => {
|
|
62
|
+
const client = usePluginClient(AutomationApi);
|
|
63
|
+
const accessApi = useApi(accessApiRef);
|
|
64
|
+
const { allowed, loading: accessLoading } = accessApi.useAccess(
|
|
65
|
+
automationAccess.read,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const [template, setTemplate] = React.useState(DEFAULT_TEMPLATE);
|
|
69
|
+
const [contextText, setContextText] = React.useState(DEFAULT_CONTEXT);
|
|
70
|
+
const [mode, setMode] = React.useState<"template" | "condition">("template");
|
|
71
|
+
const [result, setResult] = React.useState<
|
|
72
|
+
| { kind: "ok"; output?: string; booleanResult?: boolean }
|
|
73
|
+
| { kind: "err"; message: string; line?: number; column?: number }
|
|
74
|
+
| null
|
|
75
|
+
>(null);
|
|
76
|
+
|
|
77
|
+
const renderMutation = client.renderTemplate.useMutation({
|
|
78
|
+
onSuccess: (data) => {
|
|
79
|
+
if (data.success) {
|
|
80
|
+
setResult({
|
|
81
|
+
kind: "ok",
|
|
82
|
+
output: data.output,
|
|
83
|
+
booleanResult: data.booleanResult,
|
|
84
|
+
});
|
|
85
|
+
} else if (data.error) {
|
|
86
|
+
setResult({
|
|
87
|
+
kind: "err",
|
|
88
|
+
message: data.error.message,
|
|
89
|
+
line: data.error.line,
|
|
90
|
+
column: data.error.column,
|
|
91
|
+
});
|
|
92
|
+
} else {
|
|
93
|
+
setResult({ kind: "err", message: "Unknown rendering error" });
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
onError: (error) => {
|
|
97
|
+
setResult({ kind: "err", message: extractErrorMessage(error) });
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const handleRender = () => {
|
|
102
|
+
let parsedContext: Record<string, unknown> = {};
|
|
103
|
+
try {
|
|
104
|
+
parsedContext = JSON.parse(contextText) as Record<string, unknown>;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
setResult({
|
|
107
|
+
kind: "err",
|
|
108
|
+
message: `Sample context is not valid JSON: ${extractErrorMessage(error)}`,
|
|
109
|
+
});
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
renderMutation.mutate({
|
|
113
|
+
template,
|
|
114
|
+
context: parsedContext,
|
|
115
|
+
mode,
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<PageLayout
|
|
121
|
+
title="Template Playground"
|
|
122
|
+
subtitle="Test templates and conditions against a sample trigger payload"
|
|
123
|
+
icon={FlaskConical}
|
|
124
|
+
loading={accessLoading}
|
|
125
|
+
allowed={allowed}
|
|
126
|
+
>
|
|
127
|
+
<div className="grid gap-4 lg:grid-cols-2">
|
|
128
|
+
<Card>
|
|
129
|
+
<CardHeader className="border-b">
|
|
130
|
+
<CardTitle className="text-base flex items-center gap-2">
|
|
131
|
+
Template
|
|
132
|
+
<Select
|
|
133
|
+
value={mode}
|
|
134
|
+
onValueChange={(value) =>
|
|
135
|
+
setMode(value as "template" | "condition")
|
|
136
|
+
}
|
|
137
|
+
>
|
|
138
|
+
<SelectTrigger className="ml-auto h-7 w-36 text-xs">
|
|
139
|
+
<SelectValue />
|
|
140
|
+
</SelectTrigger>
|
|
141
|
+
<SelectContent>
|
|
142
|
+
<SelectItem value="template">Template</SelectItem>
|
|
143
|
+
<SelectItem value="condition">Condition</SelectItem>
|
|
144
|
+
</SelectContent>
|
|
145
|
+
</Select>
|
|
146
|
+
</CardTitle>
|
|
147
|
+
</CardHeader>
|
|
148
|
+
<CardContent className="p-0">
|
|
149
|
+
<CodeEditor
|
|
150
|
+
value={template}
|
|
151
|
+
onChange={setTemplate}
|
|
152
|
+
language="markdown"
|
|
153
|
+
minHeight="320px"
|
|
154
|
+
/>
|
|
155
|
+
</CardContent>
|
|
156
|
+
</Card>
|
|
157
|
+
<Card>
|
|
158
|
+
<CardHeader className="border-b">
|
|
159
|
+
<CardTitle className="text-base">Sample context (JSON)</CardTitle>
|
|
160
|
+
</CardHeader>
|
|
161
|
+
<CardContent className="p-0">
|
|
162
|
+
<CodeEditor
|
|
163
|
+
value={contextText}
|
|
164
|
+
onChange={setContextText}
|
|
165
|
+
language="json"
|
|
166
|
+
minHeight="320px"
|
|
167
|
+
/>
|
|
168
|
+
</CardContent>
|
|
169
|
+
</Card>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div className="mt-4 flex items-center gap-2">
|
|
173
|
+
<Button
|
|
174
|
+
type="button"
|
|
175
|
+
onClick={handleRender}
|
|
176
|
+
disabled={renderMutation.isPending}
|
|
177
|
+
>
|
|
178
|
+
{renderMutation.isPending ? "Rendering…" : "Render"}
|
|
179
|
+
</Button>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<Card className="mt-4">
|
|
183
|
+
<CardHeader className="border-b">
|
|
184
|
+
<CardTitle className="text-base">Result</CardTitle>
|
|
185
|
+
</CardHeader>
|
|
186
|
+
<CardContent>
|
|
187
|
+
{!result && (
|
|
188
|
+
<p className="text-sm text-muted-foreground italic">
|
|
189
|
+
Click Render to evaluate.
|
|
190
|
+
</p>
|
|
191
|
+
)}
|
|
192
|
+
{result?.kind === "ok" && mode === "template" && (
|
|
193
|
+
<pre className="whitespace-pre-wrap font-mono text-sm">
|
|
194
|
+
{result.output ?? ""}
|
|
195
|
+
</pre>
|
|
196
|
+
)}
|
|
197
|
+
{result?.kind === "ok" && mode === "condition" && (
|
|
198
|
+
<Label className="text-sm">
|
|
199
|
+
Result:{" "}
|
|
200
|
+
<span className="font-mono">
|
|
201
|
+
{result.booleanResult === true ? "true" : "false"}
|
|
202
|
+
</span>
|
|
203
|
+
</Label>
|
|
204
|
+
)}
|
|
205
|
+
{result?.kind === "err" && (
|
|
206
|
+
<Alert variant="error">
|
|
207
|
+
<AlertTitle>Render failed</AlertTitle>
|
|
208
|
+
<AlertDescription>
|
|
209
|
+
{result.message}
|
|
210
|
+
{result.line !== undefined && (
|
|
211
|
+
<span className="ml-2 text-xs font-mono opacity-70">
|
|
212
|
+
(line {result.line}, column {result.column})
|
|
213
|
+
</span>
|
|
214
|
+
)}
|
|
215
|
+
</AlertDescription>
|
|
216
|
+
</Alert>
|
|
217
|
+
)}
|
|
218
|
+
</CardContent>
|
|
219
|
+
</Card>
|
|
220
|
+
</PageLayout>
|
|
221
|
+
);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export const TemplatePlaygroundPage = wrapInSuspense(TemplatePlaygroundContent);
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the automation-context type-declaration generator.
|
|
3
|
+
*
|
|
4
|
+
* Each scenario builds a tiny definition + registry fixture, runs
|
|
5
|
+
* `generateAutomationContextTypes`, and asserts on key fragments of the
|
|
6
|
+
* emitted TS source. Full source-equivalence is brittle (whitespace +
|
|
7
|
+
* field ordering shift with stylistic changes), so the assertions
|
|
8
|
+
* target the load-bearing semantic invariants.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from "bun:test";
|
|
11
|
+
import type {
|
|
12
|
+
ArtifactTypeInfo,
|
|
13
|
+
AutomationDefinition,
|
|
14
|
+
TriggerInfo,
|
|
15
|
+
} from "@checkstack/automation-common";
|
|
16
|
+
import { generateAutomationContextTypes } from "./script-context";
|
|
17
|
+
|
|
18
|
+
const incidentCreated: TriggerInfo = {
|
|
19
|
+
qualifiedId: "incident.created",
|
|
20
|
+
displayName: "Incident Created",
|
|
21
|
+
category: "Incidents",
|
|
22
|
+
ownerPluginId: "incident",
|
|
23
|
+
payloadSchema: {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
incidentId: { type: "string" },
|
|
27
|
+
title: { type: "string" },
|
|
28
|
+
},
|
|
29
|
+
required: ["incidentId", "title"],
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const incidentResolved: TriggerInfo = {
|
|
34
|
+
qualifiedId: "incident.resolved",
|
|
35
|
+
displayName: "Incident Resolved",
|
|
36
|
+
category: "Incidents",
|
|
37
|
+
ownerPluginId: "incident",
|
|
38
|
+
payloadSchema: {
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: {
|
|
41
|
+
incidentId: { type: "string" },
|
|
42
|
+
resolvedAt: { type: "string" },
|
|
43
|
+
},
|
|
44
|
+
required: ["incidentId"],
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const jiraIssue: ArtifactTypeInfo = {
|
|
49
|
+
// Qualified id as the registry emits it: `${ownerPluginId}.${localId}`.
|
|
50
|
+
qualifiedId: "integration-jira.issue",
|
|
51
|
+
displayName: "Jira Issue",
|
|
52
|
+
ownerPluginId: "integration-jira",
|
|
53
|
+
schema: {
|
|
54
|
+
type: "object",
|
|
55
|
+
properties: {
|
|
56
|
+
key: { type: "string" },
|
|
57
|
+
url: { type: "string" },
|
|
58
|
+
},
|
|
59
|
+
required: ["key"],
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function baseDef(
|
|
64
|
+
overrides: Partial<AutomationDefinition> = {},
|
|
65
|
+
): AutomationDefinition {
|
|
66
|
+
return {
|
|
67
|
+
name: "Test",
|
|
68
|
+
triggers: [{ event: "incident.created" }],
|
|
69
|
+
conditions: [],
|
|
70
|
+
actions: [],
|
|
71
|
+
mode: "single",
|
|
72
|
+
max_runs: 1,
|
|
73
|
+
...overrides,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("generateAutomationContextTypes", () => {
|
|
78
|
+
it("emits a single-variant trigger union for one subscribed trigger", () => {
|
|
79
|
+
const { typeDefinitions } = generateAutomationContextTypes({
|
|
80
|
+
definition: baseDef(),
|
|
81
|
+
triggers: [incidentCreated, incidentResolved],
|
|
82
|
+
artifactTypes: [],
|
|
83
|
+
path: [{ slot: "root", index: 0 }],
|
|
84
|
+
});
|
|
85
|
+
expect(typeDefinitions).toContain('readonly event: "incident.created"');
|
|
86
|
+
// The other registered trigger must NOT appear in the union — it
|
|
87
|
+
// isn't subscribed by this automation.
|
|
88
|
+
expect(typeDefinitions).not.toContain('readonly event: "incident.resolved"');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("carries trigger.id (derived) and a shared actor on the trigger union", () => {
|
|
92
|
+
const { typeDefinitions } = generateAutomationContextTypes({
|
|
93
|
+
definition: baseDef(),
|
|
94
|
+
triggers: [incidentCreated, incidentResolved],
|
|
95
|
+
artifactTypes: [],
|
|
96
|
+
path: [{ slot: "root", index: 0 }],
|
|
97
|
+
});
|
|
98
|
+
// id is a literal, derived from the event when no explicit id is set.
|
|
99
|
+
expect(typeDefinitions).toContain('readonly id: "incident_created"');
|
|
100
|
+
// actor is shared across every variant (intersected onto the union).
|
|
101
|
+
expect(typeDefinitions).toContain("type AutomationActor =");
|
|
102
|
+
expect(typeDefinitions).toContain("readonly actor: AutomationActor");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("uses explicit trigger ids to discriminate two triggers on the same event", () => {
|
|
106
|
+
const def = baseDef({
|
|
107
|
+
triggers: [
|
|
108
|
+
{ event: "incident.created", id: "majors" },
|
|
109
|
+
{ event: "incident.created", id: "minors" },
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
const { typeDefinitions } = generateAutomationContextTypes({
|
|
113
|
+
definition: def,
|
|
114
|
+
triggers: [incidentCreated],
|
|
115
|
+
artifactTypes: [],
|
|
116
|
+
path: [{ slot: "root", index: 0 }],
|
|
117
|
+
});
|
|
118
|
+
expect(typeDefinitions).toContain('readonly id: "majors"');
|
|
119
|
+
expect(typeDefinitions).toContain('readonly id: "minors"');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("emits a multi-variant discriminated union when multiple triggers are subscribed", () => {
|
|
123
|
+
const def = baseDef({
|
|
124
|
+
triggers: [{ event: "incident.created" }, { event: "incident.resolved" }],
|
|
125
|
+
});
|
|
126
|
+
const { typeDefinitions } = generateAutomationContextTypes({
|
|
127
|
+
definition: def,
|
|
128
|
+
triggers: [incidentCreated, incidentResolved],
|
|
129
|
+
artifactTypes: [],
|
|
130
|
+
path: [{ slot: "root", index: 0 }],
|
|
131
|
+
});
|
|
132
|
+
expect(typeDefinitions).toContain('readonly event: "incident.created"');
|
|
133
|
+
expect(typeDefinitions).toContain('readonly event: "incident.resolved"');
|
|
134
|
+
// Each variant carries its own payload — `title` only appears in the
|
|
135
|
+
// incident.created arm; `resolvedAt` only in incident.resolved.
|
|
136
|
+
expect(typeDefinitions).toContain("title");
|
|
137
|
+
expect(typeDefinitions).toContain("resolvedAt");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("emits artifacts keyed by producing action id, nested by local name", () => {
|
|
141
|
+
const def = baseDef({
|
|
142
|
+
actions: [
|
|
143
|
+
{
|
|
144
|
+
id: "open_jira",
|
|
145
|
+
action: "integration-jira.create_issue",
|
|
146
|
+
config: {},
|
|
147
|
+
enabled: true,
|
|
148
|
+
continue_on_error: false,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
action: "automation.notify_user",
|
|
152
|
+
config: {},
|
|
153
|
+
enabled: true,
|
|
154
|
+
continue_on_error: false,
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
});
|
|
158
|
+
const { typeDefinitions } = generateAutomationContextTypes({
|
|
159
|
+
definition: def,
|
|
160
|
+
triggers: [incidentCreated],
|
|
161
|
+
artifactTypes: [jiraIssue],
|
|
162
|
+
actions: [
|
|
163
|
+
// `produces` from listActions is the QUALIFIED artifact type id
|
|
164
|
+
// (the registry qualifies it on registration), matching `jiraIssue`.
|
|
165
|
+
{
|
|
166
|
+
qualifiedId: "integration-jira.create_issue",
|
|
167
|
+
produces: "integration-jira.issue",
|
|
168
|
+
},
|
|
169
|
+
{ qualifiedId: "automation.notify_user" },
|
|
170
|
+
],
|
|
171
|
+
path: [{ slot: "root", index: 1 }],
|
|
172
|
+
});
|
|
173
|
+
// Keyed by the producing action's id, nested by local artifact name -
|
|
174
|
+
// mirrors the runtime `artifacts.open_jira.issue.key`.
|
|
175
|
+
expect(typeDefinitions).toContain('readonly "open_jira"');
|
|
176
|
+
expect(typeDefinitions).toContain('readonly "issue"');
|
|
177
|
+
expect(typeDefinitions).toContain("key");
|
|
178
|
+
expect(typeDefinitions).toContain("url");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("falls back to Record<string, unknown> for artifacts when no producing actions are upstream", () => {
|
|
182
|
+
const { typeDefinitions } = generateAutomationContextTypes({
|
|
183
|
+
definition: baseDef(),
|
|
184
|
+
triggers: [incidentCreated],
|
|
185
|
+
artifactTypes: [jiraIssue],
|
|
186
|
+
actions: [],
|
|
187
|
+
path: [{ slot: "root", index: 0 }],
|
|
188
|
+
});
|
|
189
|
+
expect(typeDefinitions).toContain(
|
|
190
|
+
"readonly artifacts: Record<string, unknown>",
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("emits a repeat context only when the path descends through a repeat", () => {
|
|
195
|
+
const def = baseDef({
|
|
196
|
+
actions: [
|
|
197
|
+
{
|
|
198
|
+
repeat: {
|
|
199
|
+
for_each: "{{ trigger.payload.items }}",
|
|
200
|
+
sequence: [
|
|
201
|
+
{
|
|
202
|
+
action: "automation.notify_user",
|
|
203
|
+
config: {},
|
|
204
|
+
enabled: true,
|
|
205
|
+
continue_on_error: false,
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
enabled: true,
|
|
210
|
+
continue_on_error: false,
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
});
|
|
214
|
+
const inside = generateAutomationContextTypes({
|
|
215
|
+
definition: def,
|
|
216
|
+
triggers: [incidentCreated],
|
|
217
|
+
artifactTypes: [],
|
|
218
|
+
actions: [{ qualifiedId: "automation.notify_user" }],
|
|
219
|
+
path: [
|
|
220
|
+
{ slot: "root", index: 0 },
|
|
221
|
+
{ slot: "repeat", index: 0 },
|
|
222
|
+
],
|
|
223
|
+
});
|
|
224
|
+
expect(inside.typeDefinitions).toContain("readonly repeat:");
|
|
225
|
+
expect(inside.typeDefinitions).toContain("readonly index: number");
|
|
226
|
+
expect(inside.typeDefinitions).toContain("readonly item: unknown");
|
|
227
|
+
|
|
228
|
+
const outside = generateAutomationContextTypes({
|
|
229
|
+
definition: def,
|
|
230
|
+
triggers: [incidentCreated],
|
|
231
|
+
artifactTypes: [],
|
|
232
|
+
actions: [{ qualifiedId: "automation.notify_user" }],
|
|
233
|
+
path: [{ slot: "root", index: 0 }],
|
|
234
|
+
});
|
|
235
|
+
expect(outside.typeDefinitions).not.toContain("readonly repeat:");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("returns the resolved scope alongside the type definitions for picker reuse", () => {
|
|
239
|
+
const { scope } = generateAutomationContextTypes({
|
|
240
|
+
definition: baseDef(),
|
|
241
|
+
triggers: [incidentCreated],
|
|
242
|
+
artifactTypes: [],
|
|
243
|
+
path: [{ slot: "root", index: 0 }],
|
|
244
|
+
});
|
|
245
|
+
expect(scope.entries.some((e) => e.path === "trigger")).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
});
|