@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.
- package/.storybook/main.ts +43 -0
- package/CHANGELOG.md +326 -0
- package/package.json +23 -18
- package/scripts/generate-stdlib-types.ts +23 -0
- package/src/components/Accordion.tsx +17 -9
- package/src/components/ActionCard.tsx +99 -11
- package/src/components/BrandIcon.tsx +57 -0
- package/src/components/CodeEditor/CodeEditor.tsx +159 -14
- package/src/components/CodeEditor/TypefoxEditor.tsx +537 -168
- package/src/components/CodeEditor/editorTheme.test.ts +41 -0
- package/src/components/CodeEditor/editorTheme.ts +26 -0
- 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 +26 -0
- package/src/components/CodeEditor/monacoGuard.ts +76 -0
- package/src/components/CodeEditor/monacoTsService.ts +185 -0
- package/src/components/CodeEditor/popoutTitle.test.ts +37 -0
- package/src/components/CodeEditor/popoutTitle.ts +31 -0
- package/src/components/CodeEditor/scriptContext.test.ts +15 -7
- package/src/components/CodeEditor/scriptContext.ts +12 -18
- package/src/components/CodeEditor/scriptDiagnostics.test.ts +135 -0
- package/src/components/CodeEditor/scriptDiagnostics.ts +172 -0
- package/src/components/CodeEditor/types.ts +79 -0
- package/src/components/CodeEditor/validateScripts.ts +172 -0
- package/src/components/CodeEditor/vscodeServicesSignal.ts +72 -0
- package/src/components/ConfirmationModal.tsx +7 -1
- package/src/components/Dialog.tsx +32 -11
- package/src/components/DurationInput.tsx +121 -0
- package/src/components/DynamicForm/DynamicForm.tsx +119 -47
- package/src/components/DynamicForm/DynamicOptionsField.tsx +19 -14
- package/src/components/DynamicForm/FormField.tsx +183 -15
- package/src/components/DynamicForm/MultiTypeEditorField.tsx +78 -2
- package/src/components/DynamicForm/SecretEnvEditor.tsx +315 -0
- package/src/components/DynamicForm/index.ts +20 -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 +134 -1
- package/src/components/DynamicForm/utils.test.ts +38 -0
- package/src/components/DynamicForm/utils.ts +54 -0
- package/src/components/DynamicForm/validation.logic.test.ts +255 -0
- package/src/components/DynamicForm/validation.logic.ts +210 -0
- package/src/components/DynamicIcon.tsx +39 -17
- package/src/components/Markdown.tsx +68 -2
- 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/Spinner.tsx +56 -0
- package/src/components/StatusBadge.tsx +78 -0
- package/src/components/StrategyConfigCard.tsx +3 -3
- package/src/components/Tabs.tsx +7 -1
- package/src/components/TimeOfDayInput.tsx +116 -0
- package/src/components/UserMenu.logic.test.ts +37 -0
- package/src/components/UserMenu.logic.ts +30 -0
- package/src/components/UserMenu.tsx +40 -12
- package/src/components/comboboxInteraction.ts +39 -0
- package/src/components/iconRegistry.tsx +27 -0
- package/src/components/portalContainer.ts +24 -0
- package/src/index.ts +7 -0
- package/stories/ActionCard.stories.tsx +60 -0
- package/stories/CodeEditor.stories.tsx +47 -2
- package/stories/DurationInput.stories.tsx +59 -0
- package/stories/Introduction.mdx +1 -1
- package/stories/Markdown.stories.tsx +56 -0
- package/stories/ScriptTestPanel.stories.tsx +106 -0
- package/stories/SecretEnvEditor.stories.tsx +80 -0
- package/stories/Spinner.stories.tsx +90 -0
- package/stories/TimeOfDayInput.stories.tsx +34 -0
- 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 {
|
|
2
|
-
import { Settings } from "lucide-react";
|
|
3
|
-
import type {
|
|
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
|
|
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
|
-
/**
|
|
13
|
-
|
|
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
|
|
22
|
-
*
|
|
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="
|
|
26
|
-
* <DynamicIcon name="
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|