@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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { Plus, Trash2 } from "lucide-react";
|
|
3
2
|
import {
|
|
4
|
-
|
|
3
|
+
ActionCard,
|
|
4
|
+
type ActionCardMenuItem,
|
|
5
5
|
Card,
|
|
6
6
|
CardContent,
|
|
7
7
|
CardHeader,
|
|
@@ -10,7 +10,12 @@ import {
|
|
|
10
10
|
type VariableNode,
|
|
11
11
|
} from "@checkstack/ui";
|
|
12
12
|
import type { ConditionInput } from "@checkstack/automation-common";
|
|
13
|
+
import { AddConditionDialog } from "./AddConditionDialog";
|
|
13
14
|
import { ConditionEditor } from "./ConditionEditor";
|
|
15
|
+
import { defaultForKind } from "./condition-kind";
|
|
16
|
+
import { ItemSheet } from "./ItemSheet";
|
|
17
|
+
import { useConditionIssues } from "./editor-validation";
|
|
18
|
+
import { summarizeCondition } from "./item-summary";
|
|
14
19
|
|
|
15
20
|
/**
|
|
16
21
|
* Top-level pre-run conditions. Every condition in this list must pass
|
|
@@ -35,55 +40,119 @@ export const ConditionsEditor: React.FC<{
|
|
|
35
40
|
}> = ({ value, onChange, variableNodes, completionProvider, disabled }) => (
|
|
36
41
|
<Card>
|
|
37
42
|
<CardHeader className="border-b">
|
|
38
|
-
<
|
|
39
|
-
<CardTitle className="text-base">Conditions</CardTitle>
|
|
40
|
-
<Button
|
|
41
|
-
type="button"
|
|
42
|
-
variant="outline"
|
|
43
|
-
size="sm"
|
|
44
|
-
onClick={() => onChange([...value, ""])}
|
|
45
|
-
disabled={disabled}
|
|
46
|
-
>
|
|
47
|
-
<Plus className="mr-1 h-3 w-3" />
|
|
48
|
-
Add condition
|
|
49
|
-
</Button>
|
|
50
|
-
</div>
|
|
43
|
+
<CardTitle className="text-base">Conditions</CardTitle>
|
|
51
44
|
</CardHeader>
|
|
52
45
|
<CardContent className="space-y-2 p-3">
|
|
53
|
-
{value.length === 0
|
|
46
|
+
{value.length === 0 && (
|
|
54
47
|
<p className="text-xs italic text-muted-foreground">
|
|
55
48
|
No pre-run gating. Add a condition to require it before the
|
|
56
49
|
actions run.
|
|
57
50
|
</p>
|
|
58
|
-
) : (
|
|
59
|
-
value.map((condition, index) => (
|
|
60
|
-
<div key={index} className="flex items-start gap-2">
|
|
61
|
-
<div className="flex-1">
|
|
62
|
-
<ConditionEditor
|
|
63
|
-
value={condition}
|
|
64
|
-
onChange={(next) => {
|
|
65
|
-
const list = [...value];
|
|
66
|
-
list[index] = next;
|
|
67
|
-
onChange(list);
|
|
68
|
-
}}
|
|
69
|
-
variableNodes={variableNodes}
|
|
70
|
-
completionProvider={completionProvider}
|
|
71
|
-
/>
|
|
72
|
-
</div>
|
|
73
|
-
<Button
|
|
74
|
-
type="button"
|
|
75
|
-
variant="ghost"
|
|
76
|
-
size="icon"
|
|
77
|
-
className="h-7 w-7 text-destructive hover:bg-destructive/10"
|
|
78
|
-
onClick={() => onChange(value.filter((_, i) => i !== index))}
|
|
79
|
-
disabled={disabled}
|
|
80
|
-
aria-label="Remove condition"
|
|
81
|
-
>
|
|
82
|
-
<Trash2 className="h-3 w-3" />
|
|
83
|
-
</Button>
|
|
84
|
-
</div>
|
|
85
|
-
))
|
|
86
51
|
)}
|
|
52
|
+
{value.map((condition, index) => (
|
|
53
|
+
<ConditionCard
|
|
54
|
+
key={index}
|
|
55
|
+
index={index}
|
|
56
|
+
value={condition}
|
|
57
|
+
onChange={(next) => {
|
|
58
|
+
const list = [...value];
|
|
59
|
+
list[index] = next;
|
|
60
|
+
onChange(list);
|
|
61
|
+
}}
|
|
62
|
+
onRemove={() => onChange(value.filter((_, i) => i !== index))}
|
|
63
|
+
onDuplicate={() =>
|
|
64
|
+
// Conditions carry no id, so a structural clone (inserted
|
|
65
|
+
// directly after the original) is all that's needed.
|
|
66
|
+
onChange([
|
|
67
|
+
...value.slice(0, index + 1),
|
|
68
|
+
structuredClone(condition),
|
|
69
|
+
...value.slice(index + 1),
|
|
70
|
+
])
|
|
71
|
+
}
|
|
72
|
+
variableNodes={variableNodes}
|
|
73
|
+
completionProvider={completionProvider}
|
|
74
|
+
disabled={disabled}
|
|
75
|
+
/>
|
|
76
|
+
))}
|
|
77
|
+
<AddConditionDialog
|
|
78
|
+
disabled={disabled}
|
|
79
|
+
onAdd={(kind) => onChange([...value, defaultForKind(kind)])}
|
|
80
|
+
/>
|
|
87
81
|
</CardContent>
|
|
88
82
|
</Card>
|
|
89
83
|
);
|
|
84
|
+
|
|
85
|
+
const ConditionCard: React.FC<{
|
|
86
|
+
index: number;
|
|
87
|
+
value: ConditionInput;
|
|
88
|
+
onChange: (next: ConditionInput) => void;
|
|
89
|
+
onRemove: () => void;
|
|
90
|
+
onDuplicate: () => void;
|
|
91
|
+
variableNodes: VariableNode[];
|
|
92
|
+
completionProvider: TemplateCompletionProvider;
|
|
93
|
+
disabled?: boolean;
|
|
94
|
+
}> = ({
|
|
95
|
+
index,
|
|
96
|
+
value,
|
|
97
|
+
onChange,
|
|
98
|
+
onRemove,
|
|
99
|
+
onDuplicate,
|
|
100
|
+
variableNodes,
|
|
101
|
+
completionProvider,
|
|
102
|
+
disabled,
|
|
103
|
+
}) => {
|
|
104
|
+
const issues = useConditionIssues(index);
|
|
105
|
+
const [sheetOpen, setSheetOpen] = React.useState(false);
|
|
106
|
+
|
|
107
|
+
React.useEffect(() => {
|
|
108
|
+
if (issues.length > 0) setSheetOpen(true);
|
|
109
|
+
}, [issues.length]);
|
|
110
|
+
|
|
111
|
+
const summary = summarizeCondition(value);
|
|
112
|
+
const title = `Condition ${index + 1}`;
|
|
113
|
+
|
|
114
|
+
const menuItems: ActionCardMenuItem[] = disabled
|
|
115
|
+
? []
|
|
116
|
+
: [
|
|
117
|
+
{ label: "Duplicate", icon: "Copy", onClick: onDuplicate },
|
|
118
|
+
{
|
|
119
|
+
label: "Delete",
|
|
120
|
+
icon: "Trash2",
|
|
121
|
+
onClick: onRemove,
|
|
122
|
+
variant: "destructive",
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<>
|
|
128
|
+
<ActionCard
|
|
129
|
+
id={`condition-${index}`}
|
|
130
|
+
title={title}
|
|
131
|
+
summary={summary}
|
|
132
|
+
category="Condition"
|
|
133
|
+
icon="Funnel"
|
|
134
|
+
onOpenSheet={() => setSheetOpen(true)}
|
|
135
|
+
actions={menuItems}
|
|
136
|
+
errors={issues}
|
|
137
|
+
/>
|
|
138
|
+
<ItemSheet
|
|
139
|
+
open={sheetOpen}
|
|
140
|
+
onOpenChange={setSheetOpen}
|
|
141
|
+
title={title}
|
|
142
|
+
description="Pre-run condition"
|
|
143
|
+
>
|
|
144
|
+
<ConditionEditor
|
|
145
|
+
value={value}
|
|
146
|
+
onChange={onChange}
|
|
147
|
+
variableNodes={variableNodes}
|
|
148
|
+
completionProvider={completionProvider}
|
|
149
|
+
bare
|
|
150
|
+
// Kind is chosen up front via AddConditionDialog; to change it the
|
|
151
|
+
// operator deletes + re-adds, matching how actions work. Nested
|
|
152
|
+
// clauses keep their inline selector (this prop isn't threaded down).
|
|
153
|
+
hideKindSelector
|
|
154
|
+
/>
|
|
155
|
+
</ItemSheet>
|
|
156
|
+
</>
|
|
157
|
+
);
|
|
158
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
Sheet,
|
|
4
|
+
SheetBody,
|
|
5
|
+
SheetContent,
|
|
6
|
+
SheetDescription,
|
|
7
|
+
SheetHeader,
|
|
8
|
+
SheetTitle,
|
|
9
|
+
} from "@checkstack/ui";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Side-sheet host for a single editor item's full configuration.
|
|
13
|
+
*
|
|
14
|
+
* The collapsed summary cards (actions, triggers, conditions) render only a
|
|
15
|
+
* compact row; clicking opens this right-side sheet containing the item's
|
|
16
|
+
* live config form. The form edits the same `value`/`onChange` the inline
|
|
17
|
+
* editor used, so closing the sheet keeps the changes (they're already
|
|
18
|
+
* applied) - there is no draft/commit step.
|
|
19
|
+
*
|
|
20
|
+
* Composite actions nest `ItemSheet`s: a child card inside the parent's sheet
|
|
21
|
+
* body opens its own sheet, which stacks on top via Radix Dialog's portal +
|
|
22
|
+
* overlay. The z-index/overlay layering is handled by the `Sheet` primitive.
|
|
23
|
+
*/
|
|
24
|
+
export const ItemSheet: React.FC<{
|
|
25
|
+
open: boolean;
|
|
26
|
+
onOpenChange: (open: boolean) => void;
|
|
27
|
+
title: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
/** Sheet width. Composite/provider bodies want room, so default to `lg`. */
|
|
30
|
+
size?: "default" | "lg" | "xl" | "full";
|
|
31
|
+
children: React.ReactNode;
|
|
32
|
+
}> = ({ open, onOpenChange, title, description, size = "lg", children }) => (
|
|
33
|
+
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
34
|
+
<SheetContent
|
|
35
|
+
size={size}
|
|
36
|
+
// Stop the click that bubbles from a nested card/menu inside the sheet
|
|
37
|
+
// from being interpreted as a header click on the card behind it.
|
|
38
|
+
onClick={(event) => event.stopPropagation()}
|
|
39
|
+
>
|
|
40
|
+
<SheetHeader>
|
|
41
|
+
<SheetTitle className="truncate">{title}</SheetTitle>
|
|
42
|
+
{description && (
|
|
43
|
+
<SheetDescription className="truncate">
|
|
44
|
+
{description}
|
|
45
|
+
</SheetDescription>
|
|
46
|
+
)}
|
|
47
|
+
</SheetHeader>
|
|
48
|
+
<SheetBody className="space-y-4">{children}</SheetBody>
|
|
49
|
+
</SheetContent>
|
|
50
|
+
</Sheet>
|
|
51
|
+
);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { usePluginClient } from "@checkstack/frontend-api";
|
|
3
|
+
import {
|
|
4
|
+
Select,
|
|
5
|
+
SelectContent,
|
|
6
|
+
SelectItem,
|
|
7
|
+
SelectTrigger,
|
|
8
|
+
SelectValue,
|
|
9
|
+
} from "@checkstack/ui";
|
|
10
|
+
import { AutomationApi } from "@checkstack/automation-common";
|
|
11
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
12
|
+
|
|
13
|
+
export interface RunReplayPickerProps {
|
|
14
|
+
/** Automation whose runs populate the picker. */
|
|
15
|
+
automationId: string;
|
|
16
|
+
/**
|
|
17
|
+
* Called with the reconstructed sample-context JSON once a run is
|
|
18
|
+
* selected and its scope is fetched. The parent applies it to the
|
|
19
|
+
* `ContextSampleEditor`.
|
|
20
|
+
*/
|
|
21
|
+
onLoad: (sampleContextJson: string) => void;
|
|
22
|
+
/** Surfaces a warning when the picked run's durable scope was cleared. */
|
|
23
|
+
onScopeSnapshotMissing?: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* "Load from run" dropdown for the script-test panel. Lists this
|
|
28
|
+
* automation's recent runs; selecting one fetches its reconstructed
|
|
29
|
+
* scope via `getRunScopeForReplay` and hands the JSON to `onLoad`.
|
|
30
|
+
*
|
|
31
|
+
* Lives in automation-frontend (owns the RPC); `@checkstack/ui` stays
|
|
32
|
+
* plugin-agnostic and only renders it through the `runPicker` slot.
|
|
33
|
+
*/
|
|
34
|
+
export const RunReplayPicker: React.FC<RunReplayPickerProps> = ({
|
|
35
|
+
automationId,
|
|
36
|
+
onLoad,
|
|
37
|
+
onScopeSnapshotMissing,
|
|
38
|
+
}) => {
|
|
39
|
+
const client = usePluginClient(AutomationApi);
|
|
40
|
+
const [selectedRunId, setSelectedRunId] = React.useState<string | null>(null);
|
|
41
|
+
|
|
42
|
+
const runsQuery = client.listRuns.useQuery(
|
|
43
|
+
{ automationId, limit: 25 },
|
|
44
|
+
{ enabled: Boolean(automationId) },
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const replayQuery = client.getRunScopeForReplay.useQuery(
|
|
48
|
+
{ runId: selectedRunId ?? "" },
|
|
49
|
+
{ enabled: Boolean(selectedRunId), gcTime: 0 },
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// Apply the fetched scope once it resolves for the selected run.
|
|
53
|
+
React.useEffect(() => {
|
|
54
|
+
if (!selectedRunId || !replayQuery.data) return;
|
|
55
|
+
onLoad(JSON.stringify(replayQuery.data.context, null, 2));
|
|
56
|
+
if (!replayQuery.data.scopeSnapshotAvailable) {
|
|
57
|
+
onScopeSnapshotMissing?.();
|
|
58
|
+
}
|
|
59
|
+
// Reset so the same run can be re-picked later.
|
|
60
|
+
setSelectedRunId(null);
|
|
61
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- apply once per fetch; callbacks are stable enough and re-running on their identity would double-apply
|
|
62
|
+
}, [selectedRunId, replayQuery.data]);
|
|
63
|
+
|
|
64
|
+
const runs = runsQuery.data?.items ?? [];
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<Select
|
|
68
|
+
value=""
|
|
69
|
+
onValueChange={(runId) => setSelectedRunId(runId)}
|
|
70
|
+
disabled={runs.length === 0 || replayQuery.isFetching}
|
|
71
|
+
>
|
|
72
|
+
<SelectTrigger className="h-7 w-[180px] text-xs">
|
|
73
|
+
<SelectValue
|
|
74
|
+
placeholder={
|
|
75
|
+
replayQuery.isFetching
|
|
76
|
+
? "Loading…"
|
|
77
|
+
: runs.length === 0
|
|
78
|
+
? "No runs yet"
|
|
79
|
+
: "Load from run"
|
|
80
|
+
}
|
|
81
|
+
/>
|
|
82
|
+
</SelectTrigger>
|
|
83
|
+
<SelectContent>
|
|
84
|
+
{runs.map((run) => (
|
|
85
|
+
<SelectItem key={run.id} value={run.id}>
|
|
86
|
+
{new Date(run.startedAt).toLocaleString()} — {run.status}
|
|
87
|
+
</SelectItem>
|
|
88
|
+
))}
|
|
89
|
+
</SelectContent>
|
|
90
|
+
</Select>
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** Format a replay-load error for surfacing in the panel. */
|
|
95
|
+
export function formatReplayError(error: unknown): string {
|
|
96
|
+
return `Failed to load run scope: ${extractErrorMessage(error)}`;
|
|
97
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
CodeEditor,
|
|
4
|
+
areVscodeServicesReady,
|
|
5
|
+
onVscodeServicesReady,
|
|
6
|
+
} from "@checkstack/ui";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Boots the shared monaco-vscode services so the headless script validator can
|
|
10
|
+
* run from the moment an automation is opened - WITHOUT waiting for the
|
|
11
|
+
* operator to expand a script action card.
|
|
12
|
+
*
|
|
13
|
+
* Why this exists: the validator must not initialize the monaco-vscode services
|
|
14
|
+
* itself (that collides with the editor wrapper's one-time init and throws
|
|
15
|
+
* "Services are already initialized"). Only a real editor may init them. So
|
|
16
|
+
* when an automation already contains inline scripts, we mount ONE tiny,
|
|
17
|
+
* offscreen, read-only editor purely to trigger that init. Its
|
|
18
|
+
* `onEditorStartDone` flips the global "services ready" flag, the validator's
|
|
19
|
+
* subscription fires, and every script - collapsed cards included - validates.
|
|
20
|
+
*
|
|
21
|
+
* Once services are ready the booter unmounts (it has done its job), so there's
|
|
22
|
+
* no lingering hidden editor. It is only rendered when the definition actually
|
|
23
|
+
* has a script action, so automations with none never pay the Monaco load.
|
|
24
|
+
*/
|
|
25
|
+
export const ScriptServicesBooter: React.FC = () => {
|
|
26
|
+
const [ready, setReady] = React.useState(() => areVscodeServicesReady());
|
|
27
|
+
|
|
28
|
+
React.useEffect(() => {
|
|
29
|
+
if (ready) return;
|
|
30
|
+
return onVscodeServicesReady(() => setReady(true));
|
|
31
|
+
}, [ready]);
|
|
32
|
+
|
|
33
|
+
if (ready) return null;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
aria-hidden
|
|
38
|
+
// Pulled far offscreen with a tiny footprint so it never affects layout
|
|
39
|
+
// or interaction; it exists only to start the editor services.
|
|
40
|
+
className="pointer-events-none fixed left-[-9999px] top-0 h-[2px] w-[2px] overflow-hidden opacity-0"
|
|
41
|
+
>
|
|
42
|
+
<CodeEditor
|
|
43
|
+
id="automation-script-services-booter"
|
|
44
|
+
language="typescript"
|
|
45
|
+
value=""
|
|
46
|
+
onChange={() => {}}
|
|
47
|
+
minHeight="1px"
|
|
48
|
+
allowPopout={false}
|
|
49
|
+
readOnly
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { usePluginClient } from "@checkstack/frontend-api";
|
|
3
|
+
import {
|
|
4
|
+
ScriptTestPanel,
|
|
5
|
+
ContextSampleEditor,
|
|
6
|
+
type ScriptTestRenderer,
|
|
7
|
+
type ScriptTestPanelResult,
|
|
8
|
+
type ScriptTestRunArgs,
|
|
9
|
+
} from "@checkstack/ui";
|
|
10
|
+
import { AutomationApi } from "@checkstack/automation-common";
|
|
11
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
12
|
+
import { useAutomationRegistry } from "./registry-context";
|
|
13
|
+
import { RunReplayPicker } from "./RunReplayPicker";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Default sample context for a freshly-opened test panel. Auto-seeded so
|
|
17
|
+
* operators see a runnable example instead of a blank slate. Mirrors the
|
|
18
|
+
* `context` shape `run_script` exposes / `run_shell` flattens.
|
|
19
|
+
*/
|
|
20
|
+
const DEFAULT_SAMPLE_CONTEXT = `{
|
|
21
|
+
"trigger": {
|
|
22
|
+
"event": "incident.created",
|
|
23
|
+
"payload": {
|
|
24
|
+
"id": "INC-42",
|
|
25
|
+
"title": "API latency spike",
|
|
26
|
+
"severity": "high"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"artifacts": {},
|
|
30
|
+
"var": {}
|
|
31
|
+
}`;
|
|
32
|
+
|
|
33
|
+
const TIMEOUT_MS = 30_000;
|
|
34
|
+
|
|
35
|
+
interface AutomationScriptTestPanelProps {
|
|
36
|
+
kind: "typescript" | "shell";
|
|
37
|
+
script: string;
|
|
38
|
+
/**
|
|
39
|
+
* The action's declared secret → env mapping (the sibling `x-secret-env`
|
|
40
|
+
* field). Forwarded to `testScript` so the test injects
|
|
41
|
+
* `__SECRET_<NAME>__` placeholders (or operator overrides) — real secret
|
|
42
|
+
* values are NEVER resolved in the test path.
|
|
43
|
+
*/
|
|
44
|
+
secretEnv?: Record<string, string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Self-contained test panel for an automation `run_script` / `run_shell`
|
|
49
|
+
* action config field. Owns its own editable sample-context state and the
|
|
50
|
+
* `testScript` mutation, so it can be returned from a {@link ScriptTestRenderer}
|
|
51
|
+
* without violating the rules of hooks.
|
|
52
|
+
*/
|
|
53
|
+
const AutomationScriptTestPanel: React.FC<AutomationScriptTestPanelProps> = ({
|
|
54
|
+
kind,
|
|
55
|
+
script,
|
|
56
|
+
secretEnv,
|
|
57
|
+
}) => {
|
|
58
|
+
const client = usePluginClient(AutomationApi);
|
|
59
|
+
const { automationId } = useAutomationRegistry();
|
|
60
|
+
const testMutation = client.testScript.useMutation();
|
|
61
|
+
const [sampleContext, setSampleContext] = React.useState(
|
|
62
|
+
DEFAULT_SAMPLE_CONTEXT,
|
|
63
|
+
);
|
|
64
|
+
const [snapshotWarning, setSnapshotWarning] = React.useState(false);
|
|
65
|
+
|
|
66
|
+
const handleRun = React.useCallback(
|
|
67
|
+
async ({
|
|
68
|
+
secretOverrides,
|
|
69
|
+
}: ScriptTestRunArgs): Promise<ScriptTestPanelResult> => {
|
|
70
|
+
let context: Record<string, unknown> | undefined;
|
|
71
|
+
if (sampleContext.trim().length > 0) {
|
|
72
|
+
try {
|
|
73
|
+
context = JSON.parse(sampleContext) as Record<string, unknown>;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return {
|
|
76
|
+
stdout: "",
|
|
77
|
+
stderr: "",
|
|
78
|
+
durationMs: 0,
|
|
79
|
+
timedOut: false,
|
|
80
|
+
error: `Sample context is not valid JSON: ${extractErrorMessage(error)}`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return testMutation.mutateAsync({
|
|
85
|
+
kind,
|
|
86
|
+
script,
|
|
87
|
+
context,
|
|
88
|
+
// The action's declared secrets → placeholders/overrides in the test
|
|
89
|
+
// run. Real values are never resolved here (decision 4).
|
|
90
|
+
...(secretEnv ? { secretEnv } : {}),
|
|
91
|
+
...(secretOverrides ? { secretOverrides } : {}),
|
|
92
|
+
timeoutMs: TIMEOUT_MS,
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
[testMutation, kind, script, sampleContext, secretEnv],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const note = snapshotWarning
|
|
99
|
+
? "Loaded trigger + artifacts from the run. Variables / loop state were not available (the run's durable scope was already cleared). Runs on the central backend; real satellite runs may differ."
|
|
100
|
+
: undefined;
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<ScriptTestPanel
|
|
104
|
+
onRun={handleRun}
|
|
105
|
+
disabled={script.trim().length === 0}
|
|
106
|
+
secretEnv={secretEnv}
|
|
107
|
+
note={note}
|
|
108
|
+
contextEditor={
|
|
109
|
+
<ContextSampleEditor
|
|
110
|
+
value={sampleContext}
|
|
111
|
+
onChange={(next) => {
|
|
112
|
+
setSampleContext(next);
|
|
113
|
+
setSnapshotWarning(false);
|
|
114
|
+
}}
|
|
115
|
+
runPicker={
|
|
116
|
+
automationId ? (
|
|
117
|
+
<RunReplayPicker
|
|
118
|
+
automationId={automationId}
|
|
119
|
+
onLoad={(json) => {
|
|
120
|
+
setSampleContext(json);
|
|
121
|
+
setSnapshotWarning(false);
|
|
122
|
+
}}
|
|
123
|
+
onScopeSnapshotMissing={() => setSnapshotWarning(true)}
|
|
124
|
+
/>
|
|
125
|
+
) : undefined
|
|
126
|
+
}
|
|
127
|
+
/>
|
|
128
|
+
}
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Build the {@link ScriptTestRenderer} passed to `DynamicForm` for the
|
|
135
|
+
* automation action editor. Renders an {@link AutomationScriptTestPanel}
|
|
136
|
+
* beneath any `x-script-testable` script field.
|
|
137
|
+
*/
|
|
138
|
+
export const automationScriptTestRenderer: ScriptTestRenderer = ({
|
|
139
|
+
fieldId,
|
|
140
|
+
kind,
|
|
141
|
+
script,
|
|
142
|
+
secretEnv,
|
|
143
|
+
}) => (
|
|
144
|
+
<AutomationScriptTestPanel
|
|
145
|
+
key={`${fieldId}-test`}
|
|
146
|
+
kind={kind}
|
|
147
|
+
script={script}
|
|
148
|
+
secretEnv={secretEnv}
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { shouldStartManual } from "./system-entity-picker.logic";
|
|
3
|
+
|
|
4
|
+
describe("shouldStartManual", () => {
|
|
5
|
+
const known = new Set(["payments-api", "billing"]);
|
|
6
|
+
|
|
7
|
+
it("uses the picker for a blank value", () => {
|
|
8
|
+
expect(shouldStartManual({ value: "", knownSystemIds: known })).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("uses the picker for a known system id", () => {
|
|
12
|
+
expect(
|
|
13
|
+
shouldStartManual({ value: "payments-api", knownSystemIds: known }),
|
|
14
|
+
).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("falls back to manual entry for an unknown id (preserves round-trip)", () => {
|
|
18
|
+
expect(
|
|
19
|
+
shouldStartManual({ value: "future-system", knownSystemIds: known }),
|
|
20
|
+
).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("falls back to manual entry for a template", () => {
|
|
24
|
+
expect(
|
|
25
|
+
shouldStartManual({
|
|
26
|
+
value: "{{ trigger.payload.system }}",
|
|
27
|
+
knownSystemIds: known,
|
|
28
|
+
}),
|
|
29
|
+
).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("treats a template as manual even against an empty catalog", () => {
|
|
33
|
+
expect(
|
|
34
|
+
shouldStartManual({ value: "{{ x }}", knownSystemIds: new Set() }),
|
|
35
|
+
).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Pencil, List } from "lucide-react";
|
|
3
|
+
import { usePluginClient } from "@checkstack/frontend-api";
|
|
4
|
+
import { CatalogApi } from "@checkstack/catalog-common";
|
|
5
|
+
import { Button, Input } from "@checkstack/ui";
|
|
6
|
+
import { ItemPicker } from "./ItemPicker";
|
|
7
|
+
import { shouldStartManual } from "./system-entity-picker.logic";
|
|
8
|
+
|
|
9
|
+
export interface SystemEntityPickerProps {
|
|
10
|
+
/** Current entity value — a catalog system id, or a free-form id / template. */
|
|
11
|
+
value: string;
|
|
12
|
+
onChange: (next: string) => void;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Live system picker for a `state` condition's `entity`. Backed by the
|
|
18
|
+
* catalog `getSystems` RPC so an operator picks from the registered systems
|
|
19
|
+
* instead of typing a raw id.
|
|
20
|
+
*
|
|
21
|
+
* Keeps a manual-entry fallback: an id not present in the catalog (e.g. a
|
|
22
|
+
* system that will exist at run time, or a `{{ … }}` template) must still
|
|
23
|
+
* round-trip losslessly, so the picker switches to a plain text input when
|
|
24
|
+
* the current value isn't a known system id, and an explicit toggle lets the
|
|
25
|
+
* operator force manual entry. The value is always a bare string either way,
|
|
26
|
+
* so the saved `definition` is unchanged.
|
|
27
|
+
*/
|
|
28
|
+
export const SystemEntityPicker: React.FC<SystemEntityPickerProps> = ({
|
|
29
|
+
value,
|
|
30
|
+
onChange,
|
|
31
|
+
disabled,
|
|
32
|
+
}) => {
|
|
33
|
+
const catalogClient = usePluginClient(CatalogApi);
|
|
34
|
+
const { data } = catalogClient.getSystems.useQuery({});
|
|
35
|
+
const systems = React.useMemo(() => data?.systems ?? [], [data]);
|
|
36
|
+
|
|
37
|
+
const items = React.useMemo(
|
|
38
|
+
() =>
|
|
39
|
+
systems.map((system) => ({
|
|
40
|
+
id: system.id,
|
|
41
|
+
label: system.name,
|
|
42
|
+
description: system.id,
|
|
43
|
+
})),
|
|
44
|
+
[systems],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Drop to manual entry for templates or ids that aren't (yet) in the
|
|
48
|
+
// catalog so the value still round-trips; an explicit toggle also forces
|
|
49
|
+
// it. A blank value defaults to the picker (the common case).
|
|
50
|
+
const [manual, setManual] = React.useState(() =>
|
|
51
|
+
shouldStartManual({
|
|
52
|
+
value,
|
|
53
|
+
knownSystemIds: new Set(systems.map((system) => system.id)),
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (manual) {
|
|
58
|
+
return (
|
|
59
|
+
<div className="flex items-center gap-2">
|
|
60
|
+
<Input
|
|
61
|
+
className="font-mono text-xs"
|
|
62
|
+
value={value}
|
|
63
|
+
placeholder="payments-api or {{ trigger.payload.system }}"
|
|
64
|
+
onChange={(event) => onChange(event.target.value)}
|
|
65
|
+
disabled={disabled}
|
|
66
|
+
/>
|
|
67
|
+
<Button
|
|
68
|
+
type="button"
|
|
69
|
+
variant="ghost"
|
|
70
|
+
size="icon"
|
|
71
|
+
className="h-8 w-8 shrink-0"
|
|
72
|
+
onClick={() => setManual(false)}
|
|
73
|
+
disabled={disabled}
|
|
74
|
+
aria-label="Pick from catalog systems"
|
|
75
|
+
title="Pick from catalog systems"
|
|
76
|
+
>
|
|
77
|
+
<List className="h-3.5 w-3.5" />
|
|
78
|
+
</Button>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="flex items-center gap-2">
|
|
85
|
+
<div className="flex-1">
|
|
86
|
+
<ItemPicker
|
|
87
|
+
items={items}
|
|
88
|
+
value={value || undefined}
|
|
89
|
+
onSelect={onChange}
|
|
90
|
+
placeholder="Pick a system"
|
|
91
|
+
emptyText="No systems in the catalog"
|
|
92
|
+
disabled={disabled}
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
<Button
|
|
96
|
+
type="button"
|
|
97
|
+
variant="ghost"
|
|
98
|
+
size="icon"
|
|
99
|
+
className="h-8 w-8 shrink-0"
|
|
100
|
+
onClick={() => setManual(true)}
|
|
101
|
+
disabled={disabled}
|
|
102
|
+
aria-label="Enter a system id or template manually"
|
|
103
|
+
title="Enter a system id or template manually"
|
|
104
|
+
>
|
|
105
|
+
<Pencil className="h-3.5 w-3.5" />
|
|
106
|
+
</Button>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
};
|