@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +664 -0
  2. package/package.json +38 -0
  3. package/src/components/AutomationMenuItems.tsx +37 -0
  4. package/src/editor/ActionEditor.tsx +367 -0
  5. package/src/editor/ActionListEditor.tsx +203 -0
  6. package/src/editor/AddActionDialog.tsx +225 -0
  7. package/src/editor/AutomationDefinitionContext.tsx +37 -0
  8. package/src/editor/AutomationDefinitionEditor.tsx +99 -0
  9. package/src/editor/ConditionEditor.tsx +218 -0
  10. package/src/editor/ConditionsEditor.tsx +89 -0
  11. package/src/editor/ItemPicker.tsx +147 -0
  12. package/src/editor/TriggersEditor.tsx +269 -0
  13. package/src/editor/action-composite-cards.tsx +390 -0
  14. package/src/editor/action-helpers.ts +365 -0
  15. package/src/editor/action-leaf-cards.tsx +426 -0
  16. package/src/editor/editor-validation.test.ts +95 -0
  17. package/src/editor/editor-validation.tsx +200 -0
  18. package/src/editor/registry-context.tsx +192 -0
  19. package/src/editor/template-completion.test.ts +412 -0
  20. package/src/editor/template-completion.ts +664 -0
  21. package/src/editor/template-helpers.test.ts +145 -0
  22. package/src/editor/template-helpers.ts +95 -0
  23. package/src/editor/trigger-helpers.test.ts +58 -0
  24. package/src/editor/trigger-helpers.ts +67 -0
  25. package/src/editor/useConnectionOptionResolvers.ts +80 -0
  26. package/src/editor/yaml-markers.ts +116 -0
  27. package/src/index.tsx +95 -0
  28. package/src/pages/AutomationEditPage.tsx +567 -0
  29. package/src/pages/AutomationListPage.tsx +304 -0
  30. package/src/pages/RunDetailPage.tsx +333 -0
  31. package/src/pages/RunsPage.tsx +233 -0
  32. package/src/pages/TemplatePlaygroundPage.tsx +224 -0
  33. package/src/script-context.test.ts +247 -0
  34. package/src/script-context.ts +218 -0
  35. package/tsconfig.json +29 -0
@@ -0,0 +1,412 @@
1
+ /**
2
+ * Tests for the staged template-completion analyzer + provider.
3
+ *
4
+ * `analyzeExpression` is the brain — it classifies the cursor position
5
+ * into field / operator / value / filter. The provider tests then
6
+ * verify the end-to-end mapping (offsets, enum values, filters, brace
7
+ * closing) over a couple of representative fixtures.
8
+ */
9
+ import { describe, expect, it } from "bun:test";
10
+ import {
11
+ analyzeExpression,
12
+ createTemplateCompletionProvider,
13
+ type CompletionField,
14
+ type CompletionFilter,
15
+ } from "./template-completion";
16
+
17
+ /** Helper: a field whose templateRef mirrors its canonical path. */
18
+ function plainField(
19
+ path: string,
20
+ type: string,
21
+ enumValues?: Array<string | number | boolean>,
22
+ ): CompletionField {
23
+ return { path, templateRef: path, type, enumValues };
24
+ }
25
+
26
+ describe("analyzeExpression", () => {
27
+ it("classifies an empty / partial identifier as the field stage", () => {
28
+ expect(analyzeExpression("")).toMatchObject({ kind: "field", query: "" });
29
+ expect(analyzeExpression("trig")).toMatchObject({
30
+ kind: "field",
31
+ query: "trig",
32
+ tokenStart: 0,
33
+ });
34
+ expect(analyzeExpression("trigger.payload.sev")).toMatchObject({
35
+ kind: "field",
36
+ query: "trigger.payload.sev",
37
+ tokenStart: 0,
38
+ });
39
+ });
40
+
41
+ it("moves to the operator stage after a completed field + space", () => {
42
+ expect(analyzeExpression("trigger.payload.severity ")).toMatchObject({
43
+ kind: "operator",
44
+ });
45
+ });
46
+
47
+ it("moves to the value stage right after a comparator", () => {
48
+ const stage = analyzeExpression("trigger.payload.severity == ");
49
+ expect(stage).toMatchObject({
50
+ kind: "value",
51
+ fieldPath: "trigger.payload.severity",
52
+ query: "",
53
+ quoted: false,
54
+ });
55
+ });
56
+
57
+ it("stays in the value stage while typing a quoted value", () => {
58
+ const stage = analyzeExpression('trigger.payload.severity == "hi');
59
+ expect(stage).toMatchObject({
60
+ kind: "value",
61
+ fieldPath: "trigger.payload.severity",
62
+ query: "hi",
63
+ quoted: true,
64
+ });
65
+ });
66
+
67
+ it("treats a partial value with no quote as the value stage too", () => {
68
+ const stage = analyzeExpression("count == 4");
69
+ expect(stage).toMatchObject({
70
+ kind: "value",
71
+ fieldPath: "count",
72
+ query: "4",
73
+ });
74
+ });
75
+
76
+ it("enters the filter stage after a pipe", () => {
77
+ expect(analyzeExpression("trigger.payload.title | ")).toMatchObject({
78
+ kind: "filter",
79
+ query: "",
80
+ });
81
+ expect(analyzeExpression("trigger.payload.title | up")).toMatchObject({
82
+ kind: "filter",
83
+ query: "up",
84
+ });
85
+ });
86
+
87
+ it("offers operators again after a completed comparison + space", () => {
88
+ expect(
89
+ analyzeExpression('trigger.payload.severity == "high" '),
90
+ ).toMatchObject({ kind: "operator" });
91
+ });
92
+
93
+ it("returns to the field stage after a logical connector", () => {
94
+ const stage = analyzeExpression('a == "x" && trig');
95
+ expect(stage).toMatchObject({ kind: "field", query: "trig" });
96
+ });
97
+ });
98
+
99
+ // ─── Provider ───────────────────────────────────────────────────────────
100
+
101
+ const fields: CompletionField[] = [
102
+ plainField("trigger.payload.severity", "string", ["low", "high"]),
103
+ plainField("trigger.payload.title", "string"),
104
+ plainField("trigger.payload.acknowledged", "boolean"),
105
+ ];
106
+
107
+ /**
108
+ * Artifact with a hyphenated/dotted id: canonical `path` stays dotted,
109
+ * but `templateRef` is the runtime-parseable bracket form that must be
110
+ * inserted into `{{ }}` and used for the value-stage lookup.
111
+ */
112
+ const bracketFields: CompletionField[] = [
113
+ {
114
+ path: "artifact.integration-jira.issue.issueKey",
115
+ templateRef: 'artifacts["integration-jira.issue"].issueKey',
116
+ type: "string",
117
+ enumValues: ["OPEN", "DONE"],
118
+ },
119
+ {
120
+ path: "artifact.integration-jira.issue.url",
121
+ templateRef: 'artifacts["integration-jira.issue"].url',
122
+ type: "string",
123
+ },
124
+ ];
125
+
126
+ const filters: CompletionFilter[] = [
127
+ { name: "upper", description: "Uppercase." },
128
+ { name: "default", signature: "fallback", hasArgs: true },
129
+ ];
130
+
131
+ describe("createTemplateCompletionProvider — template mode", () => {
132
+ const provider = createTemplateCompletionProvider({
133
+ fields,
134
+ filters,
135
+ mode: "template",
136
+ });
137
+
138
+ it("returns null when the cursor is not inside a {{ }} block", () => {
139
+ expect(provider({ value: "plain text", cursor: 5 })).toBeNull();
140
+ });
141
+
142
+ it("suggests fields inside an unclosed {{, appends the closing braces, and a space to advance", () => {
143
+ const value = "{{trig";
144
+ const result = provider({ value, cursor: value.length });
145
+ expect(result).not.toBeNull();
146
+ expect(result!.heading).toBe("Fields");
147
+ const severity = result!.items.find((i) =>
148
+ i.label.includes("severity"),
149
+ );
150
+ // Inserts the field + a space + the closing braces; the caret lands
151
+ // after the space (before `}}`) so the operator stage opens next.
152
+ expect(severity?.insertText).toBe("trigger.payload.severity }}");
153
+ expect(severity?.caretOffset).toBe(-2);
154
+ });
155
+
156
+ it("appends a trailing space (no braces) when the block is already closed", () => {
157
+ const value = "{{ trig }}";
158
+ const cursor = "{{ trig".length;
159
+ const result = provider({ value, cursor });
160
+ const severity = result!.items.find((i) => i.label.includes("severity"));
161
+ expect(severity?.insertText).toBe("trigger.payload.severity ");
162
+ expect(severity?.caretOffset).toBe(0);
163
+ });
164
+
165
+ it("offers comparators + pipe in the operator stage", () => {
166
+ const value = "{{ trigger.payload.severity ";
167
+ const result = provider({ value, cursor: value.length });
168
+ expect(result!.heading).toBe("Operators");
169
+ const labels = result!.items.map((i) => i.label);
170
+ expect(labels).toContain("==");
171
+ expect(labels).toContain("!=");
172
+ expect(labels).toContain("|");
173
+ });
174
+
175
+ it("offers enum values after a comparator on an enum field", () => {
176
+ const value = '{{ trigger.payload.severity == ';
177
+ const result = provider({ value, cursor: value.length });
178
+ expect(result!.heading).toContain("severity");
179
+ const inserts = result!.items.map((i) => i.insertText);
180
+ expect(inserts).toContain('"low"');
181
+ expect(inserts).toContain('"high"');
182
+ });
183
+
184
+ it("offers true/false for a boolean field value stage", () => {
185
+ const value = "{{ trigger.payload.acknowledged == ";
186
+ const result = provider({ value, cursor: value.length });
187
+ const inserts = result!.items.map((i) => i.insertText);
188
+ expect(inserts).toEqual(["true", "false"]);
189
+ });
190
+
191
+ it("returns null in the value stage for a field with no known values", () => {
192
+ const value = "{{ trigger.payload.title == ";
193
+ const result = provider({ value, cursor: value.length });
194
+ expect(result).toBeNull();
195
+ });
196
+
197
+ it("offers filters after a pipe, with () for arg-taking filters", () => {
198
+ const value = "{{ trigger.payload.title | ";
199
+ const result = provider({ value, cursor: value.length });
200
+ expect(result!.heading).toBe("Filters");
201
+ const def = result!.items.find((i) => i.label.startsWith("default"));
202
+ expect(def?.insertText).toBe("default()");
203
+ expect(def?.caretOffset).toBe(-1);
204
+ const upper = result!.items.find((i) => i.label === "upper");
205
+ expect(upper?.insertText).toBe("upper");
206
+ });
207
+ });
208
+
209
+ describe("createTemplateCompletionProvider — expression mode", () => {
210
+ const provider = createTemplateCompletionProvider({
211
+ fields,
212
+ filters,
213
+ mode: "expression",
214
+ });
215
+
216
+ it("treats the whole value as an expression (no {{ needed)", () => {
217
+ const result = provider({ value: "trigger.payload.sev", cursor: 19 });
218
+ expect(result!.heading).toBe("Fields");
219
+ // Field insert in expression mode never appends braces, but does
220
+ // append a trailing space to advance to the operator stage.
221
+ const severity = result!.items.find((i) => i.label.includes("severity"));
222
+ expect(severity?.insertText).toBe("trigger.payload.severity ");
223
+ });
224
+
225
+ it("suggests enum values after a comparator", () => {
226
+ const value = "trigger.payload.severity == ";
227
+ const result = provider({ value, cursor: value.length });
228
+ const inserts = result!.items.map((i) => i.insertText);
229
+ expect(inserts).toEqual(['"low"', '"high"']);
230
+ });
231
+
232
+ it("replaces only the partial token, not the whole expression", () => {
233
+ const value = "trigger.payload.sev";
234
+ const result = provider({ value, cursor: value.length });
235
+ expect(result!.replaceStart).toBe(0);
236
+ expect(result!.replaceEnd).toBe(value.length);
237
+ });
238
+ });
239
+
240
+ // ─── templateRef (bracket-notation artifact) handling ─────────────────────
241
+
242
+ describe("templateRef insertion + value-stage lookup", () => {
243
+ const provider = createTemplateCompletionProvider({
244
+ fields: bracketFields,
245
+ filters,
246
+ mode: "template",
247
+ });
248
+
249
+ it("field stage: completing `{{ art` inserts the bracket-notation templateRef", () => {
250
+ const value = "{{ art";
251
+ const result = provider({ value, cursor: value.length });
252
+ expect(result).not.toBeNull();
253
+ expect(result!.heading).toBe("Fields");
254
+ const issueKey = result!.items.find((i) =>
255
+ i.label.includes("issueKey"),
256
+ );
257
+ expect(issueKey?.label).toBe('artifacts["integration-jira.issue"].issueKey');
258
+ expect(issueKey?.insertText).toBe(
259
+ 'artifacts["integration-jira.issue"].issueKey }}',
260
+ );
261
+ expect(issueKey?.caretOffset).toBe(-2);
262
+ });
263
+
264
+ it("field stage: a partial bracket chain still filters by templateRef", () => {
265
+ const value = '{{ artifacts["integration-jira.issue"].iss';
266
+ const result = provider({ value, cursor: value.length });
267
+ expect(result).not.toBeNull();
268
+ const labels = result!.items.map((i) => i.label);
269
+ expect(labels).toContain(
270
+ 'artifacts["integration-jira.issue"].issueKey',
271
+ );
272
+ expect(labels).not.toContain(
273
+ 'artifacts["integration-jira.issue"].url',
274
+ );
275
+ });
276
+
277
+ it("value stage: reconstructed fieldPath equals the templateRef and finds enum values", () => {
278
+ const value = '{{ artifacts["integration-jira.issue"].issueKey == ';
279
+ const result = provider({ value, cursor: value.length });
280
+ expect(result).not.toBeNull();
281
+ expect(result!.heading).toContain(
282
+ 'artifacts["integration-jira.issue"].issueKey',
283
+ );
284
+ const inserts = result!.items.map((i) => i.insertText);
285
+ expect(inserts).toContain('"OPEN"');
286
+ expect(inserts).toContain('"DONE"');
287
+ });
288
+
289
+ it("value stage: single-quoted user input still reconstructs the double-quoted templateRef", () => {
290
+ // The user typed single quotes inside the bracket; the reconstruction
291
+ // must normalise to the JSON.stringify (double-quoted) templateRef so
292
+ // the lookup matches.
293
+ const value = "{{ artifacts['integration-jira.issue'].issueKey == ";
294
+ const result = provider({ value, cursor: value.length });
295
+ expect(result).not.toBeNull();
296
+ const inserts = result!.items.map((i) => i.insertText);
297
+ expect(inserts).toContain('"OPEN"');
298
+ });
299
+ });
300
+
301
+ // ─── Array element indexing ───────────────────────────────────────────────
302
+
303
+ describe("array element indexing", () => {
304
+ const arrayFields: CompletionField[] = [
305
+ {
306
+ path: "artifact.integration-jira.issue.comments[0].author",
307
+ templateRef: 'artifacts["integration-jira.issue"].comments[0].author',
308
+ type: "string",
309
+ },
310
+ {
311
+ path: "artifact.integration-jira.issue.tags[0]",
312
+ templateRef: 'artifacts["integration-jira.issue"].tags[0]',
313
+ type: "string",
314
+ },
315
+ ];
316
+ const provider = createTemplateCompletionProvider({
317
+ fields: arrayFields,
318
+ filters,
319
+ mode: "template",
320
+ });
321
+
322
+ it("field stage: a partial chain through a numeric index filters by templateRef", () => {
323
+ const value = '{{ artifacts["integration-jira.issue"].comments[0].au';
324
+ const result = provider({ value, cursor: value.length });
325
+ expect(result).not.toBeNull();
326
+ const labels = result!.items.map((i) => i.label);
327
+ expect(labels).toContain(
328
+ 'artifacts["integration-jira.issue"].comments[0].author',
329
+ );
330
+ expect(labels).not.toContain('artifacts["integration-jira.issue"].tags[0]');
331
+ });
332
+
333
+ it("value stage: reconstructs a fieldPath with a numeric index (bare, no quotes)", () => {
334
+ const value =
335
+ '{{ artifacts["integration-jira.issue"].comments[0].author == ';
336
+ const stage = analyzeExpression(
337
+ value.slice("{{ ".length, value.length),
338
+ );
339
+ expect(stage).toMatchObject({
340
+ kind: "value",
341
+ fieldPath: 'artifacts["integration-jira.issue"].comments[0].author',
342
+ });
343
+ });
344
+
345
+ it("reconstructChain inserts no dot before a `[` (it's tags[0], not tags.[0])", () => {
346
+ const stage = analyzeExpression('artifacts["integration-jira.issue"].tags[0] ');
347
+ // Trailing space → operator stage; the chain itself round-trips without
348
+ // a spurious dot. Verify via the value stage instead:
349
+ const valueStage = analyzeExpression(
350
+ 'artifacts["integration-jira.issue"].tags[0] == ',
351
+ );
352
+ expect(valueStage).toMatchObject({
353
+ kind: "value",
354
+ fieldPath: 'artifacts["integration-jira.issue"].tags[0]',
355
+ });
356
+ expect(stage).toMatchObject({ kind: "operator" });
357
+ });
358
+ });
359
+
360
+ // ─── Finding 1: double-escape regression ──────────────────────────────────
361
+
362
+ describe("bracket key with embedded quotes does not double-escape", () => {
363
+ // Stored templateRef uses JSON.stringify, so a key `a"b` is stored as
364
+ // `["a\"b"]`. Reconstruction must round-trip to the same string, not
365
+ // re-escape the backslash.
366
+ const escapedFields: CompletionField[] = [
367
+ {
368
+ path: 'artifact.weird.a"b',
369
+ templateRef: 'artifacts["a\\"b"]',
370
+ type: "string",
371
+ enumValues: ["X"],
372
+ },
373
+ ];
374
+ const provider = createTemplateCompletionProvider({
375
+ fields: escapedFields,
376
+ filters,
377
+ mode: "template",
378
+ });
379
+
380
+ it("value stage: an embedded-quote key reconstructs to the stored double-quoted templateRef", () => {
381
+ // User typed the same double-quoted escaped form.
382
+ const value = '{{ artifacts["a\\"b"] == ';
383
+ const result = provider({ value, cursor: value.length });
384
+ expect(result).not.toBeNull();
385
+ expect(result!.heading).toContain('artifacts["a\\"b"]');
386
+ const inserts = result!.items.map((i) => i.insertText);
387
+ expect(inserts).toContain('"X"');
388
+ });
389
+ });
390
+
391
+ describe("plain dotted paths still round-trip", () => {
392
+ const provider = createTemplateCompletionProvider({
393
+ fields,
394
+ filters,
395
+ mode: "template",
396
+ });
397
+
398
+ it("field stage filters a plain dotted partial", () => {
399
+ const value = "{{ trigger.payload.sev";
400
+ const result = provider({ value, cursor: value.length });
401
+ const labels = result!.items.map((i) => i.label);
402
+ expect(labels).toContain("trigger.payload.severity");
403
+ });
404
+
405
+ it("value stage after a plain dotted field finds its enum values", () => {
406
+ const value = "{{ trigger.payload.severity == ";
407
+ const result = provider({ value, cursor: value.length });
408
+ expect(result!.heading).toContain("trigger.payload.severity");
409
+ const inserts = result!.items.map((i) => i.insertText);
410
+ expect(inserts).toEqual(['"low"', '"high"']);
411
+ });
412
+ });