@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.
- package/dist/client/assets/index-rBYybJbt.js +26 -0
- package/dist/client/index.html +1 -1
- package/dist/client-lib/components/Sidebar.d.ts +1 -1
- package/dist/client-lib/components/Sidebar.d.ts.map +1 -1
- package/dist/server/api.d.ts.map +1 -1
- package/dist/server/api.js +275 -87
- package/dist/server/api.js.map +1 -1
- package/package.json +1 -1
- package/src/client/App.tsx +8 -0
- package/src/client/components/Sidebar.tsx +15 -1
- package/src/client/screens/ManifestScreen.test.tsx +394 -0
- package/src/client/screens/ManifestScreen.tsx +977 -0
- package/src/client/screens/MatrixView.tsx +10 -1
- package/src/client/screens/NamespaceEditor.tsx +13 -3
- package/src/client/screens/ResetScreen.test.tsx +397 -0
- package/src/client/screens/ResetScreen.tsx +614 -0
- package/dist/client/assets/index-CVpAmirt.js +0 -26
|
@@ -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
|
|
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
|
-
|
|
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
|
+
});
|