@checkstack/ui 1.11.0 → 1.12.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/.storybook/main.ts +43 -0
- package/CHANGELOG.md +181 -0
- package/package.json +4 -4
- package/scripts/generate-stdlib-types.ts +23 -0
- package/src/components/ActionCard.tsx +96 -8
- package/src/components/CodeEditor/CodeEditor.tsx +95 -14
- package/src/components/CodeEditor/TypefoxEditor.tsx +279 -123
- package/src/components/CodeEditor/generated/builtin-modules.json +1 -0
- package/src/components/CodeEditor/importSpecifiers.test.ts +286 -0
- package/src/components/CodeEditor/importSpecifiers.ts +267 -0
- package/src/components/CodeEditor/index.ts +24 -0
- package/src/components/CodeEditor/monacoTsService.ts +217 -0
- package/src/components/CodeEditor/popoutTitle.test.ts +37 -0
- package/src/components/CodeEditor/popoutTitle.ts +31 -0
- package/src/components/CodeEditor/scriptDiagnostics.test.ts +135 -0
- package/src/components/CodeEditor/scriptDiagnostics.ts +172 -0
- package/src/components/CodeEditor/types.ts +59 -0
- package/src/components/CodeEditor/validateScripts.ts +132 -0
- package/src/components/Dialog.tsx +32 -11
- package/src/components/DurationInput.tsx +121 -0
- package/src/components/DynamicForm/DynamicForm.tsx +25 -1
- package/src/components/DynamicForm/FormField.tsx +109 -1
- package/src/components/DynamicForm/MultiTypeEditorField.tsx +67 -2
- package/src/components/DynamicForm/SecretEnvEditor.tsx +315 -0
- package/src/components/DynamicForm/index.ts +6 -0
- package/src/components/DynamicForm/secretEnv.logic.test.ts +126 -0
- package/src/components/DynamicForm/secretEnv.logic.ts +87 -0
- package/src/components/DynamicForm/types.ts +72 -1
- package/src/components/DynamicForm/utils.ts +32 -0
- package/src/components/Popover.tsx +6 -1
- package/src/components/ScriptTestPanel.logic.test.ts +139 -0
- package/src/components/ScriptTestPanel.logic.ts +137 -0
- package/src/components/ScriptTestPanel.tsx +394 -0
- package/src/components/Sheet.tsx +21 -6
- package/src/components/TimeOfDayInput.tsx +116 -0
- package/src/components/comboboxInteraction.ts +39 -0
- package/src/components/portalContainer.ts +24 -0
- package/src/index.ts +4 -0
- package/stories/ActionCard.stories.tsx +60 -0
- package/stories/CodeEditor.stories.tsx +47 -2
- package/stories/DurationInput.stories.tsx +59 -0
- package/stories/ScriptTestPanel.stories.tsx +106 -0
- package/stories/SecretEnvEditor.stories.tsx +80 -0
- package/stories/TimeOfDayInput.stories.tsx +34 -0
- package/tsconfig.json +1 -0
|
@@ -2,6 +2,7 @@ import * as React from "react";
|
|
|
2
2
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
3
3
|
import { cn } from "../utils";
|
|
4
4
|
import { usePerformance } from "./PerformanceProvider";
|
|
5
|
+
import { usePortalContainer } from "./portalContainer";
|
|
5
6
|
|
|
6
7
|
const Popover = PopoverPrimitive.Root;
|
|
7
8
|
const PopoverTrigger = PopoverPrimitive.Trigger;
|
|
@@ -17,8 +18,12 @@ const PopoverContent = React.forwardRef<
|
|
|
17
18
|
PopoverContentProps
|
|
18
19
|
>(({ className, align = "end", sideOffset = 8, ...props }, ref) => {
|
|
19
20
|
const { isLowPower } = usePerformance();
|
|
21
|
+
// When inside a modal Sheet/Dialog, portal into its content so the dialog's
|
|
22
|
+
// scroll-lock doesn't block this popover's internal scroll. Outside a
|
|
23
|
+
// Sheet/Dialog the container is null and Radix portals to `body` as usual.
|
|
24
|
+
const portalContainer = usePortalContainer();
|
|
20
25
|
return (
|
|
21
|
-
<PopoverPrimitive.Portal>
|
|
26
|
+
<PopoverPrimitive.Portal container={portalContainer ?? undefined}>
|
|
22
27
|
<PopoverPrimitive.Content
|
|
23
28
|
ref={ref}
|
|
24
29
|
align={align}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildSecretOverrides,
|
|
4
|
+
distinctSecretNames,
|
|
5
|
+
formatReturnValue,
|
|
6
|
+
hasNoOutput,
|
|
7
|
+
isFailedResult,
|
|
8
|
+
rejectionResult,
|
|
9
|
+
validateSampleContextJson,
|
|
10
|
+
type ScriptTestPanelResult,
|
|
11
|
+
} from "./ScriptTestPanel.logic";
|
|
12
|
+
|
|
13
|
+
const base: ScriptTestPanelResult = {
|
|
14
|
+
stdout: "",
|
|
15
|
+
stderr: "",
|
|
16
|
+
durationMs: 10,
|
|
17
|
+
timedOut: false,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe("isFailedResult", () => {
|
|
21
|
+
test("a clean result is not a failure", () => {
|
|
22
|
+
expect(isFailedResult({ ...base, result: { ok: true } })).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
test("an error result is a failure", () => {
|
|
25
|
+
expect(isFailedResult({ ...base, error: "boom" })).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
test("a timed-out result is a failure", () => {
|
|
28
|
+
expect(isFailedResult({ ...base, timedOut: true })).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("formatReturnValue", () => {
|
|
33
|
+
test("pretty-prints an object", () => {
|
|
34
|
+
expect(formatReturnValue({ a: 1 })).toBe('{\n "a": 1\n}');
|
|
35
|
+
});
|
|
36
|
+
test("renders undefined explicitly", () => {
|
|
37
|
+
expect(formatReturnValue(undefined)).toBe("undefined");
|
|
38
|
+
});
|
|
39
|
+
test("falls back to String() for non-serialisable values", () => {
|
|
40
|
+
const circular: Record<string, unknown> = {};
|
|
41
|
+
circular.self = circular;
|
|
42
|
+
expect(formatReturnValue(circular)).toBe("[object Object]");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("hasNoOutput", () => {
|
|
47
|
+
test("true when nothing was produced", () => {
|
|
48
|
+
expect(hasNoOutput(base)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
test("false when there is a return value", () => {
|
|
51
|
+
expect(hasNoOutput({ ...base, result: null })).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
test("false when there is stdout", () => {
|
|
54
|
+
expect(hasNoOutput({ ...base, stdout: "hi" })).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
test("false when there is an error", () => {
|
|
57
|
+
expect(hasNoOutput({ ...base, error: "x" })).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("validateSampleContextJson", () => {
|
|
62
|
+
test("empty value is valid (null)", () => {
|
|
63
|
+
expect(validateSampleContextJson(" ")).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
test("valid JSON is valid (null)", () => {
|
|
66
|
+
expect(validateSampleContextJson('{"a":1}')).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
test("malformed JSON returns a message", () => {
|
|
69
|
+
expect(validateSampleContextJson("{ not json")).not.toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("rejectionResult", () => {
|
|
74
|
+
test("wraps a thrown Error into a failure result", () => {
|
|
75
|
+
const r = rejectionResult(new Error("network down"));
|
|
76
|
+
expect(r.error).toBe("network down");
|
|
77
|
+
expect(isFailedResult(r)).toBe(true);
|
|
78
|
+
expect(r.timedOut).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("distinctSecretNames", () => {
|
|
83
|
+
test("returns [] for an absent or empty mapping", () => {
|
|
84
|
+
expect(distinctSecretNames(undefined)).toEqual([]);
|
|
85
|
+
expect(distinctSecretNames({})).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("extracts distinct secret names from templates, first-seen order", () => {
|
|
89
|
+
expect(
|
|
90
|
+
distinctSecretNames({
|
|
91
|
+
API_TOKEN: "${{ secrets.jira_token }}",
|
|
92
|
+
DB: "${{ secrets.db_pass }}",
|
|
93
|
+
ALSO: "${{ secrets.jira_token }}",
|
|
94
|
+
}),
|
|
95
|
+
).toEqual(["jira_token", "db_pass"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("tolerates a legacy bare secret name as a value", () => {
|
|
99
|
+
expect(distinctSecretNames({ secret: "SECRET" })).toEqual(["SECRET"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("ignores values that are neither a template nor a bare name", () => {
|
|
103
|
+
expect(distinctSecretNames({ X: "not a name" })).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("buildSecretOverrides", () => {
|
|
108
|
+
const secretEnv = {
|
|
109
|
+
API_TOKEN: "${{ secrets.jira_token }}",
|
|
110
|
+
DB: "${{ secrets.db_pass }}",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
test("returns undefined when no draft is filled", () => {
|
|
114
|
+
expect(
|
|
115
|
+
buildSecretOverrides({ secretEnv, drafts: {} }),
|
|
116
|
+
).toBeUndefined();
|
|
117
|
+
expect(
|
|
118
|
+
buildSecretOverrides({ secretEnv, drafts: { jira_token: "" } }),
|
|
119
|
+
).toBeUndefined();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("keeps only filled drafts for referenced names", () => {
|
|
123
|
+
expect(
|
|
124
|
+
buildSecretOverrides({
|
|
125
|
+
secretEnv,
|
|
126
|
+
drafts: { jira_token: "abc", db_pass: "" },
|
|
127
|
+
}),
|
|
128
|
+
).toEqual({ jira_token: "abc" });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("drops drafts for names not referenced by the mapping", () => {
|
|
132
|
+
expect(
|
|
133
|
+
buildSecretOverrides({
|
|
134
|
+
secretEnv,
|
|
135
|
+
drafts: { jira_token: "abc", stale: "leak" },
|
|
136
|
+
}),
|
|
137
|
+
).toEqual({ jira_token: "abc" });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure logic for the script-test panel, extracted so it can be unit-tested
|
|
5
|
+
* without rendering Monaco. The React component in `ScriptTestPanel.tsx`
|
|
6
|
+
* consumes these.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Result of a single in-UI script test run (UI-side shape). */
|
|
10
|
+
export interface ScriptTestPanelResult {
|
|
11
|
+
result?: unknown;
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
exitCode?: number;
|
|
15
|
+
durationMs: number;
|
|
16
|
+
timedOut: boolean;
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A run is a failure if it errored or timed out. */
|
|
21
|
+
export function isFailedResult(result: ScriptTestPanelResult): boolean {
|
|
22
|
+
return result.error !== undefined || result.timedOut;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Pretty-print a script's return value for display. */
|
|
26
|
+
export function formatReturnValue(value: unknown): string {
|
|
27
|
+
if (value === undefined) return "undefined";
|
|
28
|
+
try {
|
|
29
|
+
return JSON.stringify(value, null, 2);
|
|
30
|
+
} catch {
|
|
31
|
+
return String(value);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* True when a result has nothing to show in the detail body (no error,
|
|
37
|
+
* no return value, no stdout/stderr) - the panel shows "No output." then.
|
|
38
|
+
*/
|
|
39
|
+
export function hasNoOutput(result: ScriptTestPanelResult): boolean {
|
|
40
|
+
return (
|
|
41
|
+
result.error === undefined &&
|
|
42
|
+
result.result === undefined &&
|
|
43
|
+
result.stdout.length === 0 &&
|
|
44
|
+
result.stderr.length === 0
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Validate the editable sample-context JSON. Returns the parse error
|
|
50
|
+
* message, or `null` when the value is empty or valid JSON.
|
|
51
|
+
*/
|
|
52
|
+
export function validateSampleContextJson(value: string): string | null {
|
|
53
|
+
if (value.trim().length === 0) return null;
|
|
54
|
+
try {
|
|
55
|
+
JSON.parse(value);
|
|
56
|
+
return null;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return extractErrorMessage(error, "Invalid JSON");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Build the failure result the panel shows when `onRun` rejects. */
|
|
63
|
+
export function rejectionResult(error: unknown): ScriptTestPanelResult {
|
|
64
|
+
return {
|
|
65
|
+
stdout: "",
|
|
66
|
+
stderr: "",
|
|
67
|
+
durationMs: 0,
|
|
68
|
+
timedOut: false,
|
|
69
|
+
error: extractErrorMessage(error),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Secret-env test overrides ──────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
const SECRET_TEMPLATE_RE = /\$\{\{\s*secrets\.([a-zA-Z0-9_-]+)\s*\}\}/g;
|
|
76
|
+
// A mapping value may legacy-carry a bare secret name instead of a template.
|
|
77
|
+
const BARE_SECRET_NAME_RE = /^\s*([a-zA-Z][a-zA-Z0-9_-]*)\s*$/;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Derive the set of distinct secret NAMES referenced by a `secretEnv`
|
|
81
|
+
* mapping (`{ ENV_NAME: "${{ secrets.NAME }}" }`). Tolerates a legacy bare
|
|
82
|
+
* secret name as a value too. Order-stable (first-seen) so the override UI
|
|
83
|
+
* renders deterministically. Returns `[]` when the mapping is absent/empty.
|
|
84
|
+
*/
|
|
85
|
+
export function distinctSecretNames(
|
|
86
|
+
secretEnv: Record<string, string> | undefined,
|
|
87
|
+
): string[] {
|
|
88
|
+
if (!secretEnv) return [];
|
|
89
|
+
const seen = new Set<string>();
|
|
90
|
+
const out: string[] = [];
|
|
91
|
+
const add = (name: string) => {
|
|
92
|
+
if (!seen.has(name)) {
|
|
93
|
+
seen.add(name);
|
|
94
|
+
out.push(name);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
for (const value of Object.values(secretEnv)) {
|
|
98
|
+
SECRET_TEMPLATE_RE.lastIndex = 0;
|
|
99
|
+
let matched = false;
|
|
100
|
+
let match: RegExpExecArray | null;
|
|
101
|
+
while ((match = SECRET_TEMPLATE_RE.exec(value)) !== null) {
|
|
102
|
+
matched = true;
|
|
103
|
+
add(match[1]);
|
|
104
|
+
}
|
|
105
|
+
if (!matched) {
|
|
106
|
+
const bare = BARE_SECRET_NAME_RE.exec(value);
|
|
107
|
+
if (bare) add(bare[1]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build the `secretOverrides` payload sent to the test endpoint from the
|
|
115
|
+
* user's per-secret-NAME draft inputs. Drops blank entries (an empty
|
|
116
|
+
* override means "use the placeholder") and keeps only names actually
|
|
117
|
+
* referenced by the mapping, so a stale draft can't inject an unreferenced
|
|
118
|
+
* value. Returns `undefined` when nothing is overridden so the wire stays
|
|
119
|
+
* clean.
|
|
120
|
+
*/
|
|
121
|
+
export function buildSecretOverrides({
|
|
122
|
+
secretEnv,
|
|
123
|
+
drafts,
|
|
124
|
+
}: {
|
|
125
|
+
secretEnv: Record<string, string> | undefined;
|
|
126
|
+
drafts: Record<string, string>;
|
|
127
|
+
}): Record<string, string> | undefined {
|
|
128
|
+
const names = distinctSecretNames(secretEnv);
|
|
129
|
+
const out: Record<string, string> = {};
|
|
130
|
+
for (const name of names) {
|
|
131
|
+
const value = drafts[name];
|
|
132
|
+
if (value !== undefined && value.length > 0) {
|
|
133
|
+
out[name] = value;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
137
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
Play,
|
|
4
|
+
ChevronDown,
|
|
5
|
+
ChevronRight,
|
|
6
|
+
CheckCircle2,
|
|
7
|
+
XCircle,
|
|
8
|
+
FlaskConical,
|
|
9
|
+
} from "lucide-react";
|
|
10
|
+
import { Button } from "./Button";
|
|
11
|
+
import { Badge } from "./Badge";
|
|
12
|
+
import { Input } from "./Input";
|
|
13
|
+
import { Label } from "./Label";
|
|
14
|
+
import { CodeEditor } from "./CodeEditor";
|
|
15
|
+
import { cn } from "../utils";
|
|
16
|
+
import { usePerformance } from "./PerformanceProvider";
|
|
17
|
+
import {
|
|
18
|
+
type ScriptTestPanelResult,
|
|
19
|
+
buildSecretOverrides,
|
|
20
|
+
distinctSecretNames,
|
|
21
|
+
formatReturnValue,
|
|
22
|
+
hasNoOutput,
|
|
23
|
+
isFailedResult,
|
|
24
|
+
rejectionResult,
|
|
25
|
+
validateSampleContextJson,
|
|
26
|
+
} from "./ScriptTestPanel.logic";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Result of a single in-UI script test run. Plugin-agnostic shape mirroring
|
|
30
|
+
* the backend `testScript` / `testCollectorScript` output so the panel can
|
|
31
|
+
* be reused by any script field (automation actions, healthcheck collectors,
|
|
32
|
+
* future surfaces). Re-exported from the pure-logic module so the public
|
|
33
|
+
* type stays at this path.
|
|
34
|
+
*/
|
|
35
|
+
export type { ScriptTestPanelResult } from "./ScriptTestPanel.logic";
|
|
36
|
+
|
|
37
|
+
/** Arguments handed to {@link ScriptTestPanelProps.onRun} for a single run. */
|
|
38
|
+
export interface ScriptTestRunArgs {
|
|
39
|
+
/**
|
|
40
|
+
* User-supplied per-secret-NAME override VALUES for this run (keyed by the
|
|
41
|
+
* secret name, never the env var), collected from the optional override
|
|
42
|
+
* inputs. Omitted when nothing was overridden. These are masked out of the
|
|
43
|
+
* result server-side and are NEVER the user's real secret — only an
|
|
44
|
+
* explicit test value the operator typed.
|
|
45
|
+
*/
|
|
46
|
+
secretOverrides?: Record<string, string>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ScriptTestPanelProps {
|
|
50
|
+
/**
|
|
51
|
+
* Runs the test. The owning feature page supplies this; it typically
|
|
52
|
+
* calls the plugin's `testScript` RPC with the current script + sample
|
|
53
|
+
* context (plus any {@link ScriptTestRunArgs.secretOverrides}) and resolves
|
|
54
|
+
* with the result. Rejecting surfaces as an error.
|
|
55
|
+
*/
|
|
56
|
+
onRun: (args: ScriptTestRunArgs) => Promise<ScriptTestPanelResult>;
|
|
57
|
+
/** Disables the Run button (e.g. while the script field is empty). */
|
|
58
|
+
disabled?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* The script's declared secret → env mapping (`x-secret-env` sibling
|
|
61
|
+
* field). When non-empty, the panel renders an optional per-secret test
|
|
62
|
+
* override input so an operator can supply a realistic value for a run.
|
|
63
|
+
* Real secrets are NEVER resolved in the test path: with no override each
|
|
64
|
+
* secret is injected as a `__SECRET_<NAME>__` placeholder; an override is
|
|
65
|
+
* masked out of the captured output. Omit it and no override UI shows.
|
|
66
|
+
*/
|
|
67
|
+
secretEnv?: Record<string, string>;
|
|
68
|
+
/**
|
|
69
|
+
* Editable sample-context slot. Render a {@link ContextSampleEditor} (or
|
|
70
|
+
* any control) here; the panel just lays it out above the results.
|
|
71
|
+
*/
|
|
72
|
+
contextEditor?: React.ReactNode;
|
|
73
|
+
/**
|
|
74
|
+
* Note shown under the Run button. Defaults to the central-execution
|
|
75
|
+
* caveat. Pass `null` to hide it.
|
|
76
|
+
*/
|
|
77
|
+
note?: React.ReactNode;
|
|
78
|
+
/**
|
|
79
|
+
* Whether the panel is expanded on first render. Defaults to `false`
|
|
80
|
+
* so a compact "Test script" affordance shows under every testable
|
|
81
|
+
* field and the sample-context editor + results only mount on demand.
|
|
82
|
+
* A successful/failed run auto-expands regardless of this.
|
|
83
|
+
*/
|
|
84
|
+
defaultOpen?: boolean;
|
|
85
|
+
className?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const DEFAULT_NOTE =
|
|
89
|
+
"Runs on the central backend. Real satellite runs may differ.";
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Inline script test panel: a Run button plus collapsible results
|
|
93
|
+
* (return value / stdout / stderr / exit code / duration / error).
|
|
94
|
+
*
|
|
95
|
+
* Purely presentational - it owns no RPC. The owning page wires `onRun`
|
|
96
|
+
* to the appropriate `testScript` mutation. Appears beneath any
|
|
97
|
+
* `x-script-testable` field via `MultiTypeEditorField`.
|
|
98
|
+
*/
|
|
99
|
+
export const ScriptTestPanel: React.FC<ScriptTestPanelProps> = ({
|
|
100
|
+
onRun,
|
|
101
|
+
disabled,
|
|
102
|
+
secretEnv,
|
|
103
|
+
contextEditor,
|
|
104
|
+
note = DEFAULT_NOTE,
|
|
105
|
+
defaultOpen = false,
|
|
106
|
+
className,
|
|
107
|
+
}) => {
|
|
108
|
+
const { isLowPower } = usePerformance();
|
|
109
|
+
const [running, setRunning] = React.useState(false);
|
|
110
|
+
// Whole-panel disclosure: collapsed by default so a testable field shows
|
|
111
|
+
// only a compact "Test script" affordance and the sample-context editor +
|
|
112
|
+
// results mount on demand. A run forces it open.
|
|
113
|
+
const [panelOpen, setPanelOpen] = React.useState(defaultOpen);
|
|
114
|
+
const [resultExpanded, setResultExpanded] = React.useState(true);
|
|
115
|
+
const [result, setResult] = React.useState<ScriptTestPanelResult | null>(null);
|
|
116
|
+
// Draft override values keyed by secret NAME. Kept client-side until an
|
|
117
|
+
// explicit run; an empty draft means "use the placeholder".
|
|
118
|
+
const [overrideDrafts, setOverrideDrafts] = React.useState<
|
|
119
|
+
Record<string, string>
|
|
120
|
+
>({});
|
|
121
|
+
|
|
122
|
+
const secretNames = React.useMemo(
|
|
123
|
+
() => distinctSecretNames(secretEnv),
|
|
124
|
+
[secretEnv],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const handleRun = React.useCallback(async () => {
|
|
128
|
+
setRunning(true);
|
|
129
|
+
try {
|
|
130
|
+
const res = await onRun({
|
|
131
|
+
secretOverrides: buildSecretOverrides({
|
|
132
|
+
secretEnv,
|
|
133
|
+
drafts: overrideDrafts,
|
|
134
|
+
}),
|
|
135
|
+
});
|
|
136
|
+
setResult(res);
|
|
137
|
+
setResultExpanded(true);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
setResult(rejectionResult(error));
|
|
140
|
+
setResultExpanded(true);
|
|
141
|
+
} finally {
|
|
142
|
+
setRunning(false);
|
|
143
|
+
}
|
|
144
|
+
}, [onRun, secretEnv, overrideDrafts]);
|
|
145
|
+
|
|
146
|
+
const failed = result !== null && isFailedResult(result);
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div
|
|
150
|
+
className={cn(
|
|
151
|
+
"rounded-lg border border-border/60 bg-muted/20",
|
|
152
|
+
className,
|
|
153
|
+
)}
|
|
154
|
+
>
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
onClick={() => setPanelOpen((prev) => !prev)}
|
|
158
|
+
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left"
|
|
159
|
+
aria-expanded={panelOpen}
|
|
160
|
+
>
|
|
161
|
+
<span className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
162
|
+
<FlaskConical className="h-3.5 w-3.5" />
|
|
163
|
+
Test script
|
|
164
|
+
{/* When collapsed, surface the last run's outcome as a hint. */}
|
|
165
|
+
{!panelOpen && result !== null && (
|
|
166
|
+
<Badge
|
|
167
|
+
variant={failed ? "destructive" : "secondary"}
|
|
168
|
+
className="font-normal normal-case"
|
|
169
|
+
>
|
|
170
|
+
{failed ? "Failed" : "Success"}
|
|
171
|
+
</Badge>
|
|
172
|
+
)}
|
|
173
|
+
</span>
|
|
174
|
+
{panelOpen ? (
|
|
175
|
+
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
176
|
+
) : (
|
|
177
|
+
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
178
|
+
)}
|
|
179
|
+
</button>
|
|
180
|
+
|
|
181
|
+
{panelOpen && (
|
|
182
|
+
<div className="space-y-3 border-t border-border/60 p-3">
|
|
183
|
+
<div className="flex items-center justify-end">
|
|
184
|
+
<Button
|
|
185
|
+
type="button"
|
|
186
|
+
size="sm"
|
|
187
|
+
variant="secondary"
|
|
188
|
+
onClick={handleRun}
|
|
189
|
+
disabled={disabled || running}
|
|
190
|
+
>
|
|
191
|
+
<Play
|
|
192
|
+
className={cn(
|
|
193
|
+
"h-3.5 w-3.5",
|
|
194
|
+
running && !isLowPower && "animate-pulse",
|
|
195
|
+
)}
|
|
196
|
+
/>
|
|
197
|
+
{running ? "Running…" : "Run"}
|
|
198
|
+
</Button>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
{contextEditor}
|
|
202
|
+
|
|
203
|
+
{secretNames.length > 0 && (
|
|
204
|
+
<div className="space-y-2 rounded-md border border-border/60 bg-card p-3">
|
|
205
|
+
<div className="space-y-0.5">
|
|
206
|
+
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
207
|
+
Secret test overrides
|
|
208
|
+
</span>
|
|
209
|
+
<p className="text-xs text-muted-foreground">
|
|
210
|
+
Optional. Left empty, each secret is injected as a{" "}
|
|
211
|
+
<code className="font-mono">__SECRET_NAME__</code> placeholder.
|
|
212
|
+
Any value you type is a test override only — masked from the
|
|
213
|
+
output and never your real secret.
|
|
214
|
+
</p>
|
|
215
|
+
</div>
|
|
216
|
+
{secretNames.map((name) => (
|
|
217
|
+
<div key={name} className="space-y-1">
|
|
218
|
+
<Label
|
|
219
|
+
htmlFor={`secret-override-${name}`}
|
|
220
|
+
className="font-mono text-xs"
|
|
221
|
+
>
|
|
222
|
+
{name}
|
|
223
|
+
</Label>
|
|
224
|
+
<Input
|
|
225
|
+
id={`secret-override-${name}`}
|
|
226
|
+
type="password"
|
|
227
|
+
value={overrideDrafts[name] ?? ""}
|
|
228
|
+
onChange={(e) =>
|
|
229
|
+
setOverrideDrafts((prev) => ({
|
|
230
|
+
...prev,
|
|
231
|
+
[name]: e.target.value,
|
|
232
|
+
}))
|
|
233
|
+
}
|
|
234
|
+
placeholder={`__SECRET_${name}__`}
|
|
235
|
+
className="font-mono text-sm"
|
|
236
|
+
/>
|
|
237
|
+
</div>
|
|
238
|
+
))}
|
|
239
|
+
</div>
|
|
240
|
+
)}
|
|
241
|
+
|
|
242
|
+
{note !== null && (
|
|
243
|
+
<p className="text-xs text-muted-foreground">{note}</p>
|
|
244
|
+
)}
|
|
245
|
+
|
|
246
|
+
{result !== null && (
|
|
247
|
+
<div className="rounded-md border border-border bg-card">
|
|
248
|
+
<button
|
|
249
|
+
type="button"
|
|
250
|
+
onClick={() => setResultExpanded((prev) => !prev)}
|
|
251
|
+
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left"
|
|
252
|
+
>
|
|
253
|
+
<span className="flex items-center gap-2 text-sm font-medium">
|
|
254
|
+
{failed ? (
|
|
255
|
+
<XCircle className="h-4 w-4 text-destructive" />
|
|
256
|
+
) : (
|
|
257
|
+
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
|
258
|
+
)}
|
|
259
|
+
{failed ? "Failed" : "Success"}
|
|
260
|
+
<Badge variant="secondary" className="font-normal">
|
|
261
|
+
{result.durationMs}ms
|
|
262
|
+
</Badge>
|
|
263
|
+
{result.exitCode !== undefined && (
|
|
264
|
+
<Badge variant="secondary" className="font-normal">
|
|
265
|
+
exit {result.exitCode}
|
|
266
|
+
</Badge>
|
|
267
|
+
)}
|
|
268
|
+
</span>
|
|
269
|
+
{resultExpanded ? (
|
|
270
|
+
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
271
|
+
) : (
|
|
272
|
+
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
273
|
+
)}
|
|
274
|
+
</button>
|
|
275
|
+
|
|
276
|
+
{resultExpanded && (
|
|
277
|
+
<div className="space-y-3 border-t border-border px-3 py-3">
|
|
278
|
+
{result.error !== undefined && (
|
|
279
|
+
<ResultBlock
|
|
280
|
+
label="Error"
|
|
281
|
+
tone="error"
|
|
282
|
+
value={result.error}
|
|
283
|
+
/>
|
|
284
|
+
)}
|
|
285
|
+
{result.result !== undefined && (
|
|
286
|
+
<ResultBlock
|
|
287
|
+
label="Return value"
|
|
288
|
+
value={formatReturnValue(result.result)}
|
|
289
|
+
/>
|
|
290
|
+
)}
|
|
291
|
+
{result.stdout.length > 0 && (
|
|
292
|
+
<ResultBlock label="stdout" value={result.stdout} />
|
|
293
|
+
)}
|
|
294
|
+
{result.stderr.length > 0 && (
|
|
295
|
+
<ResultBlock
|
|
296
|
+
label="stderr"
|
|
297
|
+
tone="error"
|
|
298
|
+
value={result.stderr}
|
|
299
|
+
/>
|
|
300
|
+
)}
|
|
301
|
+
{hasNoOutput(result) && (
|
|
302
|
+
<p className="text-xs italic text-muted-foreground">
|
|
303
|
+
No output.
|
|
304
|
+
</p>
|
|
305
|
+
)}
|
|
306
|
+
</div>
|
|
307
|
+
)}
|
|
308
|
+
</div>
|
|
309
|
+
)}
|
|
310
|
+
</div>
|
|
311
|
+
)}
|
|
312
|
+
</div>
|
|
313
|
+
);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const ResultBlock: React.FC<{
|
|
317
|
+
label: string;
|
|
318
|
+
value: string;
|
|
319
|
+
tone?: "error";
|
|
320
|
+
}> = ({ label, value, tone }) => (
|
|
321
|
+
<div className="space-y-1">
|
|
322
|
+
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
323
|
+
{label}
|
|
324
|
+
</span>
|
|
325
|
+
<pre
|
|
326
|
+
className={cn(
|
|
327
|
+
"max-h-48 overflow-auto rounded-md bg-muted/40 p-2 font-mono text-xs whitespace-pre-wrap break-words",
|
|
328
|
+
tone === "error" && "text-destructive",
|
|
329
|
+
)}
|
|
330
|
+
>
|
|
331
|
+
{value}
|
|
332
|
+
</pre>
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
// ─── Context sample editor ──────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
export interface ContextSampleEditorProps {
|
|
339
|
+
/** Current sample-context JSON string. */
|
|
340
|
+
value: string;
|
|
341
|
+
onChange: (value: string) => void;
|
|
342
|
+
/** Label above the editor. Defaults to "Sample context". */
|
|
343
|
+
label?: string;
|
|
344
|
+
disabled?: boolean;
|
|
345
|
+
/**
|
|
346
|
+
* Optional control rendered on the label row, e.g. a "Load from run"
|
|
347
|
+
* dropdown. The owning page supplies it (it owns the replay RPC); the
|
|
348
|
+
* editor stays plugin-agnostic and just lays it out.
|
|
349
|
+
*/
|
|
350
|
+
runPicker?: React.ReactNode;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Editable JSON sample-context editor for the test panel. Auto-seeding
|
|
355
|
+
* (from the field's schema / IntelliSense context) is the owning page's
|
|
356
|
+
* job - this component just renders + validates the JSON the user edits.
|
|
357
|
+
*
|
|
358
|
+
* Surfaces a parse error inline so an operator can fix malformed JSON
|
|
359
|
+
* before running. The optional `runPicker` slot lets a page add a "Load
|
|
360
|
+
* from run" dropdown that overwrites the sample with a real run's scope.
|
|
361
|
+
*/
|
|
362
|
+
export const ContextSampleEditor: React.FC<ContextSampleEditorProps> = ({
|
|
363
|
+
value,
|
|
364
|
+
onChange,
|
|
365
|
+
label = "Sample context",
|
|
366
|
+
disabled,
|
|
367
|
+
runPicker,
|
|
368
|
+
}) => {
|
|
369
|
+
const parseError = React.useMemo(
|
|
370
|
+
() => validateSampleContextJson(value),
|
|
371
|
+
[value],
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
return (
|
|
375
|
+
<div className="space-y-1.5">
|
|
376
|
+
<div className="flex items-center justify-between gap-3">
|
|
377
|
+
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
378
|
+
{label}
|
|
379
|
+
</span>
|
|
380
|
+
{runPicker}
|
|
381
|
+
</div>
|
|
382
|
+
<CodeEditor
|
|
383
|
+
value={value}
|
|
384
|
+
onChange={onChange}
|
|
385
|
+
language="json"
|
|
386
|
+
minHeight="120px"
|
|
387
|
+
readOnly={disabled}
|
|
388
|
+
/>
|
|
389
|
+
{parseError !== null && (
|
|
390
|
+
<p className="text-xs text-destructive">Invalid JSON: {parseError}</p>
|
|
391
|
+
)}
|
|
392
|
+
</div>
|
|
393
|
+
);
|
|
394
|
+
};
|