1// @vitest-environment jsdom
2import { RegistryProvider } from "@effect/atom-react";
3import { DEFAULT_SERVER_SETTINGS, ProjectId, type StorageCleanupReport } from "@t3tools/contracts";
4import { act, type ReactNode } from "react";
5import { createRoot, type Root } from "react-dom/client";
6import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
7
8const state = vi.hoisted(() => ({
9 settings: {} as typeof DEFAULT_SERVER_SETTINGS,
10 run: vi.fn(),
11}));
12
13vi.mock("../../state/server", async () => {
14 const { Atom } = await import("effect/reactivity");
15 const report = Atom.make({ _tag: "Success", value: null, waiting: false });
16 const allowed = Atom.make(true);
17 return {
18 serverEnvironment: {
19 storageCleanupReport: () => report,
20 runStorageCleanup: { permissionAtom: () => allowed },
21 },
22 };
23});
24vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.run }));
25vi.mock("./SettingsScopeContext", () => ({
26 useSettingsScope: () => {
27 const environment = {
28 environmentId: "env",
29 label: "Machine",
30 serverConfig: {
31 settings: state.settings,
32 environment: {
33 capabilities: {
34 storageCleanup: true,
35 storageCleanupRun: true,
36 settledWorktreeCleanup: true,
37 },
38 },
39 },
40 };
41 return {
42 scope: { kind: "environment" },
43 environments: [environment],
44 connectedEnvironments: [environment],
45 targets: [],
46 target: null,
47 };
48 },
49}));
50vi.mock("./useScopedSettings", () => ({
51 useScopedSettings: () => state.settings,
52 useScopedSettingsMixed: () => false,
53 useUpdateScopedSettings: () => vi.fn(),
54 useClearScopedSettings: () => vi.fn(),
55}));
56vi.mock("./settingsLayout", () => ({
57 SettingsPageContainer: ({ children }: { children: ReactNode }) => children,
58 SettingsSection: ({ children }: { children: ReactNode }) => children,
59 SettingsRow: ({ description, control }: { description: ReactNode; control: ReactNode }) => (
60 <div>
61 {description}
62 {control}
63 </div>
64 ),
65 SettingResetButton: () => null,
66 useRelativeTimeTick: () => undefined,
67}));
68vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } }));
69
70import { StorageSettingsPanel } from "./StorageSettings";
71
72let root: Root;
73let container: HTMLDivElement;
74
75beforeEach(() => {
76 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
77 state.settings = {
78 ...DEFAULT_SERVER_SETTINGS,
79 storageCleanup: {
80 worktreeKeepWhen: "uncommitted-changes",
81 worktreeAfterDays: null,
82 worktreeSettledAfterDays: null,
83 worktreeOnMerge: false,
84 worktreeOnDelete: false,
85 worktreeUnchanged: false,
86 browserArtifactsAfterDays: null,
87 logsAfterDays: null,
88 },
89 projectSettingsOverrides: {},
90 };
91 state.run.mockReset();
92 container = document.createElement("div");
93 document.body.append(container);
94 root = createRoot(container);
95});
96
97afterEach(async () => {
98 await act(async () => root.unmount());
99 container.remove();
100 vi.unstubAllGlobals();
101});
102
103async function render() {
104 await act(async () =>
105 root.render(
106 <RegistryProvider>
107 <StorageSettingsPanel />
108 </RegistryProvider>,
109 ),
110 );
111 return Array.from(container.querySelectorAll("button")).find(
112 (button) => button.textContent === "Delete now",
113 )!;
114}
115
116it.each(["machine-wide immediate", "project custom"])(
117 "runs cleanup when only %s settled retention is enabled",
118 async (scope) => {
119 if (scope === "machine-wide immediate") {
120 state.settings = {
121 ...state.settings,
122 storageCleanup: { ...state.settings.storageCleanup, worktreeSettledAfterDays: 0 },
123 };
124 } else {
125 state.settings = {
126 ...state.settings,
127 projectSettingsOverrides: {
128 [ProjectId.make("project")]: {
129 worktreeCleanup: {
130 mode: "custom",
131 rules: { ...state.settings.storageCleanup, worktreeSettledAfterDays: 7 },
132 },
133 },
134 },
135 };
136 }
137 const report: StorageCleanupReport = {
138 trigger: "manual",
139 startedAt: "2026-10-14T12:00:00.000Z",
140 finishedAt: "2026-10-14T12:00:01.000Z",
141 entries: [],
142 counts: { removed: 0, kept: 0, failed: 0 },
143 omittedCount: 0,
144 bytesFreed: 0,
145 };
146 let complete!: (result: { _tag: "Success"; value: StorageCleanupReport }) => void;
147 state.run.mockImplementation(() => new Promise((resolve) => (complete = resolve)));
148 const button = await render();
149
150 expect(button.disabled).toBe(false);
151 await act(async () => button.click());
152 expect(state.run).toHaveBeenCalledExactlyOnceWith({ environmentId: "env", input: {} });
153 expect(button.disabled).toBe(true);
154 expect(button.textContent).toBe("Deleting…");
155
156 await act(async () => complete({ _tag: "Success", value: report }));
157 expect(button.disabled).toBe(false);
158 expect(button.textContent).toBe("Delete now");
159 },
160);
161
162it("keeps cleanup disabled when settled retention and all other rules are off", async () => {
163 const button = await render();
164
165 expect(button.disabled).toBe(true);
166 await act(async () => button.click());
167 expect(state.run).not.toHaveBeenCalled();
168});