@elabs-ai/components-ui 4.0.0 → 4.1.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 (223) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-JKPPTL63.js +48 -0
  3. package/dist/chunk-JKPPTL63.js.map +1 -0
  4. package/dist/form.d.ts +28 -0
  5. package/dist/form.js +123 -0
  6. package/dist/form.js.map +1 -0
  7. package/dist/index.d.ts +2485 -167
  8. package/dist/index.js +10716 -2706
  9. package/dist/index.js.map +1 -1
  10. package/package.json +21 -7
  11. package/src/_peer-deps.test.ts +59 -0
  12. package/src/blocks-comparison-table.stories.tsx +2 -2
  13. package/src/components/accordion/accordion.tsx +1 -1
  14. package/src/components/advanced-group/advanced-group.tsx +1 -1
  15. package/src/components/alert/alert.tsx +4 -1
  16. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  17. package/src/components/app-shell/app-shell.tsx +35 -2
  18. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  19. package/src/components/attribution-panel/attribution-panel.tsx +1 -1
  20. package/src/components/attribution-panel/attributions.generated.ts +81 -77
  21. package/src/components/badge/badge.stories.tsx +8 -0
  22. package/src/components/badge/badge.tsx +1 -1
  23. package/src/components/bento-grid/bento-grid.test.tsx +1 -1
  24. package/src/components/bento-grid/bento-grid.tsx +1 -1
  25. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  26. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  27. package/src/components/breadcrumb/breadcrumb.tsx +7 -2
  28. package/src/components/button/button.stories.tsx +116 -8
  29. package/src/components/button/button.tsx +1 -1
  30. package/src/components/card/card.stories.tsx +11 -6
  31. package/src/components/card/card.tsx +1 -1
  32. package/src/components/change-review/change-review.stories.tsx +145 -1
  33. package/src/components/change-review/change-review.test.tsx +143 -0
  34. package/src/components/change-review/change-review.tsx +185 -4
  35. package/src/components/checkbox/checkbox.tsx +1 -1
  36. package/src/components/color-picker/color-picker.tsx +6 -5
  37. package/src/components/command/command.stories.tsx +16 -6
  38. package/src/components/command/command.test.tsx +73 -0
  39. package/src/components/command/command.tsx +30 -7
  40. package/src/components/command/index.ts +1 -0
  41. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  42. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  43. package/src/components/command-trigger/command-trigger.tsx +70 -0
  44. package/src/components/command-trigger/index.ts +1 -0
  45. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  46. package/src/components/context-rail/context-rail.test.tsx +263 -0
  47. package/src/components/context-rail/context-rail.tsx +678 -0
  48. package/src/components/context-rail/index.ts +1 -0
  49. package/src/components/copyable-value/copyable-value.tsx +1 -1
  50. package/src/components/dialog/dialog.tsx +2 -2
  51. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  52. package/src/components/empty-state/empty-state.test.tsx +26 -0
  53. package/src/components/empty-state/empty-state.tsx +6 -1
  54. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  55. package/src/components/field/field-context.ts +59 -0
  56. package/src/components/field/field.stories.tsx +142 -0
  57. package/src/components/field/field.test.tsx +415 -0
  58. package/src/components/field/field.tsx +340 -0
  59. package/src/components/field/index.ts +9 -0
  60. package/src/components/field-row/field-row.stories.tsx +85 -1
  61. package/src/components/field-row/field-row.test.tsx +145 -0
  62. package/src/components/field-row/field-row.tsx +48 -6
  63. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  64. package/src/components/file-upload/file-upload.test.tsx +7 -2
  65. package/src/components/file-upload/file-upload.tsx +15 -4
  66. package/src/components/form/form.tsx +5 -2
  67. package/src/components/icon-button/icon-button.test.tsx +1 -1
  68. package/src/components/icon-button/icon-button.tsx +2 -2
  69. package/src/components/input/input.stories.tsx +121 -0
  70. package/src/components/input/input.test.tsx +13 -0
  71. package/src/components/input/input.tsx +2 -2
  72. package/src/components/input-group/input-group.stories.tsx +74 -0
  73. package/src/components/input-group/input-group.tsx +13 -4
  74. package/src/components/input-otp/input-otp.tsx +5 -1
  75. package/src/components/keyboard-shortcuts/index.ts +6 -0
  76. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  77. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  78. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  79. package/src/components/link-preview/link-preview.tsx +1 -1
  80. package/src/components/locale-provider/index.ts +6 -1
  81. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  82. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  83. package/src/components/locale-provider/locale-provider.tsx +63 -8
  84. package/src/components/locale-provider/messages.ts +501 -2
  85. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  86. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  87. package/src/components/mention-input/mention-input.tsx +8 -38
  88. package/src/components/mention-input/mention-value.ts +8 -2
  89. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  90. package/src/components/metric-card/metric-card.tsx +21 -2
  91. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  92. package/src/components/model-picker/model-picker.test.tsx +63 -0
  93. package/src/components/model-picker/model-picker.tsx +62 -36
  94. package/src/components/nav-main/nav-main.tsx +1 -1
  95. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  96. package/src/components/nav-notifications/nav-notifications.tsx +57 -5
  97. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  98. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  99. package/src/components/navigation-menu/navigation-menu.tsx +189 -12
  100. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  101. package/src/components/page-shell/page-shell.test.tsx +229 -0
  102. package/src/components/page-shell/page-shell.tsx +108 -25
  103. package/src/components/pagination/pagination.test.tsx +90 -0
  104. package/src/components/pagination/pagination.tsx +9 -5
  105. package/src/components/radio-group/radio-group.tsx +1 -1
  106. package/src/components/rating/rating.tsx +1 -1
  107. package/src/components/resizable/resizable.stories.tsx +114 -2
  108. package/src/components/resizable/resizable.tsx +1 -1
  109. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  110. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  111. package/src/components/revision-timeline/revision-timeline.tsx +46 -3
  112. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  113. package/src/components/schema-form/from-json-schema.ts +375 -0
  114. package/src/components/schema-form/index.ts +81 -0
  115. package/src/components/schema-form/schema-form-spec.ts +698 -0
  116. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  117. package/src/components/schema-form/schema-form.test.tsx +900 -0
  118. package/src/components/schema-form/schema-form.tsx +1545 -0
  119. package/src/components/section-header/section-header.stories.tsx +37 -0
  120. package/src/components/section-header/section-header.tsx +12 -1
  121. package/src/components/select/select.tsx +6 -2
  122. package/src/components/sheet/sheet.tsx +1 -1
  123. package/src/components/side-dock/index.ts +1 -0
  124. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  125. package/src/components/side-dock/side-dock.test.tsx +273 -0
  126. package/src/components/side-dock/side-dock.tsx +502 -0
  127. package/src/components/sidebar/index.ts +1 -0
  128. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  129. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  130. package/src/components/sidebar/sidebar.test.tsx +36 -1
  131. package/src/components/sidebar/sidebar.tsx +245 -33
  132. package/src/components/skip-link/index.ts +1 -0
  133. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  134. package/src/components/skip-link/skip-link.test.tsx +20 -0
  135. package/src/components/skip-link/skip-link.tsx +34 -0
  136. package/src/components/slider/slider.tsx +1 -1
  137. package/src/components/split-panel/index.ts +6 -1
  138. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  139. package/src/components/split-panel/split-panel.test.tsx +20 -0
  140. package/src/components/split-panel/split-panel.tsx +28 -8
  141. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  142. package/src/components/state-panel/state-panel.test.tsx +125 -0
  143. package/src/components/state-panel/state-panel.tsx +96 -17
  144. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  145. package/src/components/switch/switch.tsx +1 -1
  146. package/src/components/table/table.stories.tsx +62 -0
  147. package/src/components/table/table.test.tsx +142 -1
  148. package/src/components/table/table.tsx +60 -1
  149. package/src/components/tabs/tabs.tsx +8 -2
  150. package/src/components/tag-input/tag-input.tsx +3 -2
  151. package/src/components/team-switcher/team-switcher.tsx +34 -5
  152. package/src/components/textarea/textarea.tsx +1 -1
  153. package/src/components/theme-switcher/theme-switcher.tsx +6 -2
  154. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  155. package/src/components/timeline/timeline.stories.tsx +27 -0
  156. package/src/components/timeline/timeline.tsx +11 -1
  157. package/src/components/toggle/toggle.tsx +1 -1
  158. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  159. package/src/components/tree/tree.stories.tsx +2 -2
  160. package/src/components/tree/tree.tsx +14 -9
  161. package/src/components/typography/prose.tsx +1 -1
  162. package/src/components/typography/typography.stories.tsx +228 -0
  163. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  164. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  165. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  166. package/src/components/virtual-select/virtual-select.test.tsx +1 -1
  167. package/src/components/wizard/wizard.tsx +1 -1
  168. package/src/components/workspace-picker/index.ts +3 -0
  169. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  170. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  171. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  172. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  173. package/src/illustrations/empty-list-illustration.tsx +26 -0
  174. package/src/illustrations/error-illustration.tsx +60 -0
  175. package/src/illustrations/first-run-illustration.tsx +27 -0
  176. package/src/illustrations/illustration-base.tsx +108 -0
  177. package/src/illustrations/illustrations.stories.tsx +94 -0
  178. package/src/illustrations/illustrations.test.tsx +98 -0
  179. package/src/illustrations/index.ts +16 -0
  180. package/src/illustrations/no-access-illustration.tsx +45 -0
  181. package/src/illustrations/no-results-illustration.tsx +46 -0
  182. package/src/illustrations/offline-illustration.tsx +37 -0
  183. package/src/illustrations/success-illustration.tsx +40 -0
  184. package/src/index.ts +75 -2
  185. package/src/lib/agent-event-model.test.ts +12 -0
  186. package/src/lib/agent-event-model.ts +42 -0
  187. package/src/lib/approval-option.test.ts +17 -0
  188. package/src/lib/approval-option.ts +53 -0
  189. package/src/lib/check-result.ts +33 -0
  190. package/src/lib/diff-line.test.ts +36 -0
  191. package/src/lib/diff-line.ts +63 -0
  192. package/src/lib/diff-rows.test.ts +76 -0
  193. package/src/lib/diff-rows.ts +92 -0
  194. package/src/lib/format-duration.test.ts +22 -0
  195. package/src/lib/format-duration.ts +30 -0
  196. package/src/lib/has-renderable-content.test.ts +49 -0
  197. package/src/lib/has-renderable-content.ts +32 -0
  198. package/src/lib/operating-mode.test.ts +36 -0
  199. package/src/lib/operating-mode.ts +81 -0
  200. package/src/lib/optional-peer.ts +59 -0
  201. package/src/lib/session-launch.ts +37 -0
  202. package/src/lib/slash-command.test.ts +44 -0
  203. package/src/lib/slash-command.ts +45 -0
  204. package/src/lib/trigger-query.test.ts +94 -0
  205. package/src/lib/trigger-query.ts +88 -0
  206. package/src/lib/use-mobile.ts +12 -6
  207. package/src/motion.stories.tsx +6 -0
  208. package/src/templates-object-detail-hub.stories.tsx +2 -2
  209. package/src/templates-screen-states.stories.tsx +5 -5
  210. package/src/templates-settings.stories.tsx +1 -1
  211. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  212. package/src/blocks/sidebar-02/logo.tsx +0 -18
  213. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  214. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  215. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  216. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  217. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  218. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  219. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  220. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  221. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  222. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  223. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -0,0 +1,900 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import {
5
+ SchemaForm,
6
+ SchemaFormField,
7
+ SchemaFormFields,
8
+ SchemaFormProvider,
9
+ SchemaFormRoot,
10
+ SchemaFormSubmit,
11
+ SchemaFormTestAction,
12
+ } from "./schema-form";
13
+ import {
14
+ fieldSpecSchema,
15
+ fileMatchesAccept,
16
+ findFieldByName,
17
+ formSpecSchema,
18
+ normalizeFormSpec,
19
+ validateField,
20
+ type FormSpec,
21
+ } from "./schema-form-spec";
22
+
23
+ const simpleSpec: FormSpec = {
24
+ formName: "signup",
25
+ title: "Sign up",
26
+ fields: [
27
+ { type: "string", name: "name", label: "Name", required: true },
28
+ { type: "string", name: "email", label: "Email", format: "email", required: true },
29
+ ],
30
+ };
31
+
32
+ const groupSpec: FormSpec = {
33
+ formName: "connector",
34
+ title: "Connector",
35
+ fields: [
36
+ {
37
+ type: "group",
38
+ name: "auth",
39
+ label: "Authentication",
40
+ variant: "tabs",
41
+ groups: [
42
+ {
43
+ key: "apiKey",
44
+ label: "API key",
45
+ fields: [{ type: "string", name: "apiKey", label: "API key", required: true }],
46
+ },
47
+ {
48
+ key: "oauth",
49
+ label: "OAuth",
50
+ fields: [{ type: "string", name: "clientId", label: "Client ID", required: true }],
51
+ },
52
+ ],
53
+ },
54
+ ],
55
+ };
56
+
57
+ // A controlling enum field plus a target field that's only visible (and only
58
+ // validated) once the controlling field equals "oauth" — issue #22's
59
+ // `visibleWhen: { field, equals }` conditional-visibility ask.
60
+ const conditionalSpec: FormSpec = {
61
+ formName: "connector_auth",
62
+ fields: [
63
+ {
64
+ type: "enum",
65
+ name: "authMethod",
66
+ label: "Auth method",
67
+ options: ["oauth", "apikey"],
68
+ },
69
+ {
70
+ type: "string",
71
+ name: "clientSecret",
72
+ label: "Client secret",
73
+ required: true,
74
+ visibleWhen: { field: "authMethod", equals: "oauth" },
75
+ },
76
+ ],
77
+ };
78
+
79
+ // Same shape, but the controller carries a spec `default` — a controlled
80
+ // consumer may omit it from its `values` object entirely and still expect
81
+ // the default to be what's actually in force (PR #81 review finding).
82
+ const defaultedConditionalSpec: FormSpec = {
83
+ formName: "defaulted_auth",
84
+ fields: [
85
+ {
86
+ type: "enum",
87
+ name: "authMethod",
88
+ label: "Auth method",
89
+ options: ["oauth", "apikey"],
90
+ default: "oauth",
91
+ },
92
+ {
93
+ type: "string",
94
+ name: "clientSecret",
95
+ label: "Client secret",
96
+ required: true,
97
+ visibleWhen: { field: "authMethod", equals: "oauth" },
98
+ },
99
+ ],
100
+ };
101
+
102
+ describe("SchemaForm — rendering", () => {
103
+ it("renders the title, fields, and a submit button", () => {
104
+ render(<SchemaForm spec={simpleSpec} />);
105
+ expect(screen.getByText("Sign up")).toBeInTheDocument();
106
+ expect(screen.getByLabelText(/Name/)).toBeInTheDocument();
107
+ expect(screen.getByLabelText(/Email/)).toBeInTheDocument();
108
+ expect(screen.getByRole("button", { name: "Submit" })).toBeInTheDocument();
109
+ });
110
+
111
+ it("labels the form via aria-labelledby pointing at the title", () => {
112
+ render(<SchemaForm spec={simpleSpec} />);
113
+ expect(screen.getByRole("form", { name: "Sign up" })).toBeInTheDocument();
114
+ });
115
+
116
+ it("uses the humanized name when no label is given", () => {
117
+ render(
118
+ <SchemaForm spec={{ formName: "f", fields: [{ type: "string", name: "first_name" }] }} />,
119
+ );
120
+ expect(screen.getByLabelText("First name")).toBeInTheDocument();
121
+ });
122
+
123
+ it("renders a list field's label without a native htmlFor association (it's a multi-row region)", () => {
124
+ render(
125
+ <SchemaForm
126
+ spec={{
127
+ formName: "f",
128
+ fields: [{ type: "list", name: "domains", label: "Allowed domains" }],
129
+ }}
130
+ />,
131
+ );
132
+ const label = screen.getByText("Allowed domains");
133
+ expect(label).toBeInTheDocument();
134
+ expect(label).not.toHaveAttribute("for");
135
+ expect(screen.getByRole("button", { name: /add item/i })).toBeInTheDocument();
136
+ });
137
+
138
+ it("renders a key-value field", () => {
139
+ render(
140
+ <SchemaForm
141
+ spec={{ formName: "f", fields: [{ type: "key-value", name: "headers", label: "Headers" }] }}
142
+ />,
143
+ );
144
+ expect(screen.getByText("Headers")).toBeInTheDocument();
145
+ });
146
+
147
+ it("renders a file field", () => {
148
+ render(
149
+ <SchemaForm
150
+ spec={{ formName: "f", fields: [{ type: "file", name: "creds", label: "Credentials" }] }}
151
+ />,
152
+ );
153
+ expect(screen.getByText("Credentials")).toBeInTheDocument();
154
+ });
155
+
156
+ it("renders a group field's tabs and only the active branch's fields", () => {
157
+ render(<SchemaForm spec={groupSpec} />);
158
+ expect(screen.getByRole("tab", { name: "API key" })).toBeInTheDocument();
159
+ expect(screen.getByRole("tab", { name: "OAuth" })).toBeInTheDocument();
160
+ // The active branch's own field ("API key") is present; the role narrows
161
+ // past the tab trigger, which shares the same visible text.
162
+ expect(screen.getByRole("textbox", { name: /API key/ })).toBeInTheDocument();
163
+ });
164
+
165
+ it("renders a group field's advanced branches as disclosures", () => {
166
+ render(
167
+ <SchemaForm
168
+ spec={{
169
+ formName: "f",
170
+ fields: [
171
+ {
172
+ type: "group",
173
+ name: "advanced",
174
+ label: "Advanced",
175
+ variant: "advanced",
176
+ groups: [
177
+ {
178
+ key: "retry",
179
+ label: "Retry policy",
180
+ fields: [{ type: "integer", name: "maxRetries", label: "Max retries" }],
181
+ },
182
+ ],
183
+ },
184
+ ],
185
+ }}
186
+ />,
187
+ );
188
+ expect(screen.getByText("Retry policy")).toBeInTheDocument();
189
+ });
190
+ });
191
+
192
+ describe("SchemaForm — never throws / fallback", () => {
193
+ it("renders a fallback for a non-object spec (does not throw)", () => {
194
+ render(<SchemaForm spec={"nope" as unknown} />);
195
+ expect(screen.getByText(/could not|missing or malformed/i)).toBeInTheDocument();
196
+ });
197
+
198
+ it("renders a fallback for a valid-but-empty spec when not loading", () => {
199
+ render(<SchemaForm spec={{ formName: "x", fields: [] }} />);
200
+ expect(screen.getByText(/no fields/i)).toBeInTheDocument();
201
+ });
202
+
203
+ it("renders a skeleton (not a fallback) for an empty spec while loading", () => {
204
+ const { container } = render(<SchemaForm spec={{ formName: "x", fields: [] }} loading />);
205
+ expect(screen.queryByText(/no fields/i)).not.toBeInTheDocument();
206
+ expect(container.querySelector('[aria-hidden="true"] .animate-pulse')).not.toBeNull();
207
+ });
208
+ });
209
+
210
+ describe("SchemaForm — validation (submit) + focus-first-invalid", () => {
211
+ it("blocks submit, shows inline errors, and focuses the first invalid field", async () => {
212
+ const user = userEvent.setup();
213
+ const onSubmit = vi.fn();
214
+ render(<SchemaForm spec={simpleSpec} onSubmit={onSubmit} />);
215
+ await user.click(screen.getByRole("button", { name: "Submit" }));
216
+ expect(onSubmit).not.toHaveBeenCalled();
217
+ expect(screen.getAllByText("This field is required.").length).toBeGreaterThan(0);
218
+ expect(screen.getByLabelText(/Name/)).toHaveFocus();
219
+ });
220
+
221
+ it("submits { formName, values } when valid", async () => {
222
+ const user = userEvent.setup();
223
+ const onSubmit = vi.fn();
224
+ render(<SchemaForm spec={simpleSpec} onSubmit={onSubmit} />);
225
+ await user.type(screen.getByLabelText(/Name/), "Dana");
226
+ await user.type(screen.getByLabelText(/Email/), "dana@acme.com");
227
+ await user.click(screen.getByRole("button", { name: "Submit" }));
228
+ expect(onSubmit).toHaveBeenCalledWith({
229
+ formName: "signup",
230
+ values: expect.objectContaining({ name: "Dana", email: "dana@acme.com" }),
231
+ });
232
+ });
233
+
234
+ it("only validates the ACTIVE tab branch for a `variant: tabs` group", async () => {
235
+ const user = userEvent.setup();
236
+ const onSubmit = vi.fn();
237
+ render(<SchemaForm spec={groupSpec} onSubmit={onSubmit} />);
238
+ // The "apiKey" branch is active by default; its field is required and empty.
239
+ await user.click(screen.getByRole("button", { name: "Submit" }));
240
+ expect(onSubmit).not.toHaveBeenCalled();
241
+ // Switching to OAuth and filling ITS field should let submit succeed —
242
+ // the API-key branch's now-inactive required field must not block it.
243
+ await user.click(screen.getByRole("tab", { name: "OAuth" }));
244
+ await user.type(screen.getByLabelText(/Client ID/), "abc123");
245
+ await user.click(screen.getByRole("button", { name: "Submit" }));
246
+ expect(onSubmit).toHaveBeenCalledWith({
247
+ formName: "connector",
248
+ values: expect.objectContaining({ auth: "oauth", clientId: "abc123" }),
249
+ });
250
+ });
251
+
252
+ it("strips the INACTIVE tab branch's fields from the submitted values, not just its validation", async () => {
253
+ const user = userEvent.setup();
254
+ const onSubmit = vi.fn();
255
+ render(<SchemaForm spec={groupSpec} onSubmit={onSubmit} />);
256
+ // Type into the API-key branch first, then switch away from it. The role
257
+ // narrows past the "API key" tab trigger, which shares the visible text.
258
+ await user.type(screen.getByRole("textbox", { name: /API key/ }), "secret-key");
259
+ await user.click(screen.getByRole("tab", { name: "OAuth" }));
260
+ await user.type(screen.getByLabelText(/Client ID/), "abc123");
261
+ await user.click(screen.getByRole("button", { name: "Submit" }));
262
+ // Exact match (not objectContaining): a stray "apiKey" key left over from
263
+ // the now-inactive branch would fail this.
264
+ expect(onSubmit).toHaveBeenCalledWith({
265
+ formName: "connector",
266
+ values: { auth: "oauth", clientId: "abc123" },
267
+ });
268
+ });
269
+
270
+ it("hides a `visibleWhen` field while its controlling value is unset, and never blocks submit on it while hidden", async () => {
271
+ const onSubmit = vi.fn();
272
+ render(<SchemaForm spec={conditionalSpec} values={{}} onSubmit={onSubmit} />);
273
+ // Unset controlling value → the target field isn't rendered at all.
274
+ expect(screen.queryByLabelText(/Client secret/)).not.toBeInTheDocument();
275
+
276
+ // Submitting must not report a validation error for the hidden required
277
+ // field — it must not even reach `validateForm`/the focus walk.
278
+ await userEvent.click(screen.getByRole("button", { name: "Submit" }));
279
+ expect(onSubmit).toHaveBeenCalledWith({
280
+ formName: "connector_auth",
281
+ values: expect.objectContaining({ authMethod: undefined }),
282
+ });
283
+ });
284
+
285
+ it("reveals a `visibleWhen` field once its controlling field matches, and blocks submit on it once visible+required", async () => {
286
+ const onSubmit = vi.fn();
287
+ const { rerender } = render(
288
+ <SchemaForm spec={conditionalSpec} values={{ authMethod: "apikey" }} onSubmit={onSubmit} />,
289
+ );
290
+ // Still hidden for the OTHER enum option ("apikey" ≠ "oauth").
291
+ expect(screen.queryByLabelText(/Client secret/)).not.toBeInTheDocument();
292
+ await userEvent.click(screen.getByRole("button", { name: "Submit" }));
293
+ expect(onSubmit).toHaveBeenCalledWith({
294
+ formName: "connector_auth",
295
+ values: expect.objectContaining({ authMethod: "apikey" }),
296
+ });
297
+
298
+ // Switching the controlling field to "oauth" reveals it — and now it DOES
299
+ // block submit while empty (it's `required`).
300
+ onSubmit.mockClear();
301
+ rerender(
302
+ <SchemaForm spec={conditionalSpec} values={{ authMethod: "oauth" }} onSubmit={onSubmit} />,
303
+ );
304
+ expect(screen.getByLabelText(/Client secret/)).toBeInTheDocument();
305
+ await userEvent.click(screen.getByRole("button", { name: "Submit" }));
306
+ expect(onSubmit).not.toHaveBeenCalled();
307
+ await waitFor(() => {
308
+ expect(screen.getByLabelText(/Client secret/)).toHaveFocus();
309
+ });
310
+ });
311
+
312
+ it("strips a hidden `visibleWhen` field's stale value from the submitted payload, even if it was set before the field became hidden", async () => {
313
+ const onSubmit = vi.fn();
314
+ const { rerender } = render(
315
+ <SchemaForm
316
+ spec={conditionalSpec}
317
+ values={{ authMethod: "oauth", clientSecret: "sekret" }}
318
+ onSubmit={onSubmit}
319
+ />,
320
+ );
321
+ expect(screen.getByLabelText(/Client secret/)).toBeInTheDocument();
322
+
323
+ // The controller flips away from "oauth" — clientSecret is now hidden,
324
+ // but a controlled consumer's `values` object still carries the
325
+ // previously-typed value (nothing clears it automatically).
326
+ rerender(
327
+ <SchemaForm
328
+ spec={conditionalSpec}
329
+ values={{ authMethod: "apikey", clientSecret: "sekret" }}
330
+ onSubmit={onSubmit}
331
+ />,
332
+ );
333
+ expect(screen.queryByLabelText(/Client secret/)).not.toBeInTheDocument();
334
+
335
+ await userEvent.click(screen.getByRole("button", { name: "Submit" }));
336
+ // Exact match (not objectContaining): a leaked "clientSecret" key from
337
+ // before the field was hidden would fail this.
338
+ expect(onSubmit).toHaveBeenCalledWith({
339
+ formName: "connector_auth",
340
+ values: { authMethod: "apikey" },
341
+ });
342
+ });
343
+
344
+ it("resolves visibility against the controller's EFFECTIVE (default-applied) value, not a raw value a controlled consumer may have omitted", async () => {
345
+ const onSubmit = vi.fn();
346
+ // The controlled `values` object omits `authMethod` entirely — the
347
+ // spec's own default ("oauth") is what actually renders and submits
348
+ // (via `effectiveValue`), so the OAuth-only field must be visible too,
349
+ // not hidden because the RAW value happens to be `undefined`.
350
+ render(<SchemaForm spec={defaultedConditionalSpec} values={{}} onSubmit={onSubmit} />);
351
+ expect(screen.getByLabelText(/Client secret/)).toBeInTheDocument();
352
+
353
+ // It's required and empty, so submit must block on it with a VISIBLE,
354
+ // focusable control to fix — not silently drop it as "hidden" while
355
+ // validation (which resolves the same default) still requires it.
356
+ await userEvent.click(screen.getByRole("button", { name: "Submit" }));
357
+ expect(onSubmit).not.toHaveBeenCalled();
358
+ await waitFor(() => {
359
+ expect(screen.getByLabelText(/Client secret/)).toHaveFocus();
360
+ });
361
+ });
362
+
363
+ it("SchemaFormField enforces its own visibleWhen even when placed directly in a custom layout, bypassing SchemaFormFields' filter", () => {
364
+ const result = normalizeFormSpec(conditionalSpec);
365
+ if (!result.ok) throw new Error("expected conditionalSpec to normalize");
366
+ render(
367
+ <SchemaFormProvider spec={result.spec} values={{ authMethod: "apikey" }}>
368
+ <SchemaFormField name="clientSecret" />
369
+ </SchemaFormProvider>,
370
+ );
371
+ expect(screen.queryByLabelText(/Client secret/)).not.toBeInTheDocument();
372
+ });
373
+
374
+ it("blocks submit and shows an inline error for a file that fails validation (wrong type)", async () => {
375
+ const user = userEvent.setup();
376
+ const onSubmit = vi.fn();
377
+ const badFile = new File(["x"], "creds.exe", { type: "application/x-msdownload" });
378
+ render(
379
+ <SchemaForm
380
+ spec={{
381
+ formName: "f",
382
+ fields: [{ type: "file", name: "creds", label: "Credentials", accept: ".json" }],
383
+ }}
384
+ values={{ creds: [badFile] }}
385
+ onSubmit={onSubmit}
386
+ />,
387
+ );
388
+ await user.click(screen.getByRole("button", { name: "Submit" }));
389
+ expect(onSubmit).not.toHaveBeenCalled();
390
+ expect(screen.getAllByText("This file type isn't accepted.").length).toBeGreaterThan(0);
391
+ });
392
+
393
+ it("auto-reveals a collapsed advanced-group branch that holds the first invalid field, and focuses it", async () => {
394
+ const user = userEvent.setup();
395
+ const onSubmit = vi.fn();
396
+ render(
397
+ <SchemaForm
398
+ spec={{
399
+ formName: "f",
400
+ fields: [
401
+ {
402
+ type: "group",
403
+ name: "advanced",
404
+ label: "Advanced",
405
+ variant: "advanced",
406
+ groups: [
407
+ {
408
+ key: "retry",
409
+ label: "Retry policy",
410
+ fields: [{ type: "string", name: "apiKey", label: "API key", required: true }],
411
+ },
412
+ ],
413
+ },
414
+ ],
415
+ }}
416
+ onSubmit={onSubmit}
417
+ />,
418
+ );
419
+ // Collapsed by default — the field isn't reachable yet.
420
+ expect(screen.queryByLabelText(/API key/)).not.toBeInTheDocument();
421
+
422
+ await user.click(screen.getByRole("button", { name: "Submit" }));
423
+
424
+ expect(onSubmit).not.toHaveBeenCalled();
425
+ await waitFor(() => {
426
+ expect(screen.getByLabelText(/API key/)).toHaveFocus();
427
+ });
428
+ });
429
+ });
430
+
431
+ describe("SchemaForm — submit control a11y (never natively disabled while submitting)", () => {
432
+ it("keeps the submit button focusable and in the tab order while submitting", () => {
433
+ render(<SchemaForm spec={simpleSpec} submitting />);
434
+ const button = screen.getByRole("button", { name: "Submitting…" });
435
+ expect(button).not.toBeDisabled();
436
+ expect(button).toHaveAttribute("aria-disabled", "true");
437
+ });
438
+
439
+ it("ignores a click while submitting (handler guard backs the aria-disabled affordance)", async () => {
440
+ const user = userEvent.setup();
441
+ const onSubmit = vi.fn();
442
+ render(<SchemaForm spec={simpleSpec} submitting onSubmit={onSubmit} />);
443
+ await user.click(screen.getByRole("button", { name: "Submitting…" }));
444
+ expect(onSubmit).not.toHaveBeenCalled();
445
+ });
446
+
447
+ it("natively disables every control for an explicit, durable disabled form", () => {
448
+ render(<SchemaForm spec={simpleSpec} disabled />);
449
+ expect(screen.getByLabelText(/Name/)).toBeDisabled();
450
+ expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
451
+ });
452
+ });
453
+
454
+ describe("SchemaForm — spec swap (uncontrolled values)", () => {
455
+ it("resets uncontrolled internal values when the spec's formName changes", async () => {
456
+ const user = userEvent.setup();
457
+ const { rerender } = render(
458
+ <SchemaForm spec={{ formName: "a", fields: [{ type: "string", name: "x", label: "X" }] }} />,
459
+ );
460
+ await user.type(screen.getByLabelText("X"), "hello");
461
+ expect(screen.getByLabelText("X")).toHaveValue("hello");
462
+
463
+ // A different spec (new formName) that happens to reuse the field name
464
+ // "x" — without a reset, the stale "hello" would leak into the new spec.
465
+ rerender(
466
+ <SchemaForm
467
+ spec={{ formName: "b", fields: [{ type: "string", name: "x", label: "X (form b)" }] }}
468
+ />,
469
+ );
470
+ expect(screen.getByLabelText("X (form b)")).toHaveValue("");
471
+ });
472
+
473
+ it("does NOT reset values on an ordinary re-render with the SAME formName", async () => {
474
+ const user = userEvent.setup();
475
+ const spec = { formName: "a", fields: [{ type: "string" as const, name: "x", label: "X" }] };
476
+ const { rerender } = render(<SchemaForm spec={spec} />);
477
+ await user.type(screen.getByLabelText("X"), "hello");
478
+ // A fresh object with the SAME formName/content (SchemaForm normalizes it
479
+ // fresh every render) must not be mistaken for a spec swap.
480
+ rerender(<SchemaForm spec={{ ...spec }} />);
481
+ expect(screen.getByLabelText("X")).toHaveValue("hello");
482
+ });
483
+ });
484
+
485
+ describe("SchemaForm — file field is controlled", () => {
486
+ it("renders a file seeded via `values` in the picker (not just its own internal state)", () => {
487
+ const file = new File(["{}"], "creds.json", { type: "application/json" });
488
+ render(
489
+ <SchemaForm
490
+ spec={{ formName: "f", fields: [{ type: "file", name: "creds", label: "Credentials" }] }}
491
+ values={{ creds: [file] }}
492
+ />,
493
+ );
494
+ expect(screen.getByText("creds.json")).toBeInTheDocument();
495
+ });
496
+ });
497
+
498
+ describe("SchemaForm — controlled + submitted", () => {
499
+ it("reflects controlled values and calls onChange", async () => {
500
+ const user = userEvent.setup();
501
+ const onChange = vi.fn();
502
+ render(<SchemaForm spec={simpleSpec} values={{ name: "Seed" }} onChange={onChange} />);
503
+ expect(screen.getByLabelText(/Name/)).toHaveValue("Seed");
504
+ await user.type(screen.getByLabelText(/Email/), "a");
505
+ expect(onChange).toHaveBeenCalled();
506
+ });
507
+
508
+ it("renders an inert submitted state with values visible and no submit button", () => {
509
+ render(<SchemaForm spec={simpleSpec} submitted values={{ name: "Dana", email: "d@a.co" }} />);
510
+ expect(screen.getByLabelText(/Name/)).toHaveValue("Dana");
511
+ expect(screen.getByLabelText(/Name/)).toHaveAttribute("readonly");
512
+ expect(screen.queryByRole("button", { name: "Submit" })).not.toBeInTheDocument();
513
+ expect(screen.getByText("Submitted")).toBeInTheDocument();
514
+ });
515
+
516
+ it("renders a terminal, form-level submission error", () => {
517
+ render(<SchemaForm spec={simpleSpec} error="Couldn't save." />);
518
+ expect(screen.getByRole("alert")).toHaveTextContent("Couldn't save.");
519
+ });
520
+ });
521
+
522
+ describe("schema-form-spec", () => {
523
+ it("formSpecSchema accepts a well-formed spec, including group/list/key-value/file fields", () => {
524
+ expect(
525
+ formSpecSchema.safeParse({
526
+ formName: "f",
527
+ fields: [
528
+ { type: "list", name: "l" },
529
+ { type: "key-value", name: "kv" },
530
+ { type: "file", name: "file" },
531
+ {
532
+ type: "group",
533
+ name: "g",
534
+ variant: "advanced",
535
+ groups: [{ key: "a", label: "A", fields: [{ type: "string", name: "x" }] }],
536
+ },
537
+ ],
538
+ }).success,
539
+ ).toBe(true);
540
+ });
541
+
542
+ it("rejects a password/credential field (no such format)", () => {
543
+ expect(
544
+ fieldSpecSchema.safeParse({ type: "string", name: "pw", format: "password" }).success,
545
+ ).toBe(false);
546
+ });
547
+
548
+ it("normalizeFormSpec drops invalid top-level fields but keeps valid ones", () => {
549
+ const result = normalizeFormSpec({
550
+ formName: "f",
551
+ fields: [{ type: "string", name: "ok" }, { nope: true }],
552
+ });
553
+ expect(result.ok).toBe(true);
554
+ if (result.ok) {
555
+ expect(result.spec.fields).toHaveLength(1);
556
+ expect(result.spec.fields[0]?.name).toBe("ok");
557
+ }
558
+ });
559
+
560
+ it("findFieldByName resolves a field nested inside a group branch", () => {
561
+ const found = findFieldByName(groupSpec.fields, "clientId");
562
+ expect(found?.name).toBe("clientId");
563
+ });
564
+
565
+ it("normalizeFormSpec drops a nested field whose name collides with an already-accepted one", () => {
566
+ const result = normalizeFormSpec({
567
+ formName: "f",
568
+ fields: [
569
+ { type: "string", name: "shared" },
570
+ {
571
+ type: "group",
572
+ name: "g",
573
+ variant: "advanced",
574
+ groups: [{ key: "a", label: "A", fields: [{ type: "string", name: "shared" }] }],
575
+ },
576
+ ],
577
+ });
578
+ expect(result.ok).toBe(true);
579
+ if (result.ok) {
580
+ expect(result.spec.fields).toHaveLength(1);
581
+ expect(result.spec.fields[0]?.name).toBe("shared");
582
+ }
583
+ });
584
+
585
+ it("normalizeFormSpec drops a group whose OWN two branches collide with each other", () => {
586
+ const result = normalizeFormSpec({
587
+ formName: "f",
588
+ fields: [
589
+ {
590
+ type: "group",
591
+ name: "g",
592
+ variant: "advanced",
593
+ groups: [
594
+ { key: "a", label: "A", fields: [{ type: "string", name: "dup" }] },
595
+ { key: "b", label: "B", fields: [{ type: "string", name: "dup" }] },
596
+ ],
597
+ },
598
+ ],
599
+ });
600
+ expect(result.ok).toBe(true);
601
+ if (result.ok) expect(result.spec.fields).toHaveLength(0);
602
+ });
603
+
604
+ it("validateField enforces the declarative vocabulary", () => {
605
+ const req = { type: "string", name: "x", required: true } as const;
606
+ expect(validateField(req, undefined)).toMatch(/required/);
607
+ expect(validateField({ type: "integer", name: "n", min: 5 }, 3)).toMatch(/at least 5/);
608
+ expect(validateField({ type: "list", name: "l", minItems: 2 }, ["a"])).toMatch(/at least 2/);
609
+ });
610
+
611
+ it("fileMatchesAccept matches extensions, MIME types, and wildcards", () => {
612
+ const jsonFile = new File(["{}"], "creds.json", { type: "application/json" });
613
+ const exeFile = new File(["x"], "creds.exe", { type: "application/x-msdownload" });
614
+ const pngFile = new File(["x"], "pic.png", { type: "image/png" });
615
+ expect(fileMatchesAccept(jsonFile, ".json")).toBe(true);
616
+ expect(fileMatchesAccept(exeFile, ".json")).toBe(false);
617
+ expect(fileMatchesAccept(pngFile, "image/*")).toBe(true);
618
+ expect(fileMatchesAccept(jsonFile, undefined)).toBe(true);
619
+ });
620
+ });
621
+
622
+ describe("SchemaFormTestAction", () => {
623
+ // A required field left empty on purpose — every test in this block proves
624
+ // the test action neither reads nor is blocked by field validity.
625
+ const connectorSpec: FormSpec = {
626
+ formName: "connector",
627
+ fields: [{ type: "string", name: "apiKey", label: "API key", required: true }],
628
+ };
629
+
630
+ function renderWithTestAction(onTest: () => void | Promise<void>) {
631
+ const result = normalizeFormSpec(connectorSpec);
632
+ if (!result.ok) throw new Error("expected connectorSpec to normalize");
633
+ const onSubmit = vi.fn();
634
+ render(
635
+ <SchemaFormProvider spec={result.spec} onSubmit={onSubmit}>
636
+ <SchemaFormRoot>
637
+ <SchemaFormFields />
638
+ <SchemaFormTestAction onTest={onTest} />
639
+ <SchemaFormSubmit />
640
+ </SchemaFormRoot>
641
+ </SchemaFormProvider>,
642
+ );
643
+ return { onSubmit };
644
+ }
645
+
646
+ it("renders idle with the default label", () => {
647
+ renderWithTestAction(() => {});
648
+ expect(screen.getByRole("button", { name: "Test connection" })).toBeInTheDocument();
649
+ });
650
+
651
+ it("goes pending then success when onTest resolves, without touching field validity", async () => {
652
+ const user = userEvent.setup();
653
+ let resolveTest: () => void = () => {};
654
+ const onTest = vi.fn(
655
+ () =>
656
+ new Promise<void>((resolve) => {
657
+ resolveTest = resolve;
658
+ }),
659
+ );
660
+ renderWithTestAction(onTest);
661
+
662
+ const button = screen.getByRole("button", { name: "Test connection" });
663
+ await user.click(button);
664
+
665
+ expect(onTest).toHaveBeenCalledTimes(1);
666
+ // Pending: still focusable/in the tab order (aria-disabled, never native disabled).
667
+ expect(screen.getByRole("button", { name: "Testing…" })).not.toBeDisabled();
668
+ expect(screen.getByRole("button", { name: "Testing…" })).toHaveAttribute(
669
+ "aria-disabled",
670
+ "true",
671
+ );
672
+ // The empty required field is untouched — no validation error appeared just
673
+ // from running the test action.
674
+ expect(screen.queryByText(/required/i)).not.toBeInTheDocument();
675
+
676
+ resolveTest();
677
+ await waitFor(() => {
678
+ expect(screen.getByText("Connected")).toBeInTheDocument();
679
+ });
680
+ expect(screen.getByRole("button", { name: "Test connection" })).not.toHaveAttribute(
681
+ "aria-disabled",
682
+ );
683
+ });
684
+
685
+ it("goes pending then failure when onTest rejects, and shows the rejection's message", async () => {
686
+ const user = userEvent.setup();
687
+ const onTest = vi.fn().mockRejectedValue(new Error("connection refused"));
688
+ renderWithTestAction(onTest);
689
+
690
+ await user.click(screen.getByRole("button", { name: "Test connection" }));
691
+
692
+ await waitFor(() => {
693
+ expect(screen.getByRole("alert")).toHaveTextContent("connection refused");
694
+ });
695
+ });
696
+
697
+ it("passes the form's current effective values to onTest", async () => {
698
+ const user = userEvent.setup();
699
+ const onTest = vi.fn().mockResolvedValue(undefined);
700
+ const result = normalizeFormSpec(connectorSpec);
701
+ if (!result.ok) throw new Error("expected connectorSpec to normalize");
702
+ render(
703
+ <SchemaFormProvider spec={result.spec} values={{ apiKey: "sk-test" }}>
704
+ <SchemaFormRoot>
705
+ <SchemaFormFields />
706
+ <SchemaFormTestAction onTest={onTest} />
707
+ </SchemaFormRoot>
708
+ </SchemaFormProvider>,
709
+ );
710
+
711
+ await user.click(screen.getByRole("button", { name: "Test connection" }));
712
+ expect(onTest).toHaveBeenCalledWith({ apiKey: "sk-test" });
713
+ });
714
+
715
+ it("does not block or get blocked by SchemaFormSubmit's own validation", async () => {
716
+ const user = userEvent.setup();
717
+ const onTest = vi.fn().mockResolvedValue(undefined);
718
+ const { onSubmit } = renderWithTestAction(onTest);
719
+
720
+ await user.click(screen.getByRole("button", { name: "Test connection" }));
721
+ await waitFor(() => {
722
+ expect(screen.getByText("Connected")).toBeInTheDocument();
723
+ });
724
+
725
+ // Submit still enforces the required field independently of the test
726
+ // action having "succeeded" — the two are not wired together.
727
+ await user.click(screen.getByRole("button", { name: "Submit" }));
728
+ expect(onSubmit).not.toHaveBeenCalled();
729
+ expect(screen.getByLabelText(/API key/)).toHaveFocus();
730
+ });
731
+
732
+ // The two tests above prove a SUCCESSFUL test-action doesn't bypass field
733
+ // validation. The maintainer ruling's actual direction is the opposite one
734
+ // — a FAILING or PENDING test-action must never BLOCK an otherwise-valid
735
+ // submit — and that direction was unlocked: a future change wiring
736
+ // `submitting` to the test-action's status would leave every test above
737
+ // green while breaking this. These two lock it, with `apiKey` seeded VALID
738
+ // so submit's own field validation can never be the reason it succeeds or
739
+ // fails.
740
+ function renderWithTestActionAndValidValues(onTest: () => void | Promise<void>) {
741
+ const result = normalizeFormSpec(connectorSpec);
742
+ if (!result.ok) throw new Error("expected connectorSpec to normalize");
743
+ const onSubmit = vi.fn();
744
+ render(
745
+ <SchemaFormProvider spec={result.spec} values={{ apiKey: "sk-test" }} onSubmit={onSubmit}>
746
+ <SchemaFormRoot>
747
+ <SchemaFormFields />
748
+ <SchemaFormTestAction onTest={onTest} />
749
+ <SchemaFormSubmit />
750
+ </SchemaFormRoot>
751
+ </SchemaFormProvider>,
752
+ );
753
+ return { onSubmit };
754
+ }
755
+
756
+ it("a FAILING test-action does not block an otherwise-valid submit", async () => {
757
+ const user = userEvent.setup();
758
+ const onTest = vi.fn().mockRejectedValue(new Error("connection refused"));
759
+ const { onSubmit } = renderWithTestActionAndValidValues(onTest);
760
+
761
+ await user.click(screen.getByRole("button", { name: "Test connection" }));
762
+ await waitFor(() => {
763
+ expect(screen.getByRole("alert")).toHaveTextContent("connection refused");
764
+ });
765
+
766
+ // The test action is in its terminal FAILURE state — submit is wired
767
+ // only to field validity, so a valid form still submits.
768
+ await user.click(screen.getByRole("button", { name: "Submit" }));
769
+ expect(onSubmit).toHaveBeenCalledWith({
770
+ formName: "connector",
771
+ values: expect.objectContaining({ apiKey: "sk-test" }),
772
+ });
773
+ });
774
+
775
+ it("a PENDING test-action does not block an otherwise-valid submit", async () => {
776
+ const user = userEvent.setup();
777
+ let resolveTest: () => void = () => {};
778
+ const onTest = vi.fn(
779
+ () =>
780
+ new Promise<void>((resolve) => {
781
+ resolveTest = resolve;
782
+ }),
783
+ );
784
+ const { onSubmit } = renderWithTestActionAndValidValues(onTest);
785
+
786
+ await user.click(screen.getByRole("button", { name: "Test connection" }));
787
+ expect(screen.getByRole("button", { name: "Testing…" })).toBeInTheDocument();
788
+
789
+ // Still pending — Submit is a wholly separate control: not disabled, not
790
+ // aria-disabled, and not gated by the test action's in-flight state.
791
+ const submitButton = screen.getByRole("button", { name: "Submit" });
792
+ expect(submitButton).not.toHaveAttribute("aria-disabled");
793
+ await user.click(submitButton);
794
+ expect(onSubmit).toHaveBeenCalledWith({
795
+ formName: "connector",
796
+ values: expect.objectContaining({ apiKey: "sk-test" }),
797
+ });
798
+
799
+ // Let the still-in-flight test action settle so it doesn't leak a
800
+ // pending `act()` warning into a later test.
801
+ resolveTest();
802
+ await waitFor(() => {
803
+ expect(screen.getByText("Connected")).toBeInTheDocument();
804
+ });
805
+ });
806
+
807
+ // PR #119 review thread 0 (chatgpt-codex-connector, P2): nothing reset the
808
+ // test result when `effectiveValues` changed after the test STARTED, so a
809
+ // "Connected" badge kept describing values the user had since edited away.
810
+ it("discards a stale success if the field changes while the test is still pending", async () => {
811
+ const user = userEvent.setup();
812
+ let resolveTest: () => void = () => {};
813
+ const onTest = vi.fn(
814
+ () =>
815
+ new Promise<void>((resolve) => {
816
+ resolveTest = resolve;
817
+ }),
818
+ );
819
+ renderWithTestAction(onTest);
820
+
821
+ await user.click(screen.getByRole("button", { name: "Test connection" }));
822
+ expect(screen.getByRole("button", { name: "Testing…" })).toBeInTheDocument();
823
+
824
+ // Edit the field WHILE the test is still in flight, then let it resolve.
825
+ await user.type(screen.getByLabelText(/API key/), "sk-edited");
826
+ resolveTest();
827
+
828
+ // The button returns to idle rather than showing "Connected" for a
829
+ // snapshot of values that is no longer current.
830
+ await waitFor(() => {
831
+ expect(screen.getByRole("button", { name: "Test connection" })).toBeInTheDocument();
832
+ });
833
+ expect(screen.queryByText("Connected")).not.toBeInTheDocument();
834
+ });
835
+
836
+ it("resets a settled success back to idle once the tested field changes", async () => {
837
+ const user = userEvent.setup();
838
+ const onTest = vi.fn().mockResolvedValue(undefined);
839
+ renderWithTestAction(onTest);
840
+
841
+ await user.click(screen.getByRole("button", { name: "Test connection" }));
842
+ await waitFor(() => {
843
+ expect(screen.getByText("Connected")).toBeInTheDocument();
844
+ });
845
+
846
+ // Edit AFTER the test already settled successfully.
847
+ await user.type(screen.getByLabelText(/API key/), "sk-edited");
848
+
849
+ await waitFor(() => {
850
+ expect(screen.queryByText("Connected")).not.toBeInTheDocument();
851
+ });
852
+ expect(screen.getByRole("button", { name: "Test connection" })).toBeInTheDocument();
853
+ });
854
+
855
+ // PR #119 review thread 4 (chatgpt-codex-connector, P2): the test action
856
+ // read neither `submitted` nor `submitting` from context, so it stayed
857
+ // fully actionable on a terminal submitted form or during an in-flight
858
+ // submit — unlike every other control in this file.
859
+ it("is natively disabled once the form has been submitted (durable state)", () => {
860
+ const result = normalizeFormSpec(connectorSpec);
861
+ if (!result.ok) throw new Error("expected connectorSpec to normalize");
862
+ const onTest = vi.fn();
863
+ render(
864
+ <SchemaFormProvider spec={result.spec} values={{ apiKey: "sk-test" }} submitted>
865
+ <SchemaFormRoot>
866
+ <SchemaFormTestAction onTest={onTest} />
867
+ </SchemaFormRoot>
868
+ </SchemaFormProvider>,
869
+ );
870
+ expect(screen.getByRole("button", { name: "Test connection" })).toBeDisabled();
871
+ });
872
+
873
+ it("is natively disabled while the form is submitting (a bystander to the SUBMIT button's own action, like every field)", async () => {
874
+ const user = userEvent.setup();
875
+ const onTest = vi.fn().mockResolvedValue(undefined);
876
+ const result = normalizeFormSpec(connectorSpec);
877
+ if (!result.ok) throw new Error("expected connectorSpec to normalize");
878
+ render(
879
+ <SchemaFormProvider spec={result.spec} values={{ apiKey: "sk-test" }} submitting>
880
+ <SchemaFormRoot>
881
+ <SchemaFormTestAction onTest={onTest} />
882
+ </SchemaFormRoot>
883
+ </SchemaFormProvider>,
884
+ );
885
+ const button = screen.getByRole("button", { name: "Test connection" });
886
+ // `submitting` describes the FORM's submit — an action this button
887
+ // didn't initiate and isn't mid-way through. It is a bystander here the
888
+ // same way a `SchemaFormField` is (`controlDisabled` includes
889
+ // `ctx.submitting`), not the button that was just activated (that's
890
+ // `SchemaFormSubmit`'s own `submitting`, which stays transient). Native
891
+ // `disabled` is correct: nothing keyboard-focused gets stranded, because
892
+ // this button can only reach a focused, un-clicked state via Tab, and a
893
+ // submit can only start from a click/Enter on Submit itself (which takes
894
+ // focus) or from Enter inside a text field (which never touches this
895
+ // button's focus state).
896
+ expect(button).toBeDisabled();
897
+ await user.click(button);
898
+ expect(onTest).not.toHaveBeenCalled();
899
+ });
900
+ });