@clef-sh/ui 0.1.14-beta.95 → 0.1.15-beta.97

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.
@@ -50,7 +50,16 @@ export function MatrixView({ setView, setNs, manifest, matrixStatuses }: MatrixV
50
50
  actions={
51
51
  <>
52
52
  <Button onClick={() => setView("lint")}>Lint All</Button>
53
- <Button variant="primary">+ Namespace</Button>
53
+ <Button onClick={() => setView("manifest")} data-testid="matrix-add-environment-btn">
54
+ + Environment
55
+ </Button>
56
+ <Button
57
+ variant="primary"
58
+ onClick={() => setView("manifest")}
59
+ data-testid="matrix-add-namespace-btn"
60
+ >
61
+ + Namespace
62
+ </Button>
54
63
  </>
55
64
  }
56
65
  />
@@ -20,7 +20,7 @@ interface EditorRow {
20
20
  interface NamespaceEditorProps {
21
21
  ns: string;
22
22
  manifest: ClefManifest | null;
23
- onCommit: (message: string) => void;
23
+ onCommit: (message: string) => Promise<void>;
24
24
  }
25
25
 
26
26
  export function NamespaceEditor({ ns, manifest, onCommit }: NamespaceEditorProps) {
@@ -129,9 +129,15 @@ export function NamespaceEditor({ ns, manifest, onCommit }: NamespaceEditorProps
129
129
 
130
130
  const handleSave = async (confirmed?: boolean) => {
131
131
  const dirtyRows = rows.filter((r) => r.edited);
132
+ // The edit-multiple-rows flow batches: each PUT writes without committing
133
+ // (commit: false), then a single explicit POST /api/git/commit at the end
134
+ // wraps all the changes in one commit. The other write paths in this
135
+ // editor (handleAdd, handleDelete, handleResetToRandom, handleAccept)
136
+ // omit the commit flag so each one auto-commits — which is what users
137
+ // want for single-action mutations.
132
138
  await Promise.all(
133
139
  dirtyRows.map((row) => {
134
- const payload: Record<string, unknown> = { value: row.value };
140
+ const payload: Record<string, unknown> = { value: row.value, commit: false };
135
141
  if (confirmed) payload.confirmed = true;
136
142
  return apiFetch(`/api/namespace/${ns}/${env}/${row.key}`, {
137
143
  method: "PUT",
@@ -141,7 +147,11 @@ export function NamespaceEditor({ ns, manifest, onCommit }: NamespaceEditorProps
141
147
  }),
142
148
  );
143
149
  if (commitMessage) {
144
- onCommit(commitMessage);
150
+ // Await the commit so the working tree is clean by the time handleSave
151
+ // returns. Without this, subsequent transactional operations (here in
152
+ // the UI or via the CLI) would race against the in-flight commit and
153
+ // hit the dirty-tree preflight refusal.
154
+ await onCommit(commitMessage);
145
155
  }
146
156
  setShowCommitInput(false);
147
157
  setCommitMessage("");
@@ -0,0 +1,397 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
3
+ import "@testing-library/jest-dom";
4
+ import { ResetScreen } from "./ResetScreen";
5
+ import type { ClefManifest } from "@clef-sh/core";
6
+
7
+ jest.mock("../api", () => ({
8
+ apiFetch: jest.fn(),
9
+ }));
10
+
11
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
12
+ const { apiFetch } = require("../api") as { apiFetch: jest.Mock };
13
+
14
+ const manifest: ClefManifest = {
15
+ version: 1,
16
+ environments: [
17
+ { name: "staging", description: "Staging" },
18
+ { name: "production", description: "Production", protected: true },
19
+ ],
20
+ namespaces: [
21
+ { name: "database", description: "Database" },
22
+ { name: "api", description: "API" },
23
+ ],
24
+ sops: { default_backend: "age" },
25
+ file_pattern: "{namespace}/{environment}.enc.yaml",
26
+ };
27
+
28
+ const setView = jest.fn();
29
+ const reloadManifest = jest.fn();
30
+
31
+ function renderScreen() {
32
+ return render(
33
+ <ResetScreen manifest={manifest} setView={setView} reloadManifest={reloadManifest} />,
34
+ );
35
+ }
36
+
37
+ function mockResetSuccess(overrides = {}) {
38
+ apiFetch.mockResolvedValue({
39
+ ok: true,
40
+ json: () =>
41
+ Promise.resolve({
42
+ success: true,
43
+ result: {
44
+ scaffoldedCells: ["/repo/database/staging.enc.yaml"],
45
+ pendingKeysByCell: {},
46
+ backendChanged: false,
47
+ affectedEnvironments: ["staging"],
48
+ ...overrides,
49
+ },
50
+ }),
51
+ });
52
+ }
53
+
54
+ beforeEach(() => {
55
+ jest.clearAllMocks();
56
+ });
57
+
58
+ describe("ResetScreen — scope picker", () => {
59
+ it("renders all three scope kinds", () => {
60
+ renderScreen();
61
+ expect(screen.getByTestId("reset-scope-env")).toBeTruthy();
62
+ expect(screen.getByTestId("reset-scope-namespace")).toBeTruthy();
63
+ expect(screen.getByTestId("reset-scope-cell")).toBeTruthy();
64
+ });
65
+
66
+ it("defaults to env scope and shows env dropdown populated from manifest", () => {
67
+ renderScreen();
68
+ const select = screen.getByTestId("reset-env-select") as HTMLSelectElement;
69
+ expect(select).toBeTruthy();
70
+ expect(select.value).toBe("staging");
71
+ // Both envs in the dropdown
72
+ expect(screen.queryByTestId("reset-namespace-select")).toBeNull();
73
+ expect(screen.queryByTestId("reset-cell-namespace-select")).toBeNull();
74
+ });
75
+
76
+ it("switches to namespace scope and shows namespace dropdown", () => {
77
+ renderScreen();
78
+ fireEvent.click(screen.getByTestId("reset-scope-namespace"));
79
+ const select = screen.getByTestId("reset-namespace-select") as HTMLSelectElement;
80
+ expect(select.value).toBe("database");
81
+ expect(screen.queryByTestId("reset-env-select")).toBeNull();
82
+ });
83
+
84
+ it("switches to cell scope and shows two dropdowns", () => {
85
+ renderScreen();
86
+ fireEvent.click(screen.getByTestId("reset-scope-cell"));
87
+ expect(screen.getByTestId("reset-cell-namespace-select")).toBeTruthy();
88
+ expect(screen.getByTestId("reset-cell-env-select")).toBeTruthy();
89
+ expect(screen.queryByTestId("reset-env-select")).toBeNull();
90
+ });
91
+ });
92
+
93
+ describe("ResetScreen — backend disclosure", () => {
94
+ it("hides backend picker by default", () => {
95
+ renderScreen();
96
+ expect(screen.queryByTestId("reset-backend-radio-awskms")).toBeNull();
97
+ });
98
+
99
+ it("reveals backend picker when checkbox is checked", () => {
100
+ renderScreen();
101
+ fireEvent.click(screen.getByTestId("reset-switch-backend"));
102
+ expect(screen.getByTestId("reset-backend-radio-age")).toBeTruthy();
103
+ expect(screen.getByTestId("reset-backend-radio-awskms")).toBeTruthy();
104
+ });
105
+
106
+ it("shows key input only for non-age backends", () => {
107
+ renderScreen();
108
+ fireEvent.click(screen.getByTestId("reset-switch-backend"));
109
+ // age is the default — no key input
110
+ expect(screen.queryByTestId("reset-backend-key-input")).toBeNull();
111
+ fireEvent.click(screen.getByTestId("reset-backend-radio-awskms"));
112
+ expect(screen.getByTestId("reset-backend-key-input")).toBeTruthy();
113
+ });
114
+ });
115
+
116
+ describe("ResetScreen — typed confirmation gate", () => {
117
+ it("disables Reset button when confirmation does not match", () => {
118
+ renderScreen();
119
+ const button = screen.getByTestId("reset-submit") as HTMLButtonElement;
120
+ expect(button.disabled).toBe(true);
121
+ });
122
+
123
+ it("enables Reset button only when typed confirmation matches the scope label", () => {
124
+ renderScreen();
125
+ const button = screen.getByTestId("reset-submit") as HTMLButtonElement;
126
+ const input = screen.getByTestId("reset-confirm-input") as HTMLInputElement;
127
+
128
+ fireEvent.change(input, { target: { value: "wrong" } });
129
+ expect(button.disabled).toBe(true);
130
+
131
+ fireEvent.change(input, { target: { value: "env staging" } });
132
+ expect(button.disabled).toBe(false);
133
+ });
134
+
135
+ it("re-disables the button when scope changes", () => {
136
+ renderScreen();
137
+ const button = screen.getByTestId("reset-submit") as HTMLButtonElement;
138
+ const input = screen.getByTestId("reset-confirm-input") as HTMLInputElement;
139
+
140
+ fireEvent.change(input, { target: { value: "env staging" } });
141
+ expect(button.disabled).toBe(false);
142
+
143
+ // Switch to namespace scope — typed confirm is cleared
144
+ fireEvent.click(screen.getByTestId("reset-scope-namespace"));
145
+ expect(button.disabled).toBe(true);
146
+ });
147
+
148
+ it("uses ns/env format for cell scope confirmation", () => {
149
+ renderScreen();
150
+ fireEvent.click(screen.getByTestId("reset-scope-cell"));
151
+ const input = screen.getByTestId("reset-confirm-input") as HTMLInputElement;
152
+ const button = screen.getByTestId("reset-submit") as HTMLButtonElement;
153
+
154
+ fireEvent.change(input, { target: { value: "database/staging" } });
155
+ expect(button.disabled).toBe(false);
156
+ });
157
+
158
+ it("blocks submit when backend switch is on but key is missing", () => {
159
+ renderScreen();
160
+ fireEvent.click(screen.getByTestId("reset-switch-backend"));
161
+ fireEvent.click(screen.getByTestId("reset-backend-radio-awskms"));
162
+ const input = screen.getByTestId("reset-confirm-input") as HTMLInputElement;
163
+ fireEvent.change(input, { target: { value: "env staging" } });
164
+
165
+ const button = screen.getByTestId("reset-submit") as HTMLButtonElement;
166
+ expect(button.disabled).toBe(true);
167
+
168
+ fireEvent.change(screen.getByTestId("reset-backend-key-input"), {
169
+ target: { value: "arn:aws:kms:us-east-1:123:key/new" },
170
+ });
171
+ expect(button.disabled).toBe(false);
172
+ });
173
+ });
174
+
175
+ describe("ResetScreen — submit", () => {
176
+ it("POSTs to /api/reset with the env scope", async () => {
177
+ mockResetSuccess();
178
+ renderScreen();
179
+ fireEvent.change(screen.getByTestId("reset-confirm-input"), {
180
+ target: { value: "env staging" },
181
+ });
182
+ await act(async () => {
183
+ fireEvent.click(screen.getByTestId("reset-submit"));
184
+ });
185
+
186
+ expect(apiFetch).toHaveBeenCalledWith(
187
+ "/api/reset",
188
+ expect.objectContaining({
189
+ method: "POST",
190
+ body: expect.stringContaining('"scope":{"kind":"env","name":"staging"}'),
191
+ }),
192
+ );
193
+ expect(reloadManifest).toHaveBeenCalled();
194
+ });
195
+
196
+ it("POSTs the namespace scope", async () => {
197
+ mockResetSuccess({ scaffoldedCells: ["/repo/database/s.enc.yaml"] });
198
+ renderScreen();
199
+ fireEvent.click(screen.getByTestId("reset-scope-namespace"));
200
+ fireEvent.change(screen.getByTestId("reset-confirm-input"), {
201
+ target: { value: "namespace database" },
202
+ });
203
+ await act(async () => {
204
+ fireEvent.click(screen.getByTestId("reset-submit"));
205
+ });
206
+
207
+ const callBody = JSON.parse(apiFetch.mock.calls[0][1].body);
208
+ expect(callBody.scope).toEqual({ kind: "namespace", name: "database" });
209
+ });
210
+
211
+ it("POSTs the cell scope", async () => {
212
+ mockResetSuccess();
213
+ renderScreen();
214
+ fireEvent.click(screen.getByTestId("reset-scope-cell"));
215
+ fireEvent.change(screen.getByTestId("reset-confirm-input"), {
216
+ target: { value: "database/staging" },
217
+ });
218
+ await act(async () => {
219
+ fireEvent.click(screen.getByTestId("reset-submit"));
220
+ });
221
+
222
+ const callBody = JSON.parse(apiFetch.mock.calls[0][1].body);
223
+ expect(callBody.scope).toEqual({
224
+ kind: "cell",
225
+ namespace: "database",
226
+ environment: "staging",
227
+ });
228
+ });
229
+
230
+ it("includes optional backend + key when backend switch is enabled", async () => {
231
+ mockResetSuccess({ backendChanged: true });
232
+ renderScreen();
233
+ fireEvent.click(screen.getByTestId("reset-switch-backend"));
234
+ fireEvent.click(screen.getByTestId("reset-backend-radio-awskms"));
235
+ fireEvent.change(screen.getByTestId("reset-backend-key-input"), {
236
+ target: { value: "arn:aws:kms:us-east-1:123:key/new" },
237
+ });
238
+ fireEvent.change(screen.getByTestId("reset-confirm-input"), {
239
+ target: { value: "env staging" },
240
+ });
241
+ await act(async () => {
242
+ fireEvent.click(screen.getByTestId("reset-submit"));
243
+ });
244
+
245
+ const callBody = JSON.parse(apiFetch.mock.calls[0][1].body);
246
+ expect(callBody.backend).toBe("awskms");
247
+ expect(callBody.key).toBe("arn:aws:kms:us-east-1:123:key/new");
248
+ });
249
+
250
+ it("includes parsed comma-separated keys", async () => {
251
+ mockResetSuccess();
252
+ renderScreen();
253
+ fireEvent.change(screen.getByTestId("reset-keys-input"), {
254
+ target: { value: " DB_URL , DB_PASSWORD " },
255
+ });
256
+ fireEvent.change(screen.getByTestId("reset-confirm-input"), {
257
+ target: { value: "env staging" },
258
+ });
259
+ await act(async () => {
260
+ fireEvent.click(screen.getByTestId("reset-submit"));
261
+ });
262
+
263
+ const callBody = JSON.parse(apiFetch.mock.calls[0][1].body);
264
+ expect(callBody.keys).toEqual(["DB_URL", "DB_PASSWORD"]);
265
+ });
266
+
267
+ it("omits keys when input is blank", async () => {
268
+ mockResetSuccess();
269
+ renderScreen();
270
+ fireEvent.change(screen.getByTestId("reset-confirm-input"), {
271
+ target: { value: "env staging" },
272
+ });
273
+ await act(async () => {
274
+ fireEvent.click(screen.getByTestId("reset-submit"));
275
+ });
276
+
277
+ const callBody = JSON.parse(apiFetch.mock.calls[0][1].body);
278
+ expect(callBody.keys).toBeUndefined();
279
+ });
280
+ });
281
+
282
+ describe("ResetScreen — result reporting", () => {
283
+ it("shows scaffolded count and pending count on success", async () => {
284
+ apiFetch.mockResolvedValue({
285
+ ok: true,
286
+ json: () =>
287
+ Promise.resolve({
288
+ success: true,
289
+ result: {
290
+ scaffoldedCells: [
291
+ "/repo/database/staging.enc.yaml",
292
+ "/repo/database/production.enc.yaml",
293
+ ],
294
+ pendingKeysByCell: {
295
+ "/repo/database/staging.enc.yaml": ["DB_URL"],
296
+ "/repo/database/production.enc.yaml": ["DB_URL", "DB_PASSWORD"],
297
+ },
298
+ backendChanged: false,
299
+ affectedEnvironments: ["staging", "production"],
300
+ },
301
+ }),
302
+ });
303
+ renderScreen();
304
+ fireEvent.click(screen.getByTestId("reset-scope-namespace"));
305
+ fireEvent.change(screen.getByTestId("reset-confirm-input"), {
306
+ target: { value: "namespace database" },
307
+ });
308
+ await act(async () => {
309
+ fireEvent.click(screen.getByTestId("reset-submit"));
310
+ });
311
+
312
+ expect(await screen.findByTestId("reset-done")).toBeTruthy();
313
+ expect(screen.getByText("2 cells scaffolded, 3 pending placeholders")).toBeTruthy();
314
+ });
315
+
316
+ it("shows backend override notice when backend changed", async () => {
317
+ mockResetSuccess({ backendChanged: true, affectedEnvironments: ["staging"] });
318
+ renderScreen();
319
+ fireEvent.change(screen.getByTestId("reset-confirm-input"), {
320
+ target: { value: "env staging" },
321
+ });
322
+ await act(async () => {
323
+ fireEvent.click(screen.getByTestId("reset-submit"));
324
+ });
325
+
326
+ expect(await screen.findByText("Backend override written for: staging")).toBeTruthy();
327
+ });
328
+
329
+ it("navigates to matrix on View in Matrix click", async () => {
330
+ mockResetSuccess();
331
+ renderScreen();
332
+ fireEvent.change(screen.getByTestId("reset-confirm-input"), {
333
+ target: { value: "env staging" },
334
+ });
335
+ await act(async () => {
336
+ fireEvent.click(screen.getByTestId("reset-submit"));
337
+ });
338
+
339
+ fireEvent.click(await screen.findByTestId("reset-view-matrix"));
340
+ expect(setView).toHaveBeenCalledWith("matrix");
341
+ });
342
+
343
+ it("returns to idle on Reset another click", async () => {
344
+ mockResetSuccess();
345
+ renderScreen();
346
+ fireEvent.change(screen.getByTestId("reset-confirm-input"), {
347
+ target: { value: "env staging" },
348
+ });
349
+ await act(async () => {
350
+ fireEvent.click(screen.getByTestId("reset-submit"));
351
+ });
352
+
353
+ expect(await screen.findByTestId("reset-done")).toBeTruthy();
354
+ fireEvent.click(screen.getByTestId("reset-start-over"));
355
+ expect(screen.getByTestId("reset-submit")).toBeTruthy();
356
+ // Typed confirm is cleared
357
+ expect((screen.getByTestId("reset-confirm-input") as HTMLInputElement).value).toBe("");
358
+ });
359
+ });
360
+
361
+ describe("ResetScreen — error handling", () => {
362
+ it("renders error banner on 4xx with the server message", async () => {
363
+ apiFetch.mockResolvedValue({
364
+ ok: false,
365
+ json: () => Promise.resolve({ error: "Environment 'staging' not found in manifest." }),
366
+ });
367
+ renderScreen();
368
+ fireEvent.change(screen.getByTestId("reset-confirm-input"), {
369
+ target: { value: "env staging" },
370
+ });
371
+ await act(async () => {
372
+ fireEvent.click(screen.getByTestId("reset-submit"));
373
+ });
374
+
375
+ await waitFor(() => {
376
+ expect(screen.getByTestId("reset-error")).toBeTruthy();
377
+ });
378
+ expect(screen.getByTestId("reset-error").textContent).toContain("not found");
379
+ // Returns to idle so user can adjust
380
+ expect(screen.getByTestId("reset-submit")).toBeTruthy();
381
+ });
382
+
383
+ it("renders fallback error message when network throws", async () => {
384
+ apiFetch.mockRejectedValue(new Error("network down"));
385
+ renderScreen();
386
+ fireEvent.change(screen.getByTestId("reset-confirm-input"), {
387
+ target: { value: "env staging" },
388
+ });
389
+ await act(async () => {
390
+ fireEvent.click(screen.getByTestId("reset-submit"));
391
+ });
392
+
393
+ await waitFor(() => {
394
+ expect(screen.getByTestId("reset-error").textContent).toContain("network down");
395
+ });
396
+ });
397
+ });