@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
@@ -1,4 +1,10 @@
1
- import type { TemplateProperty, ShellEnvVar } from "../CodeEditor";
1
+ import type React from "react";
2
+ import type {
3
+ TemplateProperty,
4
+ ShellEnvVar,
5
+ AcquireTypes,
6
+ AcquiredTypeFile,
7
+ } from "../CodeEditor";
2
8
  import type { TemplateCompletionProvider } from "../TemplateValueInput";
3
9
  import type { EditorType } from "@checkstack/common";
4
10
 
@@ -11,6 +17,7 @@ import type {
11
17
  JsonSchemaPropertyCore,
12
18
  JsonSchemaBase,
13
19
  } from "@checkstack/common";
20
+ import type { FieldErrorMap } from "./validation.logic";
14
21
 
15
22
  /**
16
23
  * JSON Schema property with DynamicForm-specific x-* extensions for config rendering.
@@ -26,8 +33,48 @@ export interface JsonSchemaProperty extends JsonSchemaPropertyCore<JsonSchemaPro
26
33
  "x-searchable"?: boolean; // Shows search input for filtering dropdown options
27
34
  "x-editor-types"?: EditorType[]; // Available editor types for multi-type input
28
35
  "x-hidden-when"?: Record<string, string[]>; // Conditionally hide based on sibling field values
36
+ "x-duration"?: boolean; // Render a DurationInput (single-unit duration object)
37
+ "x-script-testable"?: boolean; // Field is an inline script that can be tested in-UI
38
+ "x-secret-env"?: boolean; // Record field is a secret -> env mapping (SecretEnvEditor)
39
+ "x-templatable"?: boolean; // String value is rendered through the template engine at run time
29
40
  }
30
41
 
42
+ /**
43
+ * Sample context used to preview the rendered output of `x-templatable`
44
+ * string fields in the editor (e.g. `{ environment: { baseUrl: "..." } }`).
45
+ * When supplied to DynamicForm, a "Preview" line appears below each
46
+ * templatable field showing `renderTemplatePreview(value, context)` so authors
47
+ * see the resolved value while editing. Shares the run-time render semantics
48
+ * (see `@checkstack/template-engine`), so the preview never diverges.
49
+ */
50
+ export type TemplatePreviewContext = Record<string, unknown>;
51
+
52
+ /**
53
+ * Renders the inline script-test UI beneath a testable script field. The
54
+ * owning feature page supplies this; it owns the RPC call + sample-context
55
+ * state and typically renders a `ScriptTestPanel`. The form only decides
56
+ * *where* it appears (below any `x-script-testable` field whose selected
57
+ * editor type is a code language). Mirrors the callback-prop pattern used
58
+ * by `optionsResolvers` / `templateCompletionProvider`.
59
+ */
60
+ export type ScriptTestRenderer = (args: {
61
+ /** The field's id (form key). */
62
+ fieldId: string;
63
+ /** Editor language currently selected in the field. */
64
+ kind: "typescript" | "shell";
65
+ /** Current script source in the field. */
66
+ script: string;
67
+ /**
68
+ * The current value of the SIBLING secret→env mapping field (the field
69
+ * annotated `x-secret-env` within the same config object as the script),
70
+ * if any. DynamicForm locates it by the annotation — not by name — so the
71
+ * test panel can inject `__SECRET_<NAME>__` placeholders (or the operator's
72
+ * overrides) for the same secrets the real action declares. `undefined`
73
+ * when the config has no `x-secret-env` field or it's empty.
74
+ */
75
+ secretEnv?: Record<string, string>;
76
+ }) => React.ReactNode;
77
+
31
78
  /** Option returned by an options resolver */
32
79
  export interface ResolverOption {
33
80
  value: string;
@@ -98,6 +145,77 @@ export interface DynamicFormProps {
98
145
  * fields with a working example. Keyed by `EditorType`.
99
146
  */
100
147
  starterTemplates?: EditorStarterTemplates;
148
+ /**
149
+ * Optional renderer for the inline script-test panel. When supplied,
150
+ * fields flagged `x-script-testable` (whose selected editor type is a
151
+ * code language) render this beneath the editor so operators can run
152
+ * the script against a sample context. Omit it and no test UI appears.
153
+ */
154
+ scriptTestRenderer?: ScriptTestRenderer;
155
+ /**
156
+ * Optional list of secret NAMES (never values) for `x-secret-env` record
157
+ * fields. The owning page fetches these from the secrets plugin's
158
+ * `listSecretNames` and passes them here so the secret-env editor offers
159
+ * name autocomplete. Omit it and the editor still works as free text.
160
+ */
161
+ secretNames?: string[];
162
+ /**
163
+ * Optional lazy type-acquisition resolver forwarded to TS/JS editor-type
164
+ * fields. When supplied, the editor fetches + registers the `.d.ts` of any
165
+ * npm package the script imports, so `import { x } from "pkg"`
166
+ * autocompletes. Injected by the owning page (see
167
+ * `@checkstack/script-packages-frontend`).
168
+ */
169
+ acquireTypes?: AcquireTypes;
170
+ /** Install identity (lockfile hash); resets acquired types on a new install. */
171
+ acquireResetKey?: string;
172
+ /**
173
+ * The running release's `@checkstack/sdk` editor bundle, forwarded to TS/JS
174
+ * editors so `import { defineHealthCheck } from "@checkstack/sdk/healthcheck"`
175
+ * resolves with real, version-matched types.
176
+ */
177
+ sdkTypes?: ReadonlyArray<AcquiredTypeFile>;
178
+ /** Release version; resets the mounted SDK libs on a deployment upgrade. */
179
+ sdkTypesResetKey?: string;
180
+ /**
181
+ * Importable installed package names (already `@types/*`-free), forwarded to
182
+ * TS/JS editors so the import specifier itself autocompletes
183
+ * (`import {} from "lod"` -> `lodash`).
184
+ */
185
+ importablePackages?: string[];
186
+ /**
187
+ * Optional sample context for previewing `x-templatable` fields. When
188
+ * supplied, a "Preview" line appears below each templatable string field
189
+ * showing the rendered output against this context (e.g. a sample
190
+ * environment's custom fields). Omit it and templatable fields render
191
+ * normally with no preview.
192
+ */
193
+ templatePreviewContext?: TemplatePreviewContext;
194
+ /**
195
+ * Opt-in. When `true`, the form shows inline per-field validation messages
196
+ * (for empty required fields) REACTIVELY once a field has been touched and
197
+ * blurred, so the user can see WHY the submit button is disabled without
198
+ * the form nagging while they are still typing. Defaults to `false`, so
199
+ * every existing consumer is visually unchanged unless it opts in.
200
+ */
201
+ showInlineErrors?: boolean;
202
+ /**
203
+ * Optional externally-supplied per-field error messages, keyed by field
204
+ * path (dot-joined for nested object fields, e.g. `spendCap.tokenBudget`).
205
+ * Use this to surface SERVER validation failures inline on the offending
206
+ * field. These render whenever present, independent of the touched-state
207
+ * gate. Omit it (the default) and no external errors show.
208
+ */
209
+ fieldErrors?: FieldErrorMap;
210
+ /**
211
+ * Optional list of `x-secret` field keys whose value is already stored
212
+ * server-side (EDIT mode). A blank input on such a field means "keep the
213
+ * existing secret" and is therefore VALID, not missing-required - the
214
+ * redacted preview never returns the value, so the input is expected to be
215
+ * empty. CREATE mode omits this (or passes `[]`) so blank required secrets
216
+ * stay invalid. Drives both the validity boolean and the inline errors.
217
+ */
218
+ keepExistingSecretFields?: string[];
101
219
  }
102
220
 
103
221
  /** Props for the FormField component */
@@ -114,6 +232,21 @@ export interface FormFieldProps {
114
232
  typeDefinitions?: string;
115
233
  shellEnvVars?: ShellEnvVar[];
116
234
  starterTemplates?: EditorStarterTemplates;
235
+ scriptTestRenderer?: ScriptTestRenderer;
236
+ secretNames?: string[];
237
+ acquireTypes?: AcquireTypes;
238
+ acquireResetKey?: string;
239
+ sdkTypes?: ReadonlyArray<AcquiredTypeFile>;
240
+ sdkTypesResetKey?: string;
241
+ importablePackages?: string[];
242
+ /** Sample context for previewing `x-templatable` fields. */
243
+ templatePreviewContext?: TemplatePreviewContext;
244
+ /**
245
+ * Current value of the sibling `x-secret-env` mapping field within the
246
+ * SAME config object as this field, located by annotation. Threaded down
247
+ * so a testable script field can forward it to {@link ScriptTestRenderer}.
248
+ */
249
+ siblingSecretEnv?: Record<string, string>;
117
250
  /** Callback when value changes. Omit val to clear the field. */
118
251
  onChange: (val?: unknown) => void;
119
252
  }
@@ -5,6 +5,7 @@ import {
5
5
  extractDefaults,
6
6
  getCleanDescription,
7
7
  isValueEmpty,
8
+ nestedChildrenRequired,
8
9
  isFieldHiddenByCondition,
9
10
  NONE_SENTINEL,
10
11
  parseSelectValue,
@@ -201,6 +202,43 @@ describe("isValueEmpty", () => {
201
202
  });
202
203
  });
203
204
 
205
+ describe("nestedChildrenRequired", () => {
206
+ it("marks children of a REQUIRED object regardless of value", () => {
207
+ expect(
208
+ nestedChildrenRequired({ objectRequired: true, objectValue: undefined }),
209
+ ).toBe(true);
210
+ expect(
211
+ nestedChildrenRequired({ objectRequired: true, objectValue: {} }),
212
+ ).toBe(true);
213
+ });
214
+
215
+ it("does NOT mark children of an OPTIONAL object that is empty/unset", () => {
216
+ // The spend-cap case: empty optional object -> no required `*`.
217
+ expect(
218
+ nestedChildrenRequired({ objectRequired: false, objectValue: undefined }),
219
+ ).toBe(false);
220
+ expect(
221
+ nestedChildrenRequired({ objectRequired: false, objectValue: {} }),
222
+ ).toBe(false);
223
+ expect(
224
+ nestedChildrenRequired({
225
+ objectRequired: false,
226
+ objectValue: { tokenBudget: "", windowMinutes: "" },
227
+ }),
228
+ ).toBe(false);
229
+ });
230
+
231
+ it("marks children of an OPTIONAL object once it is being provided", () => {
232
+ // Operator started filling the cap -> guide completion with `*`.
233
+ expect(
234
+ nestedChildrenRequired({
235
+ objectRequired: false,
236
+ objectValue: { tokenBudget: 1000, windowMinutes: "" },
237
+ }),
238
+ ).toBe(true);
239
+ });
240
+ });
241
+
204
242
  describe("NONE_SENTINEL", () => {
205
243
  it("is a specific string constant", () => {
206
244
  expect(NONE_SENTINEL).toBe("__none__");
@@ -69,6 +69,60 @@ export function isValueEmpty(
69
69
  return false;
70
70
  }
71
71
 
72
+ /**
73
+ * Whether a nested object's schema-required children should display the
74
+ * required `*` marker. A REQUIRED nested object always marks its required
75
+ * children. An OPTIONAL nested object (e.g. an opt-in spend cap) only marks
76
+ * them once the operator is actually providing the object (any child has a
77
+ * non-empty value) — while it is empty, supplying it is optional, so its
78
+ * children must not show `*` (the form is valid without any of them).
79
+ */
80
+ export function nestedChildrenRequired({
81
+ objectRequired,
82
+ objectValue,
83
+ }: {
84
+ objectRequired: boolean;
85
+ objectValue: unknown;
86
+ }): boolean {
87
+ if (objectRequired) return true;
88
+ if (objectValue === null || typeof objectValue !== "object") return false;
89
+ return Object.values(objectValue as Record<string, unknown>).some(
90
+ (entry) => entry !== undefined && entry !== null && entry !== "",
91
+ );
92
+ }
93
+
94
+ /**
95
+ * Locate the value of the secret→env mapping field within an object's
96
+ * properties by the `x-secret-env` annotation (NOT by a hard-coded field
97
+ * name), and return it. Used to feed the inline script-test panel the same
98
+ * `secretEnv` the sibling action declares, so a test injects placeholders /
99
+ * overrides for those secrets. Returns `undefined` when no `x-secret-env`
100
+ * field exists or its value isn't a record.
101
+ */
102
+ export function findSecretEnvSibling({
103
+ properties,
104
+ values,
105
+ }: {
106
+ properties: Record<string, JsonSchemaProperty> | undefined;
107
+ values: Record<string, unknown> | undefined;
108
+ }): Record<string, string> | undefined {
109
+ if (!properties || !values) return undefined;
110
+ for (const [key, propSchema] of Object.entries(properties)) {
111
+ if (propSchema["x-secret-env"] === true) {
112
+ const value = values[key];
113
+ if (value && typeof value === "object" && !Array.isArray(value)) {
114
+ const record: Record<string, string> = {};
115
+ for (const [k, v] of Object.entries(value)) {
116
+ if (typeof v === "string") record[k] = v;
117
+ }
118
+ return record;
119
+ }
120
+ return undefined;
121
+ }
122
+ }
123
+ return undefined;
124
+ }
125
+
72
126
  /** Sentinel value used to represent "None" selection in Select components */
73
127
  export const NONE_SENTINEL = "__none__";
74
128
 
@@ -0,0 +1,255 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import {
4
+ deriveClientFieldErrors,
5
+ deriveServerFieldErrors,
6
+ parseServerValidationData,
7
+ omitKeepExistingSecrets,
8
+ listSecretFieldKeys,
9
+ } from "./validation.logic";
10
+ import type { JsonSchema } from "./types";
11
+
12
+ const connectionSchema: JsonSchema = {
13
+ type: "object",
14
+ required: ["apiKey", "baseUrl"],
15
+ properties: {
16
+ apiKey: { type: "string", "x-secret": true },
17
+ baseUrl: { type: "string" },
18
+ organization: { type: "string" },
19
+ spendCap: {
20
+ type: "object",
21
+ required: ["tokenBudget"],
22
+ properties: {
23
+ tokenBudget: { type: "number" },
24
+ },
25
+ },
26
+ },
27
+ };
28
+
29
+ describe("deriveClientFieldErrors", () => {
30
+ it("flags empty required fields", () => {
31
+ const errors = deriveClientFieldErrors({
32
+ schema: connectionSchema,
33
+ value: { apiKey: "", baseUrl: undefined },
34
+ });
35
+ expect(errors.apiKey).toBeDefined();
36
+ expect(errors.baseUrl).toBeDefined();
37
+ expect(errors.apiKey).toContain("required");
38
+ });
39
+
40
+ it("returns no error for filled required fields", () => {
41
+ const errors = deriveClientFieldErrors({
42
+ schema: connectionSchema,
43
+ value: { apiKey: "sk-123", baseUrl: "https://api.example.com" },
44
+ });
45
+ expect(errors.apiKey).toBeUndefined();
46
+ expect(errors.baseUrl).toBeUndefined();
47
+ });
48
+
49
+ it("never flags optional empty fields", () => {
50
+ const errors = deriveClientFieldErrors({
51
+ schema: connectionSchema,
52
+ value: { apiKey: "sk-123", baseUrl: "https://x", organization: "" },
53
+ });
54
+ expect(errors.organization).toBeUndefined();
55
+ });
56
+
57
+ it("skips required fields hidden by x-hidden", () => {
58
+ const schema: JsonSchema = {
59
+ type: "object",
60
+ required: ["connectionId"],
61
+ properties: { connectionId: { type: "string", "x-hidden": true } },
62
+ };
63
+ const errors = deriveClientFieldErrors({ schema, value: {} });
64
+ expect(errors.connectionId).toBeUndefined();
65
+ });
66
+
67
+ it("skips required fields hidden by x-hidden-when", () => {
68
+ const schema: JsonSchema = {
69
+ type: "object",
70
+ required: ["token"],
71
+ properties: {
72
+ mode: { type: "string" },
73
+ token: { type: "string", "x-hidden-when": { mode: ["anonymous"] } },
74
+ },
75
+ };
76
+ const errors = deriveClientFieldErrors({
77
+ schema,
78
+ value: { mode: "anonymous" },
79
+ });
80
+ expect(errors.token).toBeUndefined();
81
+ });
82
+
83
+ it("flags a required nested object whose required child is empty", () => {
84
+ const errors = deriveClientFieldErrors({
85
+ schema: { ...connectionSchema, required: ["spendCap"] },
86
+ value: { spendCap: {} },
87
+ });
88
+ expect(errors.spendCap).toBeDefined();
89
+ });
90
+
91
+ it("returns an empty map for a schema with no properties", () => {
92
+ const errors = deriveClientFieldErrors({
93
+ schema: { type: "object" },
94
+ value: {},
95
+ });
96
+ expect(errors).toEqual({});
97
+ });
98
+
99
+ it("edit + blank x-secret + existing secret => valid (no required error)", () => {
100
+ const errors = deriveClientFieldErrors({
101
+ schema: connectionSchema,
102
+ value: { apiKey: "", baseUrl: "https://api.example.com" },
103
+ keepExistingSecretFields: ["apiKey"],
104
+ });
105
+ expect(errors.apiKey).toBeUndefined();
106
+ expect(errors.baseUrl).toBeUndefined();
107
+ expect(Object.keys(errors)).toHaveLength(0);
108
+ });
109
+
110
+ it("create + blank x-secret => required error", () => {
111
+ const errors = deriveClientFieldErrors({
112
+ schema: connectionSchema,
113
+ value: { apiKey: "", baseUrl: "https://api.example.com" },
114
+ keepExistingSecretFields: [],
115
+ });
116
+ expect(errors.apiKey).toBeDefined();
117
+ });
118
+
119
+ it("edit + blank x-secret + no existing secret for that field => required error", () => {
120
+ // apiKey was never set, so it is NOT in keepExistingSecretFields.
121
+ const errors = deriveClientFieldErrors({
122
+ schema: connectionSchema,
123
+ value: { apiKey: "", baseUrl: "https://api.example.com" },
124
+ keepExistingSecretFields: ["someOtherSecret"],
125
+ });
126
+ expect(errors.apiKey).toBeDefined();
127
+ });
128
+
129
+ it("does not exempt a blank NON-secret required field via keepExisting", () => {
130
+ const errors = deriveClientFieldErrors({
131
+ schema: connectionSchema,
132
+ value: { apiKey: "sk-1", baseUrl: "" },
133
+ keepExistingSecretFields: ["baseUrl"],
134
+ });
135
+ // baseUrl is not x-secret, so the keep-existing exemption must not apply.
136
+ expect(errors.baseUrl).toBeDefined();
137
+ });
138
+ });
139
+
140
+ describe("listSecretFieldKeys", () => {
141
+ it("lists only x-secret field keys", () => {
142
+ expect(listSecretFieldKeys(connectionSchema)).toEqual(["apiKey"]);
143
+ });
144
+
145
+ it("returns an empty list when there are no secret fields", () => {
146
+ expect(listSecretFieldKeys({ type: "object", properties: {} })).toEqual([]);
147
+ });
148
+ });
149
+
150
+ describe("omitKeepExistingSecrets", () => {
151
+ it("strips a blank keep-existing secret so it is not sent on submit", () => {
152
+ const result = omitKeepExistingSecrets({
153
+ schema: connectionSchema,
154
+ value: { apiKey: "", baseUrl: "https://x", organization: "acme" },
155
+ keepExistingSecretFields: ["apiKey"],
156
+ });
157
+ expect("apiKey" in result).toBe(false);
158
+ expect(result.baseUrl).toBe("https://x");
159
+ expect(result.organization).toBe("acme");
160
+ });
161
+
162
+ it("keeps a freshly-typed secret value", () => {
163
+ const result = omitKeepExistingSecrets({
164
+ schema: connectionSchema,
165
+ value: { apiKey: "sk-new", baseUrl: "https://x" },
166
+ keepExistingSecretFields: ["apiKey"],
167
+ });
168
+ expect(result.apiKey).toBe("sk-new");
169
+ });
170
+
171
+ it("strips nothing on create (empty keep-existing list)", () => {
172
+ const value = { apiKey: "", baseUrl: "https://x" };
173
+ const result = omitKeepExistingSecrets({
174
+ schema: connectionSchema,
175
+ value,
176
+ keepExistingSecretFields: [],
177
+ });
178
+ expect(result).toEqual(value);
179
+ });
180
+ });
181
+
182
+ describe("parseServerValidationData", () => {
183
+ it("parses a well-formed config-validation payload", () => {
184
+ const parsed = parseServerValidationData({
185
+ code: "CONFIG_VALIDATION",
186
+ issues: [{ path: ["apiKey"], message: "Required" }],
187
+ });
188
+ expect(parsed?.issues).toHaveLength(1);
189
+ });
190
+
191
+ it("returns undefined for an unrelated payload", () => {
192
+ expect(parseServerValidationData(undefined)).toBeUndefined();
193
+ expect(parseServerValidationData({ code: "OTHER" })).toBeUndefined();
194
+ expect(parseServerValidationData("boom")).toBeUndefined();
195
+ });
196
+ });
197
+
198
+ describe("deriveServerFieldErrors", () => {
199
+ it("maps issues with a known top-level path", () => {
200
+ const { mapped, unmapped } = deriveServerFieldErrors({
201
+ schema: connectionSchema,
202
+ issues: [{ path: ["apiKey"], message: "API key is invalid" }],
203
+ });
204
+ expect(mapped.apiKey).toBe("API key is invalid");
205
+ expect(unmapped).toHaveLength(0);
206
+ });
207
+
208
+ it("maps nested paths with dot notation", () => {
209
+ const { mapped } = deriveServerFieldErrors({
210
+ schema: connectionSchema,
211
+ issues: [
212
+ { path: ["spendCap", "tokenBudget"], message: "Must be positive" },
213
+ ],
214
+ });
215
+ expect(mapped["spendCap.tokenBudget"]).toBe("Must be positive");
216
+ });
217
+
218
+ it("falls back unmapped issues whose root is unknown", () => {
219
+ const { mapped, unmapped } = deriveServerFieldErrors({
220
+ schema: connectionSchema,
221
+ issues: [{ path: ["nonexistent"], message: "Surprise" }],
222
+ });
223
+ expect(Object.keys(mapped)).toHaveLength(0);
224
+ expect(unmapped).toEqual(["Surprise"]);
225
+ });
226
+
227
+ it("treats an empty path as unmappable", () => {
228
+ const { mapped, unmapped } = deriveServerFieldErrors({
229
+ schema: connectionSchema,
230
+ issues: [{ path: [], message: "Whole object is wrong" }],
231
+ });
232
+ expect(Object.keys(mapped)).toHaveLength(0);
233
+ expect(unmapped).toEqual(["Whole object is wrong"]);
234
+ });
235
+
236
+ it("keeps the first message when a path repeats", () => {
237
+ const { mapped } = deriveServerFieldErrors({
238
+ schema: connectionSchema,
239
+ issues: [
240
+ { path: ["apiKey"], message: "First" },
241
+ { path: ["apiKey"], message: "Second" },
242
+ ],
243
+ });
244
+ expect(mapped.apiKey).toBe("First");
245
+ });
246
+
247
+ it("returns empty maps for no issues", () => {
248
+ const { mapped, unmapped } = deriveServerFieldErrors({
249
+ schema: connectionSchema,
250
+ issues: [],
251
+ });
252
+ expect(mapped).toEqual({});
253
+ expect(unmapped).toEqual([]);
254
+ });
255
+ });