1import { EnvironmentId } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
3import { AsyncResult } from "effect/reactivity";
4import { act, StrictMode, type ReactNode } from "react";
5import { create, type ReactTestRenderer } from "react-test-renderer";
6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
7
8vi.mock("~/state/session", () => ({
9 readEnvironmentScope: () => true,
10 useEnvironmentScope: () => true,
11}));
12vi.mock("./ui/dialog", () => ({
13 Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ? children : null),
14 DialogDescription: "p",
15 DialogFooter: "footer",
16 DialogHeader: "header",
17 DialogPanel: "section",
18 DialogPopup: "section",
19 DialogTitle: "h2",
20}));
21vi.mock("./ui/alert-dialog", () => ({
22 AlertDialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
23 open ? children : null,
24 AlertDialogClose: "button",
25 AlertDialogDescription: "p",
26 AlertDialogFooter: "footer",
27 AlertDialogHeader: "header",
28 AlertDialogPopup: "section",
29 AlertDialogTitle: "h2",
30}));
31vi.mock("./ui/button", () => ({ Button: "button" }));
32vi.mock("./ui/input", () => ({ Input: "input" }));
33vi.mock("./ui/label", () => ({ Label: "label" }));
34vi.mock("./ui/popover", () => ({
35 Popover: ({ children }: { children: ReactNode }) => children,
36 PopoverPopup: () => null,
37 PopoverTrigger: "button",
38}));
39vi.mock("./ui/switch", () => ({ Switch: "input" }));
40vi.mock("./ui/textarea", () => ({ Textarea: "textarea" }));
41
42import {
43 EMPTY_PROJECT_SCRIPT_INPUT,
44 ProjectScriptEditorDialog,
45 type ProjectScriptActionResult,
46 type ProjectScriptEditorRequest,
47} from "./projectScriptEditor";
48
49const onSubmit = vi.fn<Parameters<typeof ProjectScriptEditorDialog>[0]["onSubmit"]>();
50const onClose = vi.fn();
51const onDelete = vi.fn();
52let renderer: ReactTestRenderer | null;
53
54function request(name: string, error?: string): ProjectScriptEditorRequest {
55 return {
56 scriptId: name,
57 initial: { ...EMPTY_PROJECT_SCRIPT_INPUT, name, command: `run-${name}` },
58 ...(error === undefined ? {} : { error }),
59 };
60}
61
62function editor(nextRequest: ProjectScriptEditorRequest) {
63 return (
64 <StrictMode>
65 <ProjectScriptEditorDialog
66 environmentId={EnvironmentId.make("test")}
67 request={nextRequest}
68 scripts={[]}
69 onSubmit={onSubmit}
70 onClose={onClose}
71 onDelete={onDelete}
72 />
73 </StrictMode>
74 );
75}
76
77function open(nextRequest: ProjectScriptEditorRequest) {
78 act(() => {
79 if (renderer) renderer.update(editor(nextRequest));
80 else renderer = create(editor(nextRequest));
81 });
82}
83
84function submit(): Promise<void> {
85 return renderer!.root.findByType("form").props.onSubmit({ preventDefault() {} });
86}
87
88function saveButton() {
89 return renderer!.root.findAllByType("button").find((button) => button.props.type === "submit")!;
90}
91
92function deferredSave() {
93 let resolve!: (result: ProjectScriptActionResult) => void;
94 let reject!: (error: Error) => void;
95 const promise = new Promise<ProjectScriptActionResult>((resolveResult, rejectResult) => {
96 resolve = resolveResult;
97 reject = rejectResult;
98 });
99 return { promise, resolve, reject };
100}
101
102beforeEach(() => {
103 renderer = null;
104 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
105 onSubmit.mockReset();
106 onClose.mockReset();
107 onDelete.mockReset();
108});
109
110afterEach(async () => {
111 await act(async () => renderer?.unmount());
112 vi.unstubAllGlobals();
113});
114
115describe("project action editor save lifecycle", () => {
116 it("blocks repeated submits and edits until the current save completes", async () => {
117 const save = deferredSave();
118 onSubmit.mockReturnValue(save.promise);
119 open(request("build"));
120
121 let completion!: Promise<void>;
122 act(() => {
123 completion = submit();
124 void submit();
125 });
126
127 expect(onSubmit).toHaveBeenCalledTimes(1);
128 expect(saveButton().props.disabled).toBe(true);
129 expect(renderer!.root.findByType("fieldset").props.disabled).toBe(true);
130 const cancel = renderer!.root
131 .findAllByType("button")
132 .find((button) => button.children.includes("Cancel"))!;
133 expect(cancel.props.disabled).not.toBe(true);
134
135 await act(async () => {
136 save.resolve(AsyncResult.success(undefined));
137 await completion;
138 });
139 expect(onClose).toHaveBeenCalledTimes(1);
140 });
141
142 it("does not close a replacement request or release its in-flight save", async () => {
143 const first = deferredSave();
144 const second = deferredSave();
145 onSubmit.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
146 open(request("build"));
147 let firstCompletion!: Promise<void>;
148 act(() => {
149 firstCompletion = submit();
150 });
151
152 open(request("test"));
153 expect(saveButton().props.disabled).toBe(false);
154 expect(renderer!.root.findByProps({ id: "script-name" }).props.value).toBe("test");
155 let secondCompletion!: Promise<void>;
156 act(() => {
157 secondCompletion = submit();
158 });
159
160 await act(async () => {
161 first.resolve(AsyncResult.success(undefined));
162 await firstCompletion;
163 });
164 expect(onClose).not.toHaveBeenCalled();
165 expect(saveButton().props.disabled).toBe(true);
166
167 await act(async () => {
168 second.resolve(AsyncResult.success(undefined));
169 await secondCompletion;
170 });
171 expect(onClose).toHaveBeenCalledTimes(1);
172 expect(onSubmit.mock.calls.map(([scriptId]) => scriptId)).toEqual(["build", "test"]);
173 });
174
175 it.each(["failure", "rejection"] as const)(
176 "ignores a stale %s after the request changes",
177 async (outcome) => {
178 const save = deferredSave();
179 onSubmit.mockReturnValue(save.promise);
180 open(request("build"));
181 let completion!: Promise<void>;
182 act(() => {
183 completion = submit();
184 });
185
186 open(request("test", "New request error"));
187 await act(async () => {
188 if (outcome === "failure")
189 save.resolve(AsyncResult.failure(Cause.fail(new Error("Old save error"))));
190 else save.reject(new Error("Old save error"));
191 await completion;
192 });
193
194 const messages = renderer!.root.findAllByType("p").flatMap((paragraph) => paragraph.children);
195 expect(messages).toContain("New request error");
196 expect(messages).not.toContain("Old save error");
197 expect(saveButton().props.disabled).toBe(false);
198 expect(onClose).not.toHaveBeenCalled();
199 },
200 );
201
202 it("shows a current save error and allows retry", async () => {
203 onSubmit.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Save failed"))));
204 onSubmit.mockResolvedValueOnce(AsyncResult.success(undefined));
205 open(request("build"));
206
207 await act(async () => {
208 await submit();
209 });
210 expect(renderer!.root.findAllByType("p").flatMap((paragraph) => paragraph.children)).toContain(
211 "Save failed",
212 );
213 expect(saveButton().props.disabled).toBe(false);
214 expect(renderer!.root.findByType("fieldset").props.disabled).toBe(false);
215 expect(onClose).not.toHaveBeenCalled();
216
217 await act(async () => {
218 await submit();
219 });
220 expect(onSubmit).toHaveBeenCalledTimes(2);
221 expect(onClose).toHaveBeenCalledTimes(1);
222 });
223
224 it.each(["cancel", "unmount"] as const)("ignores save completion after %s", async (exit) => {
225 const save = deferredSave();
226 onSubmit.mockReturnValue(save.promise);
227 open(request("build"));
228 let completion!: Promise<void>;
229 act(() => {
230 completion = submit();
231 });
232
233 act(() => {
234 if (exit === "cancel") {
235 renderer!.root
236 .findAllByType("button")
237 .find((button) => button.children.includes("Cancel"))!
238 .props.onClick();
239 } else {
240 renderer!.unmount();
241 renderer = null;
242 }
243 });
244 onClose.mockClear();
245 await act(async () => {
246 save.resolve(AsyncResult.success(undefined));
247 await completion;
248 });
249 expect(onClose).not.toHaveBeenCalled();
250 });
251});