| 1 | import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import * as Cause from "effect/Cause"; |
| 3 | import { AsyncResult } from "effect/reactivity"; |
| 4 | import { act, StrictMode, type ReactNode } from "react"; |
| 5 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 7 | |
| 8 | vi.mock("~/state/session", () => ({ |
| 9 | readEnvironmentScope: () => true, |
| 10 | useEnvironmentScope: () => true, |
| 11 | })); |
| 12 | vi.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 | })); |
| 21 | vi.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 | })); |
| 31 | vi.mock("./ui/button", () => ({ Button: "button" })); |
| 32 | vi.mock("./ui/input", () => ({ Input: "input" })); |
| 33 | vi.mock("./ui/label", () => ({ Label: "label" })); |
| 34 | vi.mock("./ui/popover", () => ({ |
| 35 | Popover: ({ children }: { children: ReactNode }) => children, |
| 36 | PopoverPopup: () => null, |
| 37 | PopoverTrigger: "button", |
| 38 | })); |
| 39 | vi.mock("./ui/switch", () => ({ Switch: "input" })); |
| 40 | vi.mock("./ui/textarea", () => ({ Textarea: "textarea" })); |
| 41 | |
| 42 | import { |
| 43 | EMPTY_PROJECT_SCRIPT_INPUT, |
| 44 | ProjectScriptEditorDialog, |
| 45 | type ProjectScriptActionResult, |
| 46 | type ProjectScriptEditorRequest, |
| 47 | } from "./projectScriptEditor"; |
| 48 | |
| 49 | const onSubmit = vi.fn<Parameters<typeof ProjectScriptEditorDialog>[0]["onSubmit"]>(); |
| 50 | const onClose = vi.fn(); |
| 51 | const onDelete = vi.fn(); |
| 52 | let renderer: ReactTestRenderer | null; |
| 53 | |
| 54 | function 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 | |
| 62 | function 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 | |
| 77 | function open(nextRequest: ProjectScriptEditorRequest) { |
| 78 | act(() => { |
| 79 | if (renderer) renderer.update(editor(nextRequest)); |
| 80 | else renderer = create(editor(nextRequest)); |
| 81 | }); |
| 82 | } |
| 83 | |
| 84 | function submit(): Promise<void> { |
| 85 | return renderer!.root.findByType("form").props.onSubmit({ preventDefault() {} }); |
| 86 | } |
| 87 | |
| 88 | function saveButton() { |
| 89 | return renderer!.root.findAllByType("button").find((button) => button.props.type === "submit")!; |
| 90 | } |
| 91 | |
| 92 | function 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 | |
| 102 | beforeEach(() => { |
| 103 | renderer = null; |
| 104 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
| 105 | onSubmit.mockReset(); |
| 106 | onClose.mockReset(); |
| 107 | onDelete.mockReset(); |
| 108 | }); |
| 109 | |
| 110 | afterEach(async () => { |
| 111 | await act(async () => renderer?.unmount()); |
| 112 | vi.unstubAllGlobals(); |
| 113 | }); |
| 114 | |
| 115 | describe("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 | }); |