Open History to see the last commit
1import { act, fakeTimers, render, type Screen } from "../../../test/render";
2import { RegistryProvider } from "@effect/atom-react";
3import { DEFAULT_SERVER_SETTINGS, type StorageCleanupReport } from "@t3tools/contracts";
4import { type ReactNode } from "react";
5import { expect, it, vi } from "vite-plus/test";
6import { userEvent } from "vite-plus/test/browser";
7
8import "~/index.css";
9
10const state = vi.hoisted(() => ({
11 settings: {} as typeof DEFAULT_SERVER_SETTINGS,
12 targets: [] as Array<{ environmentId: string; settings: typeof DEFAULT_SERVER_SETTINGS }>,
13 run: vi.fn(),
14 update: vi.fn(),
15 report: null as StorageCleanupReport | null,
16}));
17
18vi.mock(import("../../state/server"), async (original) => {
19 const actual = await original();
20 const { Atom } = await import("effect/reactivity");
21 const report = Atom.make(() => ({ _tag: "Success", value: state.report, waiting: false }));
22 const allowed = Atom.make(true);
23 return {
24 ...actual,
25 serverEnvironment: {
26 ...actual.serverEnvironment,
27 storageCleanupReport: (() => report) as never,
28 runStorageCleanup: {
29 ...actual.serverEnvironment.runStorageCleanup,
30 permissionAtom: () => allowed,
31 },
32 },
33 };
34});
35vi.mock(import("../../state/use-atom-command"), async (original) => ({
36 ...(await original()),
37 useAtomCommand: (() => state.run) as never,
38}));
39vi.mock(import("./SettingsScopeContext"), async (original) => ({
40 ...(await original()),
41 useSettingsScope: (() => {
42 const environments = state.targets.map(({ environmentId, settings }) => ({
43 environmentId,
44 label: environmentId,
45 serverConfig: {
46 settings,
47 environment: {
48 capabilities: {
49 storageCleanup: true,
50 storageCleanupRun: true,
51 settledWorktreeCleanup: true,
52 },
53 },
54 },
55 }));
56 return {
57 scope: { kind: "all" },
58 environments,
59 connectedEnvironments: environments,
60 targets: state.targets,
61 target: null,
62 };
63 }) as never,
64}));
65vi.mock(import("./useScopedSettings"), async (original) => ({
66 ...(await original()),
67 useScopedSettings: (() => state.settings) as never,
68 useScopedSettingsMixed: () => false,
69 useUpdateScopedSettings: () => state.update,
70 useClearScopedSettings: () => vi.fn(),
71}));
72vi.mock(import("./settingsLayout"), async (original) => ({
73 ...(await original()),
74 SettingsPageContainer: (({ children }: { children: ReactNode }) => children) as never,
75 SettingsRow: ({ title, description, status, control, children }) => (
76 <div>
77 <h3>{title}</h3>
78 {description}
79 {status && (
80 <div
81 data-testid={`${title === "Delete old browser artifacts" ? "artifact" : "logs"}-retention-status`}
82 >
83 {status}
84 </div>
85 )}
86 {control}
87 {children}
88 </div>
89 ),
90 SettingResetButton: (() => null) as never,
91}));
92vi.mock(import("../ui/toast"), async (original) => {
93 const actual = await original();
94 return { ...actual, toastManager: { ...actual.toastManager, add: vi.fn() } };
95});
96
97import { StorageCleanupSections } from "./StorageSettings";
98
99function setup() {
100 state.settings = {
101 ...DEFAULT_SERVER_SETTINGS,
102 storageCleanup: {
103 worktreeKeepWhen: "uncommitted-changes",
104 worktreeAfterDays: null,
105 worktreeSettledAfterDays: null,
106 worktreeOnMerge: false,
107 worktreeOnDelete: false,
108 worktreeUnchanged: false,
109 browserArtifactsAfterDays: null,
110 logsAfterDays: null,
111 },
112 projectSettingsOverrides: {},
113 };
114 state.targets = ["env-a", "env-b"].map((environmentId) => ({
115 environmentId,
116 settings: state.settings,
117 }));
118 state.report = null;
119 state.run.mockReset();
120 state.update.mockReset();
121}
122
123function setRetentionValues(
124 artifactDays: ReadonlyArray<number | null>,
125 logDays: ReadonlyArray<number | null> = artifactDays.map(() => null),
126 environmentIds: readonly string[] = ["env-a", "env-b"],
127) {
128 state.targets = artifactDays.map((browserArtifactsAfterDays, index) => ({
129 environmentId: environmentIds[index]!,
130 settings: {
131 ...state.settings,
132 storageCleanup: {
133 ...state.settings.storageCleanup,
134 browserArtifactsAfterDays,
135 logsAfterDays: logDays[index]!,
136 },
137 },
138 }));
139 state.settings = state.targets[0]?.settings ?? state.settings;
140}
141
142const panel = () => (
143 <RegistryProvider>
144 <StorageCleanupSections />
145 </RegistryProvider>
146);
147const deleteButton = (container: HTMLElement) =>
148 Array.from(container.querySelectorAll("button")).find(
149 (button) => button.textContent === "Delete now",
150 )!;
151
152it("keeps cleanup disabled when settled retention and all other rules are off", async () => {
153 setup();
154 await using screen = await render(panel());
155 const button = deleteButton(screen.container);
156
157 expect(button.disabled).toBe(true);
158 await act(async () => button.click());
159 expect(state.run).not.toHaveBeenCalled();
160});
161
162const rule = "Delete old browser artifacts";
163const ruleSwitch = (screen: Screen) => screen.getByRole("switch", { name: rule });
164const daysInput = (screen: Screen) => screen.getByLabelText(`${rule} in days`);
165const saves = () => state.update.mock.calls.map(([patch]) => patch.storageCleanup);
166const failSave = (index: number) => act(() => state.update.mock.calls[index]?.[1]?.onFailed?.());
167const withSavedAge = (days: number) => {
168 state.settings = {
169 ...state.settings,
170 storageCleanup: { ...state.settings.storageCleanup, browserArtifactsAfterDays: days },
171 };
172};
173
174it("saves a newly enabled retention rule only once its age is committed", async () => {
175 setup();
176 await using screen = await render(panel());
177 await userEvent.click(ruleSwitch(screen));
178 expect(state.update).not.toHaveBeenCalled();
179 await expect.element(ruleSwitch(screen)).toBeChecked();
180 await expect.element(daysInput(screen)).toHaveFocus();
181
182 await userEvent.fill(daysInput(screen), "30");
183 await userEvent.keyboard("{Enter}");
184 expect(saves()).toEqual([{ browserArtifactsAfterDays: 30 }]);
185 await expect.element(ruleSwitch(screen)).toBeChecked();
186});
187
188it("discards a retention draft on Escape, on leaving it untouched, or when switched back off", async () => {
189 setup();
190 await using screen = await render(panel());
191 await userEvent.click(ruleSwitch(screen));
192 await expect.element(daysInput(screen)).toHaveFocus();
193 await userEvent.keyboard("{Escape}");
194 await expect.element(ruleSwitch(screen)).not.toBeChecked();
195
196 await userEvent.click(ruleSwitch(screen));
197 await expect.element(daysInput(screen)).toHaveFocus();
198 await userEvent.click(screen.getByRole("heading", { name: rule }));
199 await expect.element(ruleSwitch(screen)).not.toBeChecked();
200
201 await userEvent.click(ruleSwitch(screen));
202 await userEvent.fill(daysInput(screen), "30");
203 await userEvent.click(ruleSwitch(screen));
204 await expect.element(daysInput(screen)).not.toBeInTheDocument();
205 await expect.element(ruleSwitch(screen)).not.toBeChecked();
206 expect(state.update).not.toHaveBeenCalled();
207});
208
209it("saves an edited draft when focus leaves its row, not when it reaches the switch", async () => {
210 setup();
211 await using screen = await render(panel());
212 await userEvent.click(ruleSwitch(screen));
213 await userEvent.fill(daysInput(screen), "30");
214 await userEvent.tab();
215 await expect.element(ruleSwitch(screen)).toHaveFocus();
216 expect(state.update).not.toHaveBeenCalled();
217 await userEvent.tab();
218 expect(saves()).toEqual([{ browserArtifactsAfterDays: 30 }]);
219});
220
221it("saves a stepped first age only once it is committed", async () => {
222 setup();
223 await using screen = await render(panel());
224 await userEvent.click(ruleSwitch(screen));
225 await userEvent.click(screen.getByRole("button", { name: `Increase ${rule}` }));
226 await userEvent.keyboard("{ArrowUp}");
227 await expect.element(daysInput(screen)).toHaveValue("10");
228 expect(state.update).not.toHaveBeenCalled();
229 await userEvent.keyboard("{Enter}");
230 expect(saves()).toEqual([{ browserArtifactsAfterDays: 10 }]);
231});
232
233it("does not save the draft age when Enter is pressed on invalid text", async () => {
234 setup();
235 await using screen = await render(panel());
236 await userEvent.click(ruleSwitch(screen));
237 await userEvent.fill(daysInput(screen), "+");
238 await userEvent.keyboard("{Enter}");
239 expect(state.update).not.toHaveBeenCalled();
240});
241
242it("switching an edited saved rule off never saves its age afterwards", async () => {
243 setup();
244 withSavedAge(30);
245 await using screen = await render(panel());
246 await userEvent.click(daysInput(screen));
247 await userEvent.keyboard("{Control>}a{/Control}40");
248 await userEvent.click(ruleSwitch(screen));
249 await expect.element(ruleSwitch(screen)).not.toBeChecked();
250 await userEvent.click(screen.getByRole("heading", { name: rule }));
251 expect(saves()).toEqual([{ browserArtifactsAfterDays: null }]);
252});
253
254it("returns a newly enabled retention rule to off when its first save fails", async () => {
255 setup();
256 await using screen = await render(panel());
257 await userEvent.click(ruleSwitch(screen));
258 await expect.element(daysInput(screen)).toHaveFocus();
259 await userEvent.keyboard("{Enter}");
260 expect(saves()).toEqual([{ browserArtifactsAfterDays: 8 }]);
261
262 await failSave(0);
263 await expect.element(ruleSwitch(screen)).not.toBeChecked();
264});
265
266it("keeps a newer pending age open when an older first save fails", async () => {
267 setup();
268 await using screen = await render(panel());
269 await userEvent.click(ruleSwitch(screen));
270 for (const age of ["30", "60"]) {
271 await userEvent.fill(daysInput(screen), age);
272 await userEvent.keyboard("{Enter}");
273 }
274 expect(saves()).toHaveLength(2);
275
276 await failSave(0);
277 await expect.element(daysInput(screen)).toHaveValue("60");
278 await expect.element(ruleSwitch(screen)).toBeChecked();
279});
280
281it("settles mixed retention statuses independently across selected machines", async () => {
282 setup();
283 using _timers = fakeTimers();
284 await using screen = await render(panel());
285 const advance = (ms: number) => act(() => vi.advanceTimersByTime(ms));
286
287 await advance(300);
288 setRetentionValues([8, null], [8, null]);
289 await screen.rerender(panel());
290 await advance(125);
291 await expect.element(screen.getByTestId("artifact-retention-status")).not.toBeInTheDocument();
292 await expect.element(screen.getByTestId("logs-retention-status")).not.toBeInTheDocument();
293 setRetentionValues([null, null], [8, null]);
294 await screen.rerender(panel());
295 await advance(8);
296 await expect.element(screen.getByTestId("logs-retention-status")).not.toBeInTheDocument();
297 await advance(117);
298 await expect.element(screen.getByTestId("logs-retention-status")).not.toBeInTheDocument();
299 setRetentionValues([null, null]);
300 await screen.rerender(panel());
301 await advance(300);
302 await expect.element(screen.getByTestId("artifact-retention-status")).not.toBeInTheDocument();
303 await expect.element(screen.getByTestId("logs-retention-status")).not.toBeInTheDocument();
304
305 setRetentionValues([8, null]);
306 await screen.rerender(panel());
307 await advance(300);
308 await expect
309 .element(screen.getByTestId("artifact-retention-status"))
310 .toHaveTextContent("Mixed across selected machines");
311 await expect.element(screen.getByTestId("logs-retention-status")).not.toBeInTheDocument();
312
313 setRetentionValues([null, null]);
314 await screen.rerender(panel());
315 await advance(75);
316 await expect
317 .element(screen.getByTestId("artifact-retention-status"))
318 .toHaveTextContent("Mixed across selected machines");
319 setRetentionValues([8, null]);
320 await screen.rerender(panel());
321 await advance(133);
322 await expect
323 .element(screen.getByTestId("artifact-retention-status"))
324 .toHaveTextContent("Mixed across selected machines");
325 setRetentionValues([null, null]);
326 await screen.rerender(panel());
327 await advance(133);
328 await expect
329 .element(screen.getByTestId("artifact-retention-status"))
330 .toHaveTextContent("Mixed across selected machines");
331 await advance(117);
332 await expect
333 .element(screen.getByTestId("artifact-retention-status"))
334 .toHaveTextContent("Mixed across selected machines");
335 setRetentionValues([8, null]);
336 await screen.rerender(panel());
337 await advance(300);
338 await expect
339 .element(screen.getByTestId("artifact-retention-status"))
340 .toHaveTextContent("Mixed across selected machines");
341
342 setRetentionValues([8], [null], ["env-c"]);
343 await screen.rerender(panel());
344 await expect.element(screen.getByTestId("artifact-retention-status")).not.toBeInTheDocument();
345
346 setRetentionValues([null, null], [null, null], ["env-c", "env-d"]);
347 await screen.rerender(panel());
348 await expect.element(screen.getByTestId("artifact-retention-status")).not.toBeInTheDocument();
349
350 setRetentionValues([null, null], [8, null]);
351 await screen.rerender(panel());
352 await advance(300);
353 await expect.element(screen.getByTestId("artifact-retention-status")).not.toBeInTheDocument();
354 await expect
355 .element(screen.getByTestId("logs-retention-status"))
356 .toHaveTextContent("Mixed across selected machines");
357 setRetentionValues([null, null]);
358 await screen.rerender(panel());
359 await advance(300);
360 await expect.element(screen.getByTestId("logs-retention-status")).not.toBeInTheDocument();
361});