diff --git a/apps/yerd-gui/src/components/site-create/CreateComposerSiteWizard.spec.ts b/apps/yerd-gui/src/components/site-create/CreateComposerSiteWizard.spec.ts new file mode 100644 index 00000000..909124c6 --- /dev/null +++ b/apps/yerd-gui/src/components/site-create/CreateComposerSiteWizard.spec.ts @@ -0,0 +1,307 @@ +import { flushPromises, mount } from "@vue/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import CreateComposerSiteWizard from "./CreateComposerSiteWizard.vue"; +import type { ComposerFramework, ToolStatus } from "@/ipc/types"; + +const createSite = vi.hoisted(() => vi.fn()); +const jobStatus = vi.hoisted(() => vi.fn()); +const listTools = vi.hoisted(() => vi.fn()); +const toastSuccess = vi.hoisted(() => vi.fn()); +const toastError = vi.hoisted(() => vi.fn()); + +vi.mock("@/ipc/client", () => ({ + IpcError: class IpcError extends Error {}, + availablePhp: vi.fn(), + createSite, + installPhpWithProgress: vi.fn(), + installToolStreamed: vi.fn(), + jobCancel: vi.fn().mockResolvedValue(undefined), + jobStatus, + listTools, + openInBrowser: vi.fn(), + openPath: vi.fn(), + pickDirectory: vi.fn(), + pollJobToEnd: vi.fn(), +})); + +vi.mock("@/composables/useDaemon", () => ({ + useDaemon: () => ({ refresh: vi.fn() }), +})); + +vi.mock("@/composables/useToast", () => ({ + useToast: () => ({ success: toastSuccess, error: toastError }), +})); + +function composer(overrides: Partial = {}): ToolStatus { + return { + id: "composer", + display_name: "Composer", + installed: true, + version: "2.8.0", + binaries: ["composer"], + ...overrides, + }; +} + +async function mountWizard(framework: ComposerFramework, phpVersions: string[]) { + const w = mount(CreateComposerSiteWizard, { + props: { + open: true, + framework, + parkedFolders: ["/srv"], + phpVersions, + defaultPhp: "", + tld: "test", + report: null, + }, + global: { stubs: { teleport: true } }, + }); + await flushPromises(); + return w; +} + +function byText(w: Awaited>, text: string) { + return w.findAll("button").find((b) => b.text().includes(text)); +} + +describe("CreateComposerSiteWizard", () => { + beforeEach(() => { + createSite.mockReset(); + jobStatus.mockReset(); + listTools.mockReset(); + listTools.mockResolvedValue([composer()]); + toastSuccess.mockReset(); + toastError.mockReset(); + }); + + it.each([ + ["codeigniter", "CodeIgniter"], + ["cakephp", "CakePHP"], + ["slim", "Slim"], + ] as const)("titles the %s wizard after the framework", async (framework, label) => { + const w = await mountWizard(framework, ["8.4"]); + expect(w.text()).toContain(`Create a new ${label} site`); + }); + + it("submits a spec tagged with the chosen framework on the newest supported PHP", async () => { + createSite.mockResolvedValue("job-1"); + jobStatus.mockResolvedValue({ + log: [], + next_cursor: 0, + phase: "Done", + state: "succeeded", + error: null, + }); + const w = await mountWizard("cakephp", ["8.3", "8.4"]); + + await w.find("#ccs-name").setValue("shop"); + await byText(w, "Next")!.trigger("click"); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + + expect(createSite).toHaveBeenCalledWith({ + name: "shop", + parent_dir: "/srv", + php: "8.4", + secure: false, + framework: { framework: "cakephp" }, + }); + expect(w.emitted("created")).toBeTruthy(); + }); + + it("shows the lowercased folder the daemon will create", async () => { + const w = await mountWizard("slim", ["8.4"]); + + await w.find("#ccs-name").setValue("Shop"); + await byText(w, "Next")!.trigger("click"); + + expect(w.text()).toContain("/srv/shop"); + expect(w.text()).not.toContain("/srv/Shop"); + }); + + it("re-enables Cancel when a cancelled create is retried", async () => { + createSite.mockResolvedValue("job-1"); + const status = (state: string) => ({ + log: [], + next_cursor: 0, + phase: "Scaffolding", + state, + error: null, + }); + jobStatus + .mockResolvedValueOnce(status("running")) + .mockResolvedValueOnce(status("cancelled")) + .mockResolvedValue(status("running")); + const w = await mountWizard("slim", ["8.4"]); + + await w.find("#ccs-name").setValue("shop"); + await byText(w, "Next")!.trigger("click"); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + await byText(w, "Cancel")!.trigger("click"); + expect(byText(w, "Cancelling")).toBeTruthy(); + + await vi.waitFor(() => expect(byText(w, "Back")).toBeTruthy(), { timeout: 2000 }); + await byText(w, "Back")!.trigger("click"); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + + const cancel = byText(w, "Cancel")!; + expect(cancel.text()).toBe("Cancel"); + expect(cancel.attributes("disabled")).toBeUndefined(); + w.unmount(); + }); + + it("keeps polling after the dialog closes and resumes progress on reopen", async () => { + createSite.mockResolvedValue("job-1"); + const status = (state: string) => ({ + log: [], + next_cursor: 0, + phase: "Scaffolding", + state, + error: null, + }); + jobStatus + .mockResolvedValueOnce(status("running")) + .mockResolvedValueOnce(status("running")) + .mockResolvedValue(status("succeeded")); + const w = await mountWizard("slim", ["8.4"]); + + await w.find("#ccs-name").setValue("shop"); + await byText(w, "Next")!.trigger("click"); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + + await w.setProps({ open: false }); + await w.setProps({ open: true }); + expect(w.find("#ccs-name").exists()).toBe(false); + expect(w.text()).toContain("Scaffolding"); + + await w.setProps({ open: false }); + await vi.waitFor(() => expect(w.emitted("created")).toBeTruthy(), { timeout: 3000 }); + w.unmount(); + }); + + it("stops polling when unmounted while a status request is in flight", async () => { + createSite.mockResolvedValue("job-1"); + let resolveStatus: (value: unknown) => void = () => {}; + jobStatus.mockReturnValueOnce( + new Promise((resolve) => { + resolveStatus = resolve; + }), + ); + const w = await mountWizard("slim", ["8.4"]); + + await w.find("#ccs-name").setValue("shop"); + await byText(w, "Next")!.trigger("click"); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + expect(jobStatus).toHaveBeenCalledTimes(1); + + w.unmount(); + resolveStatus({ log: [], next_cursor: 0, phase: "Scaffolding", state: "running", error: null }); + await flushPromises(); + await new Promise((resolve) => setTimeout(resolve, 700)); + + expect(jobStatus).toHaveBeenCalledTimes(1); + }); + + it("resumes the progress view when reopened before the daemon returns a job id", async () => { + let resolveCreate: (value: string) => void = () => {}; + createSite.mockReturnValueOnce( + new Promise((resolve) => { + resolveCreate = resolve; + }), + ); + jobStatus.mockResolvedValue({ + log: [], + next_cursor: 0, + phase: "Scaffolding", + state: "succeeded", + error: null, + }); + const w = await mountWizard("slim", ["8.4"]); + + await w.find("#ccs-name").setValue("shop"); + await byText(w, "Next")!.trigger("click"); + await byText(w, "Create site")!.trigger("click"); + await w.setProps({ open: false }); + await w.setProps({ open: true }); + await flushPromises(); + + expect(w.find("#ccs-name").exists()).toBe(false); + resolveCreate("job-1"); + await flushPromises(); + expect(w.emitted("created")).toBeTruthy(); + w.unmount(); + }); + + it("stops the phase spinner when the job fails", async () => { + createSite.mockResolvedValue("job-1"); + jobStatus.mockResolvedValue({ + log: [], + next_cursor: 0, + phase: "Scaffolding", + state: "failed", + error: "composer exploded", + }); + const w = await mountWizard("slim", ["8.4"]); + + await w.find("#ccs-name").setValue("shop"); + await byText(w, "Next")!.trigger("click"); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + + expect(w.text()).toContain("composer exploded"); + expect(w.find(".animate-spin").exists()).toBe(false); + w.unmount(); + }); + + it.each([ + ["succeeded", null], + ["failed", "composer exploded"], + ] as const)("toasts a %s create that finishes while the dialog is closed", async (state, error) => { + createSite.mockResolvedValue("job-1"); + jobStatus + .mockResolvedValueOnce({ log: [], next_cursor: 0, phase: "Scaffolding", state: "running", error: null }) + .mockResolvedValue({ log: [], next_cursor: 0, phase: "Done", state, error }); + const w = await mountWizard("slim", ["8.4"]); + + await w.find("#ccs-name").setValue("shop"); + await byText(w, "Next")!.trigger("click"); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + await w.setProps({ open: false }); + + const toast = state === "succeeded" ? toastSuccess : toastError; + await vi.waitFor(() => expect(toast).toHaveBeenCalledTimes(1), { timeout: 2000 }); + expect(Boolean(w.emitted("created"))).toBe(state === "succeeded"); + w.unmount(); + }); + + it("gates on PHP when every installed version is below the framework minimum", async () => { + const w = await mountWizard("codeigniter", ["7.4"]); + expect(w.text()).toContain("A few tools are needed first"); + expect(w.text()).toContain("Installed, but not 8.1+"); + }); + + it("accepts older PHP for Slim", async () => { + const w = await mountWizard("slim", ["7.4"]); + expect(w.text()).not.toContain("A few tools are needed first"); + expect(w.find("#ccs-name").exists()).toBe(true); + }); + + it("treats an external Composer as satisfying the prerequisite", async () => { + listTools.mockResolvedValue([composer({ installed: false, external: true })]); + const w = await mountWizard("slim", ["8.4"]); + expect(w.text()).not.toContain("A few tools are needed first"); + }); + + it("gates on Composer when it is neither managed nor external", async () => { + listTools.mockResolvedValue([]); + const w = await mountWizard("slim", ["8.4"]); + expect(w.text()).toContain("A few tools are needed first"); + expect(byText(w, "Install missing tools")).toBeTruthy(); + }); +}); diff --git a/apps/yerd-gui/src/components/site-create/CreateComposerSiteWizard.vue b/apps/yerd-gui/src/components/site-create/CreateComposerSiteWizard.vue new file mode 100644 index 00000000..7da0f862 --- /dev/null +++ b/apps/yerd-gui/src/components/site-create/CreateComposerSiteWizard.vue @@ -0,0 +1,177 @@ + + + diff --git a/apps/yerd-gui/src/components/site-create/CreateLaravelWizard.spec.ts b/apps/yerd-gui/src/components/site-create/CreateLaravelWizard.spec.ts new file mode 100644 index 00000000..cd6b4cea --- /dev/null +++ b/apps/yerd-gui/src/components/site-create/CreateLaravelWizard.spec.ts @@ -0,0 +1,334 @@ +import { flushPromises, mount } from "@vue/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import CreateLaravelWizard from "./CreateLaravelWizard.vue"; +import type { ToolStatus } from "@/ipc/types"; + +const createSite = vi.hoisted(() => vi.fn()); +const jobStatus = vi.hoisted(() => vi.fn()); +const listTools = vi.hoisted(() => vi.fn()); +const availablePhp = vi.hoisted(() => vi.fn()); +const installPhpWithProgress = vi.hoisted(() => vi.fn()); +const installToolStreamed = vi.hoisted(() => vi.fn()); +const pollJobToEnd = vi.hoisted(() => vi.fn()); +const toastSuccess = vi.hoisted(() => vi.fn()); +const toastError = vi.hoisted(() => vi.fn()); + +vi.mock("@/ipc/client", () => ({ + IpcError: class IpcError extends Error {}, + availablePhp, + createSite, + installPhpWithProgress, + installToolStreamed, + jobCancel: vi.fn().mockResolvedValue(undefined), + jobStatus, + listTools, + openInBrowser: vi.fn(), + openPath: vi.fn(), + pickDirectory: vi.fn(), + pollJobToEnd, +})); + +vi.mock("@/composables/useDaemon", () => ({ + useDaemon: () => ({ refresh: vi.fn() }), +})); + +vi.mock("@/composables/useToast", () => ({ + useToast: () => ({ success: toastSuccess, error: toastError }), +})); + +function tool(id: string, overrides: Partial = {}): ToolStatus { + return { id, display_name: id, installed: true, version: "1.0", binaries: [id], ...overrides }; +} + +const ALL_TOOLS = [tool("composer"), tool("laravel"), tool("node")]; + +function status(state: string, error: string | null = null) { + return { log: [], next_cursor: 0, phase: "Scaffolding", state, error }; +} + +async function openWizard(phpVersions = ["8.3", "8.4"], defaultPhp = "8.4") { + const w = mount(CreateLaravelWizard, { + props: { + open: false, + parkedFolders: ["/srv"], + phpVersions, + defaultPhp, + tld: "test", + report: null, + }, + global: { stubs: { teleport: true } }, + }); + await w.setProps({ open: true }); + await flushPromises(); + return w; +} + +function byText(w: Awaited>, text: string) { + return w.findAll("button").find((b) => b.text().includes(text)); +} + +async function next(w: Awaited>) { + await byText(w, "Next")!.trigger("click"); +} + +async function toReview(w: Awaited>, name = "blog") { + await w.find("#cs-name").setValue(name); + await next(w); + await next(w); + await next(w); +} + +function submittedOptions() { + return createSite.mock.calls[0][0].framework.options; +} + +describe("CreateLaravelWizard", () => { + beforeEach(() => { + vi.clearAllMocks(); + listTools.mockResolvedValue(ALL_TOOLS); + createSite.mockResolvedValue("job-1"); + jobStatus.mockResolvedValue(status("succeeded")); + }); + + it("is titled for Laravel", async () => { + const w = await openWizard(); + expect(w.text()).toContain("Create a new Laravel site"); + }); + + it("submits the default spec on the default PHP", async () => { + const w = await openWizard(); + await toReview(w); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + + expect(createSite).toHaveBeenCalledWith({ + name: "blog", + parent_dir: "/srv", + php: "8.4", + secure: false, + framework: { + framework: "laravel", + options: { + starter_kit: "none", + auth: "laravel", + livewire_class_components: false, + teams: false, + testing: "pest", + database: "sqlite", + js: "skip", + git: true, + boost: false, + }, + }, + }); + expect(w.emitted("created")).toBeTruthy(); + }); + + it("honours a supported default PHP that isn't the newest", async () => { + const w = await openWizard(["8.3", "8.4"], "8.3"); + expect((w.find("#cs-php").element as HTMLSelectElement).value).toBe("8.3"); + }); + + it("defaults to the newest supported PHP when the default is outside 8.3-8.5", async () => { + const w = await openWizard(["8.2", "8.3", "8.4"], "8.2"); + expect((w.find("#cs-php").element as HTMLSelectElement).value).toBe("8.4"); + expect(w.find("#cs-php").text()).not.toContain("8.2"); + }); + + it("replaces a selection that stops being installed", async () => { + const w = await openWizard(["8.3", "8.4"], "8.3"); + await w.setProps({ phpVersions: ["8.4"] }); + expect((w.find("#cs-php").element as HTMLSelectElement).value).toBe("8.4"); + }); + + it("gates Next on a valid name", async () => { + const w = await openWizard(); + await w.find("#cs-name").setValue("-bad"); + expect(byText(w, "Next")!.attributes("disabled")).toBeDefined(); + expect(w.text()).toContain("Use a single label"); + await w.find("#cs-name").setValue("good"); + expect(byText(w, "Next")!.attributes("disabled")).toBeUndefined(); + }); + + it("sends the chosen JS runtime for an Inertia kit and skip for Livewire", async () => { + const w = await openWizard(); + await w.find("#cs-name").setValue("blog"); + await next(w); + await byText(w, "Vue")!.trigger("click"); + await w.find("#cs-js").setValue("bun"); + await next(w); + await next(w); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + expect(submittedOptions()).toMatchObject({ starter_kit: "vue", js: "bun" }); + + createSite.mockClear(); + await w.setProps({ open: false }); + await w.setProps({ open: true }); + await flushPromises(); + await w.find("#cs-name").setValue("blog"); + await next(w); + await byText(w, "Livewire")!.trigger("click"); + await next(w); + await next(w); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + expect(submittedOptions()).toMatchObject({ starter_kit: "livewire", js: "skip" }); + }); + + it("requires a community package before leaving the Stack step", async () => { + const w = await openWizard(); + await w.find("#cs-name").setValue("blog"); + await next(w); + await byText(w, "Community")!.trigger("click"); + expect(byText(w, "Next")!.attributes("disabled")).toBeDefined(); + await w.find("#cs-pkg").setValue("acme/kit"); + await next(w); + await next(w); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + expect(submittedOptions().starter_kit).toEqual({ community: "acme/kit" }); + }); + + it("notes that a missing Node is installed during creation", async () => { + listTools.mockResolvedValue([tool("composer"), tool("laravel")]); + const w = await openWizard(); + await w.find("#cs-name").setValue("blog"); + await next(w); + await byText(w, "React")!.trigger("click"); + expect(w.text()).toContain("Node will be installed automatically."); + await next(w); + await next(w); + expect(w.text()).toContain("Node will be installed automatically during creation."); + }); + + it("blocks the installer row until Yerd's own Composer is present", async () => { + listTools.mockResolvedValue([tool("composer", { installed: false, external: true })]); + const w = await openWizard(); + + expect(w.text()).toContain("A few tools are needed first"); + const installs = w.findAll("button").filter((b) => b.text() === "Install"); + const install = installs[installs.length - 1]; + expect(install.attributes("disabled")).toBeDefined(); + expect(install.attributes("title")).toContain("Composer is required"); + + await byText(w, "Install missing tools")!.trigger("click"); + await flushPromises(); + expect(toastError).toHaveBeenCalledWith( + "Can't install the Laravel installer", + expect.any(String), + ); + expect(installToolStreamed).not.toHaveBeenCalled(); + }); + + it("installs missing tools in dependency order", async () => { + listTools + .mockResolvedValueOnce([]) + .mockResolvedValue([tool("composer")]); + availablePhp.mockResolvedValue({ available: ["8.2", "8.4", "8.6"] }); + installToolStreamed.mockImplementation(async (id: string) => `install-${id}`); + pollJobToEnd.mockResolvedValue({ state: "succeeded", error: null }); + const w = await openWizard([], ""); + + await byText(w, "Install missing tools")!.trigger("click"); + await flushPromises(); + + expect(installPhpWithProgress).toHaveBeenCalledWith("8.4", expect.any(Function)); + expect(installToolStreamed.mock.calls.map((c) => c[0])).toEqual(["composer", "laravel"]); + expect(toastSuccess).toHaveBeenCalledWith("Toolchain ready"); + }); + + it("returns to Review with the form kept after a failed create", async () => { + jobStatus.mockResolvedValue(status("failed", "composer exploded")); + const w = await openWizard(); + await toReview(w); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + + expect(w.text()).toContain("composer exploded"); + await byText(w, "Back")!.trigger("click"); + expect(w.text()).toContain("blog.test"); + expect(byText(w, "Create site")).toBeTruthy(); + }); + + it("shows the lowercased folder the daemon will create", async () => { + const w = await openWizard(); + await w.find("#cs-name").setValue("Blog"); + expect(w.text()).toContain("/srv/blog"); + expect(w.text()).not.toContain("/srv/Blog"); + }); + + it("keeps polling while closed, announces the result and resumes on reopen", async () => { + jobStatus + .mockResolvedValueOnce(status("running")) + .mockResolvedValueOnce(status("running")) + .mockResolvedValue(status("succeeded")); + const w = await openWizard(); + await toReview(w); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + + await w.setProps({ open: false }); + await w.setProps({ open: true }); + expect(w.find("#cs-name").exists()).toBe(false); + expect(w.text()).toContain("Scaffolding"); + + await w.setProps({ open: false }); + await vi.waitFor(() => expect(w.emitted("created")).toBeTruthy(), { timeout: 3000 }); + expect(toastSuccess).toHaveBeenCalledWith("blog.test is ready"); + w.unmount(); + }); + + it("announces a status failure that happens while closed", async () => { + jobStatus + .mockResolvedValueOnce(status("running")) + .mockRejectedValue(new Error("daemon restarted")); + const w = await openWizard(); + await toReview(w); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + + await w.setProps({ open: false }); + await vi.waitFor( + () => expect(toastError).toHaveBeenCalledWith("Couldn't create blog.test", "daemon restarted"), + { timeout: 2000 }, + ); + w.unmount(); + }); + + it("re-enables Cancel when a cancelled create is retried", async () => { + jobStatus + .mockResolvedValueOnce(status("running")) + .mockResolvedValueOnce(status("cancelled")) + .mockResolvedValue(status("running")); + const w = await openWizard(); + await toReview(w); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + await byText(w, "Cancel")!.trigger("click"); + + await vi.waitFor(() => expect(byText(w, "Back")).toBeTruthy(), { timeout: 2000 }); + await byText(w, "Back")!.trigger("click"); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + + const cancel = byText(w, "Cancel")!; + expect(cancel.text()).toBe("Cancel"); + expect(cancel.attributes("disabled")).toBeUndefined(); + w.unmount(); + }); + + it("resets the form when reopened after a finished create", async () => { + const w = await openWizard(); + await toReview(w); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + expect(w.text()).toContain("blog.test is ready"); + + await w.setProps({ open: false }); + await w.setProps({ open: true }); + await flushPromises(); + expect((w.find("#cs-name").element as HTMLInputElement).value).toBe(""); + }); +}); diff --git a/apps/yerd-gui/src/components/site-create/CreateLaravelWizard.vue b/apps/yerd-gui/src/components/site-create/CreateLaravelWizard.vue index 98819676..ebdf07fb 100644 --- a/apps/yerd-gui/src/components/site-create/CreateLaravelWizard.vue +++ b/apps/yerd-gui/src/components/site-create/CreateLaravelWizard.vue @@ -1,52 +1,25 @@