import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { act, StrictMode, type ReactNode } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; vi.mock("./ui/dialog", () => ({ Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ? children : null), DialogDescription: "p", DialogFooter: "footer", DialogHeader: "header", DialogPanel: "section", DialogPopup: "section", DialogTitle: "h2", })); vi.mock("./ui/alert-dialog", () => ({ AlertDialog: ({ open, children }: { open: boolean; children: ReactNode }) => open ? children : null, AlertDialogClose: "button", AlertDialogDescription: "p", AlertDialogFooter: "footer", AlertDialogHeader: "header", AlertDialogPopup: "section", AlertDialogTitle: "h2", })); vi.mock("./ui/button", () => ({ Button: "button" })); vi.mock("./ui/input", () => ({ Input: "input" })); vi.mock("./ui/label", () => ({ Label: "label" })); vi.mock("./ui/popover", () => ({ Popover: ({ children }: { children: ReactNode }) => children, PopoverPopup: () => null, PopoverTrigger: "button", })); vi.mock("./ui/switch", () => ({ Switch: "input" })); vi.mock("./ui/textarea", () => ({ Textarea: "textarea" })); import { EMPTY_PROJECT_SCRIPT_INPUT, ProjectScriptEditorDialog, type ProjectScriptActionResult, type ProjectScriptEditorRequest, } from "./projectScriptEditor"; const onSubmit = vi.fn[0]["onSubmit"]>(); const onClose = vi.fn(); const onDelete = vi.fn(); let renderer: ReactTestRenderer | null; function request(name: string, error?: string): ProjectScriptEditorRequest { return { scriptId: name, initial: { ...EMPTY_PROJECT_SCRIPT_INPUT, name, command: `run-${name}` }, ...(error === undefined ? {} : { error }), }; } function editor(nextRequest: ProjectScriptEditorRequest) { return ( ); } function open(nextRequest: ProjectScriptEditorRequest) { act(() => { if (renderer) renderer.update(editor(nextRequest)); else renderer = create(editor(nextRequest)); }); } function submit(): Promise { return renderer!.root.findByType("form").props.onSubmit({ preventDefault() {} }); } function saveButton() { return renderer!.root.findAllByType("button").find((button) => button.props.type === "submit")!; } function deferredSave() { let resolve!: (result: ProjectScriptActionResult) => void; let reject!: (error: Error) => void; const promise = new Promise((resolveResult, rejectResult) => { resolve = resolveResult; reject = rejectResult; }); return { promise, resolve, reject }; } beforeEach(() => { renderer = null; vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); onSubmit.mockReset(); onClose.mockReset(); onDelete.mockReset(); }); afterEach(async () => { await act(async () => renderer?.unmount()); vi.unstubAllGlobals(); }); describe("project action editor save lifecycle", () => { it("blocks repeated submits and edits until the current save completes", async () => { const save = deferredSave(); onSubmit.mockReturnValue(save.promise); open(request("build")); let completion!: Promise; act(() => { completion = submit(); void submit(); }); expect(onSubmit).toHaveBeenCalledTimes(1); expect(saveButton().props.disabled).toBe(true); expect(renderer!.root.findByType("fieldset").props.disabled).toBe(true); const cancel = renderer!.root .findAllByType("button") .find((button) => button.children.includes("Cancel"))!; expect(cancel.props.disabled).not.toBe(true); await act(async () => { save.resolve(AsyncResult.success(undefined)); await completion; }); expect(onClose).toHaveBeenCalledTimes(1); }); it("does not close a replacement request or release its in-flight save", async () => { const first = deferredSave(); const second = deferredSave(); onSubmit.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); open(request("build")); let firstCompletion!: Promise; act(() => { firstCompletion = submit(); }); open(request("test")); expect(saveButton().props.disabled).toBe(false); expect(renderer!.root.findByProps({ id: "script-name" }).props.value).toBe("test"); let secondCompletion!: Promise; act(() => { secondCompletion = submit(); }); await act(async () => { first.resolve(AsyncResult.success(undefined)); await firstCompletion; }); expect(onClose).not.toHaveBeenCalled(); expect(saveButton().props.disabled).toBe(true); await act(async () => { second.resolve(AsyncResult.success(undefined)); await secondCompletion; }); expect(onClose).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls.map(([scriptId]) => scriptId)).toEqual(["build", "test"]); }); it.each(["failure", "rejection"] as const)( "ignores a stale %s after the request changes", async (outcome) => { const save = deferredSave(); onSubmit.mockReturnValue(save.promise); open(request("build")); let completion!: Promise; act(() => { completion = submit(); }); open(request("test", "New request error")); await act(async () => { if (outcome === "failure") save.resolve(AsyncResult.failure(Cause.fail(new Error("Old save error")))); else save.reject(new Error("Old save error")); await completion; }); const messages = renderer!.root.findAllByType("p").flatMap((paragraph) => paragraph.children); expect(messages).toContain("New request error"); expect(messages).not.toContain("Old save error"); expect(saveButton().props.disabled).toBe(false); expect(onClose).not.toHaveBeenCalled(); }, ); it("shows a current save error and allows retry", async () => { onSubmit.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Save failed")))); onSubmit.mockResolvedValueOnce(AsyncResult.success(undefined)); open(request("build")); await act(async () => { await submit(); }); expect(renderer!.root.findAllByType("p").flatMap((paragraph) => paragraph.children)).toContain( "Save failed", ); expect(saveButton().props.disabled).toBe(false); expect(renderer!.root.findByType("fieldset").props.disabled).toBe(false); expect(onClose).not.toHaveBeenCalled(); await act(async () => { await submit(); }); expect(onSubmit).toHaveBeenCalledTimes(2); expect(onClose).toHaveBeenCalledTimes(1); }); it.each(["cancel", "unmount"] as const)("ignores save completion after %s", async (exit) => { const save = deferredSave(); onSubmit.mockReturnValue(save.promise); open(request("build")); let completion!: Promise; act(() => { completion = submit(); }); act(() => { if (exit === "cancel") { renderer!.root .findAllByType("button") .find((button) => button.children.includes("Cancel"))! .props.onClick(); } else { renderer!.unmount(); renderer = null; } }); onClose.mockClear(); await act(async () => { save.resolve(AsyncResult.success(undefined)); await completion; }); expect(onClose).not.toHaveBeenCalled(); }); });