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