@checkstack/ui 1.11.0 → 1.13.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 (71) hide show
  1. package/.storybook/main.ts +43 -0
  2. package/CHANGELOG.md +326 -0
  3. package/package.json +23 -18
  4. package/scripts/generate-stdlib-types.ts +23 -0
  5. package/src/components/Accordion.tsx +17 -9
  6. package/src/components/ActionCard.tsx +99 -11
  7. package/src/components/BrandIcon.tsx +57 -0
  8. package/src/components/CodeEditor/CodeEditor.tsx +159 -14
  9. package/src/components/CodeEditor/TypefoxEditor.tsx +537 -168
  10. package/src/components/CodeEditor/editorTheme.test.ts +41 -0
  11. package/src/components/CodeEditor/editorTheme.ts +26 -0
  12. package/src/components/CodeEditor/generated/builtin-modules.json +1 -0
  13. package/src/components/CodeEditor/importSpecifiers.test.ts +286 -0
  14. package/src/components/CodeEditor/importSpecifiers.ts +267 -0
  15. package/src/components/CodeEditor/index.ts +26 -0
  16. package/src/components/CodeEditor/monacoGuard.ts +76 -0
  17. package/src/components/CodeEditor/monacoTsService.ts +185 -0
  18. package/src/components/CodeEditor/popoutTitle.test.ts +37 -0
  19. package/src/components/CodeEditor/popoutTitle.ts +31 -0
  20. package/src/components/CodeEditor/scriptContext.test.ts +15 -7
  21. package/src/components/CodeEditor/scriptContext.ts +12 -18
  22. package/src/components/CodeEditor/scriptDiagnostics.test.ts +135 -0
  23. package/src/components/CodeEditor/scriptDiagnostics.ts +172 -0
  24. package/src/components/CodeEditor/types.ts +79 -0
  25. package/src/components/CodeEditor/validateScripts.ts +172 -0
  26. package/src/components/CodeEditor/vscodeServicesSignal.ts +72 -0
  27. package/src/components/ConfirmationModal.tsx +7 -1
  28. package/src/components/Dialog.tsx +32 -11
  29. package/src/components/DurationInput.tsx +121 -0
  30. package/src/components/DynamicForm/DynamicForm.tsx +119 -47
  31. package/src/components/DynamicForm/DynamicOptionsField.tsx +19 -14
  32. package/src/components/DynamicForm/FormField.tsx +183 -15
  33. package/src/components/DynamicForm/MultiTypeEditorField.tsx +78 -2
  34. package/src/components/DynamicForm/SecretEnvEditor.tsx +315 -0
  35. package/src/components/DynamicForm/index.ts +20 -0
  36. package/src/components/DynamicForm/secretEnv.logic.test.ts +126 -0
  37. package/src/components/DynamicForm/secretEnv.logic.ts +87 -0
  38. package/src/components/DynamicForm/types.ts +134 -1
  39. package/src/components/DynamicForm/utils.test.ts +38 -0
  40. package/src/components/DynamicForm/utils.ts +54 -0
  41. package/src/components/DynamicForm/validation.logic.test.ts +255 -0
  42. package/src/components/DynamicForm/validation.logic.ts +210 -0
  43. package/src/components/DynamicIcon.tsx +39 -17
  44. package/src/components/Markdown.tsx +68 -2
  45. package/src/components/Popover.tsx +6 -1
  46. package/src/components/ScriptTestPanel.logic.test.ts +139 -0
  47. package/src/components/ScriptTestPanel.logic.ts +137 -0
  48. package/src/components/ScriptTestPanel.tsx +394 -0
  49. package/src/components/Sheet.tsx +21 -6
  50. package/src/components/Spinner.tsx +56 -0
  51. package/src/components/StatusBadge.tsx +78 -0
  52. package/src/components/StrategyConfigCard.tsx +3 -3
  53. package/src/components/Tabs.tsx +7 -1
  54. package/src/components/TimeOfDayInput.tsx +116 -0
  55. package/src/components/UserMenu.logic.test.ts +37 -0
  56. package/src/components/UserMenu.logic.ts +30 -0
  57. package/src/components/UserMenu.tsx +40 -12
  58. package/src/components/comboboxInteraction.ts +39 -0
  59. package/src/components/iconRegistry.tsx +27 -0
  60. package/src/components/portalContainer.ts +24 -0
  61. package/src/index.ts +7 -0
  62. package/stories/ActionCard.stories.tsx +60 -0
  63. package/stories/CodeEditor.stories.tsx +47 -2
  64. package/stories/DurationInput.stories.tsx +59 -0
  65. package/stories/Introduction.mdx +1 -1
  66. package/stories/Markdown.stories.tsx +56 -0
  67. package/stories/ScriptTestPanel.stories.tsx +106 -0
  68. package/stories/SecretEnvEditor.stories.tsx +80 -0
  69. package/stories/Spinner.stories.tsx +90 -0
  70. package/stories/TimeOfDayInput.stories.tsx +34 -0
  71. package/tsconfig.json +4 -0
@@ -0,0 +1,210 @@
1
+ import { z } from "zod";
2
+
3
+ import type { JsonSchema, JsonSchemaProperty } from "./types";
4
+ import { isValueEmpty, isFieldHiddenByCondition } from "./utils";
5
+
6
+ /**
7
+ * A map from a field path to a human-readable validation message. The path
8
+ * is dot-joined (e.g. `spendCap.tokenBudget`) so it lines up with the keys
9
+ * DynamicForm renders for nested object fields. The top-level segment is
10
+ * always the form field DynamicForm shows; nested segments identify the
11
+ * offending sub-field for the message text.
12
+ */
13
+ export type FieldErrorMap = Record<string, string>;
14
+
15
+ /**
16
+ * Zod schema for a single zod issue as it crosses the oRPC boundary. The
17
+ * backend attaches the original `ZodError.issues` array to the
18
+ * `ORPCError.data` payload; oRPC serializes it to JSON and the client
19
+ * deserializes it, so we re-parse it here rather than trusting its shape.
20
+ * Only the fields we actually consume are required.
21
+ */
22
+ const serverZodIssueSchema = z.object({
23
+ path: z.array(z.union([z.string(), z.number()])),
24
+ message: z.string(),
25
+ });
26
+
27
+ /**
28
+ * Zod schema for the structured validation payload the backend places on
29
+ * `ORPCError.data` for connection-config validation failures. `code`
30
+ * discriminates this payload from any other structured error data so we
31
+ * never mis-map an unrelated error onto form fields.
32
+ */
33
+ export const serverValidationDataSchema = z.object({
34
+ code: z.literal("CONFIG_VALIDATION"),
35
+ issues: z.array(serverZodIssueSchema),
36
+ });
37
+
38
+ export type ServerValidationData = z.infer<typeof serverValidationDataSchema>;
39
+
40
+ /**
41
+ * Parse an unknown error payload (typically `error.data` from an oRPC
42
+ * `ORPCError`) into the structured connection-config validation shape.
43
+ * Returns `undefined` when the payload is not structured config-validation
44
+ * data, signalling the caller to fall back to a toast/banner.
45
+ */
46
+ export function parseServerValidationData(
47
+ data: unknown,
48
+ ): ServerValidationData | undefined {
49
+ const result = serverValidationDataSchema.safeParse(data);
50
+ return result.success ? result.data : undefined;
51
+ }
52
+
53
+ /**
54
+ * Map a structured server validation payload onto DynamicForm field paths.
55
+ *
56
+ * Each zod issue's `path` is joined with dots to form a field path. Only
57
+ * issues whose first path segment names a property that actually exists in
58
+ * the rendered schema are considered "mappable"; everything else (empty
59
+ * paths, unknown roots) is reported back as `unmapped` so the caller can
60
+ * surface it via the existing toast/banner instead of silently dropping it.
61
+ *
62
+ * When multiple issues target the same path, the first one wins (zod emits
63
+ * the most specific issue first for a given location).
64
+ */
65
+ export function deriveServerFieldErrors({
66
+ issues,
67
+ schema,
68
+ }: {
69
+ issues: ServerValidationData["issues"];
70
+ schema: JsonSchema;
71
+ }): { mapped: FieldErrorMap; unmapped: string[] } {
72
+ const mapped: FieldErrorMap = {};
73
+ const unmapped: string[] = [];
74
+ const properties = schema.properties ?? {};
75
+
76
+ for (const issue of issues) {
77
+ const root = issue.path[0];
78
+ const rootKey = typeof root === "string" ? root : undefined;
79
+
80
+ if (rootKey === undefined || !(rootKey in properties)) {
81
+ unmapped.push(issue.message);
82
+ continue;
83
+ }
84
+
85
+ const fieldPath = issue.path.map(String).join(".");
86
+
87
+ if (mapped[fieldPath] === undefined) {
88
+ mapped[fieldPath] = issue.message;
89
+ }
90
+ }
91
+
92
+ return { mapped, unmapped };
93
+ }
94
+
95
+ /**
96
+ * Compute client-side validation problems from the JSON schema and the
97
+ * current form values. Mirrors the validity computation DynamicForm already
98
+ * does for `onValidChange`, but returns a per-field message map instead of a
99
+ * single boolean so the offending fields can be flagged inline. The form is
100
+ * considered valid exactly when this map is empty, so the same call drives
101
+ * both the inline errors and the `onValidChange` boolean.
102
+ *
103
+ * At minimum this flags empty REQUIRED fields (reusing {@link isValueEmpty}
104
+ * so the notion of "empty" stays in lock-step). Hidden and
105
+ * conditionally-hidden required fields are skipped because the user cannot
106
+ * fill them. Optional empty fields never produce an error.
107
+ *
108
+ * `keepExistingSecretFields` lists `x-secret` field keys whose value is
109
+ * already stored server-side (edit mode). A blank input on such a field
110
+ * means "keep the existing secret" - the redacted preview never returns the
111
+ * value, so an empty input is expected and MUST count as valid rather than a
112
+ * missing-required error. A blank `x-secret` field NOT in this set (create
113
+ * mode, or a secret that was never set) is still treated as missing-required.
114
+ */
115
+ export function deriveClientFieldErrors({
116
+ schema,
117
+ value,
118
+ keepExistingSecretFields = [],
119
+ }: {
120
+ schema: JsonSchema;
121
+ value: Record<string, unknown>;
122
+ keepExistingSecretFields?: string[];
123
+ }): FieldErrorMap {
124
+ const errors: FieldErrorMap = {};
125
+ const properties = schema.properties;
126
+ if (!properties) return errors;
127
+
128
+ const keepExisting = new Set(keepExistingSecretFields);
129
+ const requiredKeys = schema.required ?? [];
130
+
131
+ for (const key of requiredKeys) {
132
+ const propSchema: JsonSchemaProperty | undefined = properties[key];
133
+ if (!propSchema) continue;
134
+
135
+ // Hidden fields are auto-populated; the user cannot act on them.
136
+ if (propSchema["x-hidden"]) continue;
137
+
138
+ const hiddenWhen = propSchema["x-hidden-when"];
139
+ if (hiddenWhen && isFieldHiddenByCondition(hiddenWhen, value)) continue;
140
+
141
+ if (!isValueEmpty(value[key], propSchema)) continue;
142
+
143
+ // A blank secret that already has a stored value is "keep existing",
144
+ // not missing-required.
145
+ if (propSchema["x-secret"] === true && keepExisting.has(key)) continue;
146
+
147
+ errors[key] = `${labelForKey(key)} is required.`;
148
+ }
149
+
150
+ return errors;
151
+ }
152
+
153
+ /**
154
+ * Strip blank `x-secret` fields that are flagged keep-existing from a config
155
+ * object before submit, so an empty input does not overwrite (clear) the
156
+ * stored secret. The update path treats an absent secret key as "leave the
157
+ * stored value untouched"; sending `""` would clear it. CREATE mode passes an
158
+ * empty `keepExistingSecretFields`, so nothing is stripped there.
159
+ */
160
+ export function omitKeepExistingSecrets({
161
+ schema,
162
+ value,
163
+ keepExistingSecretFields,
164
+ }: {
165
+ schema: JsonSchema;
166
+ value: Record<string, unknown>;
167
+ keepExistingSecretFields: string[];
168
+ }): Record<string, unknown> {
169
+ const properties = schema.properties;
170
+ if (!properties || keepExistingSecretFields.length === 0) return value;
171
+
172
+ const keepExisting = new Set(keepExistingSecretFields);
173
+ const result: Record<string, unknown> = {};
174
+
175
+ for (const [key, fieldValue] of Object.entries(value)) {
176
+ const propSchema = properties[key];
177
+ const isBlankKeepExistingSecret =
178
+ propSchema?.["x-secret"] === true &&
179
+ keepExisting.has(key) &&
180
+ isValueEmpty(fieldValue, propSchema);
181
+ if (isBlankKeepExistingSecret) continue;
182
+ result[key] = fieldValue;
183
+ }
184
+
185
+ return result;
186
+ }
187
+
188
+ /**
189
+ * List the top-level `x-secret` field keys declared in a schema. The owning
190
+ * page uses this in EDIT mode to mark every secret field as keep-existing
191
+ * (their stored values are redacted out of the loaded preview, so a blank
192
+ * input must mean "keep existing" rather than "clear"). CREATE mode passes
193
+ * an empty list so blank secrets stay genuinely required.
194
+ */
195
+ export function listSecretFieldKeys(schema: JsonSchema): string[] {
196
+ const properties = schema.properties;
197
+ if (!properties) return [];
198
+ return Object.entries(properties)
199
+ .filter(([, propSchema]) => propSchema["x-secret"] === true)
200
+ .map(([key]) => key);
201
+ }
202
+
203
+ /**
204
+ * Derive the human-readable label DynamicForm renders for a field key
205
+ * (capitalized first letter), kept in sync with the label logic in
206
+ * `DynamicForm.tsx` so inline messages read naturally.
207
+ */
208
+ function labelForKey(key: string): string {
209
+ return key.charAt(0).toUpperCase() + key.slice(1);
210
+ }
@@ -1,29 +1,46 @@
1
- import { icons, type LucideIcon } from "lucide-react";
2
- import { Settings } from "lucide-react";
3
- import type { LucideIconName } from "@checkstack/common";
1
+ import { lazy, Suspense } from "react";
2
+ import { Settings, type LucideIcon } from "lucide-react";
3
+ import type { IconName, BrandIconName } from "@checkstack/common";
4
+ import { brandIcons } from "./BrandIcon";
4
5
 
5
- // Re-export the type for convenience
6
- export type { LucideIconName } from "@checkstack/common";
6
+ // Re-export the icon-name types for convenience.
7
+ export type { IconName, LucideIconName } from "@checkstack/common";
7
8
 
8
9
  /**
9
10
  * Props for the DynamicIcon component
10
11
  */
11
12
  export interface DynamicIconProps {
12
- /** Lucide icon name in PascalCase (e.g., 'AlertCircle', 'HeartPulse') */
13
- name?: LucideIconName;
13
+ /**
14
+ * Icon name. Lucide names are PascalCase (e.g. 'CircleAlert', 'HeartPulse');
15
+ * the few brand names lucide v1 dropped ('Github', 'Gitlab') resolve to
16
+ * vendored marks.
17
+ */
18
+ name?: IconName;
14
19
  /** CSS class name to apply to the icon */
15
20
  className?: string;
16
21
  /** Fallback icon if name is not provided */
17
22
  fallback?: LucideIcon;
18
23
  }
19
24
 
25
+ // The lucide icon set is loaded lazily through `iconRegistry` (see that file):
26
+ // importing lucide's `icons` map eagerly would ship the entire ~1600-icon set
27
+ // in the initial bundle. `RegistryIcon` is the only importer of that map, and
28
+ // it's behind React.lazy, so the icon set is fetched on demand the first time a
29
+ // data-driven icon renders - never in the initial load.
30
+ const RegistryIcon = lazy(() => import("./iconRegistry"));
31
+
32
+ function isBrandIcon(name: string): name is BrandIconName {
33
+ return Object.prototype.hasOwnProperty.call(brandIcons, name);
34
+ }
35
+
20
36
  /**
21
- * Dynamically renders a Lucide icon by name.
22
- * Falls back to Settings icon if the icon name is not provided.
37
+ * Dynamically renders an icon by name. Lucide icons are code-split into a single
38
+ * on-demand chunk; vendored brand icons render synchronously. Falls back to the
39
+ * Settings icon if no name is provided.
23
40
  *
24
41
  * @example
25
- * <DynamicIcon name="AlertCircle" />
26
- * <DynamicIcon name="HeartPulse" className="h-6 w-6" />
42
+ * <DynamicIcon name="CircleAlert" />
43
+ * <DynamicIcon name="Github" className="h-6 w-6" />
27
44
  */
28
45
  export function DynamicIcon({
29
46
  name,
@@ -34,12 +51,17 @@ export function DynamicIcon({
34
51
  return <FallbackIcon className={className} />;
35
52
  }
36
53
 
37
- const Icon = icons[name];
38
-
39
- // Fallback if icon name doesn't exist in lucide-react
40
- if (!Icon) {
41
- return <FallbackIcon className={className} />;
54
+ // Brand marks lucide v1 removed - render the vendored component synchronously.
55
+ if (isBrandIcon(name)) {
56
+ const Brand = brandIcons[name];
57
+ return <Brand className={className} />;
42
58
  }
43
59
 
44
- return <Icon className={className} />;
60
+ return (
61
+ // Invisible, correctly-sized placeholder while the icon chunk loads, so
62
+ // there's no layout shift and no flash of a wrong (fallback) icon.
63
+ <Suspense fallback={<span aria-hidden className={className} />}>
64
+ <RegistryIcon name={name} className={className} />
65
+ </Suspense>
66
+ );
45
67
  }
@@ -1,7 +1,36 @@
1
1
  import ReactMarkdown from "react-markdown";
2
2
  import type { Components } from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+ import rehypeRaw from "rehype-raw";
5
+ import rehypeSanitize from "rehype-sanitize";
3
6
  import { cn } from "../utils";
4
7
 
8
+ /**
9
+ * Allow a SAFE subset of raw HTML in rendered markdown so model output that uses
10
+ * native disclosure widgets (`<details>` / `<summary>` for a foldable diff) and
11
+ * other plain formatting renders as intended instead of leaking the literal
12
+ * tags as text. `rehype-raw` parses the embedded HTML; `rehype-sanitize` then
13
+ * strips anything unsafe (scripts, event handlers, `javascript:` URLs, ...) - its
14
+ * default schema already allow-lists `details`/`summary` plus the usual markdown
15
+ * elements, so the XSS surface stays closed even though chat content is
16
+ * model-generated. Order matters: raw MUST run before sanitize.
17
+ */
18
+ const rehypePlugins = [rehypeRaw, rehypeSanitize];
19
+
20
+ /** Shared `<details>`/`<summary>` renderers: a styled, click-to-expand fold. */
21
+ const disclosureComponents: Pick<Components, "details" | "summary"> = {
22
+ details: ({ children }) => (
23
+ <details className="my-2 rounded-md border border-border bg-muted/40 px-3 py-2 [&[open]>summary]:mb-2">
24
+ {children}
25
+ </details>
26
+ ),
27
+ summary: ({ children }) => (
28
+ <summary className="cursor-pointer select-none font-medium text-foreground marker:text-muted-foreground">
29
+ {children}
30
+ </summary>
31
+ ),
32
+ };
33
+
5
34
  export interface MarkdownProps {
6
35
  /** The markdown content to render */
7
36
  children: string;
@@ -69,11 +98,20 @@ export function Markdown({
69
98
  del: ({ children }) => (
70
99
  <del className="line-through text-muted-foreground">{children}</del>
71
100
  ),
101
+
102
+ // Collapsible disclosure (e.g. a model-emitted "view diff" fold)
103
+ ...disclosureComponents,
72
104
  };
73
105
 
74
106
  return (
75
107
  <span className={cn(sizeClasses[size], className)}>
76
- <ReactMarkdown components={components}>{children}</ReactMarkdown>
108
+ <ReactMarkdown
109
+ remarkPlugins={[remarkGfm]}
110
+ rehypePlugins={rehypePlugins}
111
+ components={components}
112
+ >
113
+ {children}
114
+ </ReactMarkdown>
77
115
  </span>
78
116
  );
79
117
  }
@@ -190,6 +228,28 @@ export function MarkdownBlock({
190
228
  del: ({ children }) => (
191
229
  <del className="line-through text-muted-foreground">{children}</del>
192
230
  ),
231
+
232
+ // GFM tables (enabled via remark-gfm). Styled to the design tokens; the
233
+ // wrapper scrolls horizontally so a wide table never overflows its bubble.
234
+ table: ({ children }) => (
235
+ <div className="mb-4 overflow-x-auto">
236
+ <table className="w-full border-collapse text-sm">{children}</table>
237
+ </div>
238
+ ),
239
+ thead: ({ children }) => <thead className="bg-muted">{children}</thead>,
240
+ tbody: ({ children }) => <tbody>{children}</tbody>,
241
+ tr: ({ children }) => <tr className="border-b border-border">{children}</tr>,
242
+ th: ({ children }) => (
243
+ <th className="border border-border px-2 py-1 text-left font-semibold text-foreground">
244
+ {children}
245
+ </th>
246
+ ),
247
+ td: ({ children }) => (
248
+ <td className="border border-border px-2 py-1 align-top">{children}</td>
249
+ ),
250
+
251
+ // Collapsible disclosure (e.g. a model-emitted "view diff" fold)
252
+ ...disclosureComponents,
193
253
  };
194
254
 
195
255
  return (
@@ -200,7 +260,13 @@ export function MarkdownBlock({
200
260
  className
201
261
  )}
202
262
  >
203
- <ReactMarkdown components={components}>{children}</ReactMarkdown>
263
+ <ReactMarkdown
264
+ remarkPlugins={[remarkGfm]}
265
+ rehypePlugins={rehypePlugins}
266
+ components={components}
267
+ >
268
+ {children}
269
+ </ReactMarkdown>
204
270
  </div>
205
271
  );
206
272
  }
@@ -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
+ }