// @vitest-environment jsdom import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import i18next from "i18next"; import { ServerFiles } from "./ServerFiles"; import { humanizeError } from "@/lib/api"; import { ONE_REQUEST_BYTES } from "@/components/files/useUploads"; import { OP_POLL_MS } from "@/components/files/sessionUpload"; import { EXPORT_POLL_MS } from "@/lib/download"; import { STATUS_POLL_FAST_MS } from "@/lib/hooks"; import type { FileOp } from "@/lib/types"; const mocks = vi.hoisted(() => ({ writeServerFile: vi.fn(), status: vi.fn(), stop: vi.fn(), listServerFiles: vi.fn(), readServerFile: vi.fn(), createServerFile: vi.fn(), deleteServerFile: vi.fn(), mkdirServerFolder: vi.fn(), renameServerFile: vi.fn(), uploadServerFile: vi.fn(), listServerFileOps: vi.fn(), unzipServerFile: vi.fn(), downloadServerFile: vi.fn(), exportStatus: vi.fn(), exportDownloadURL: vi.fn(), beginServerFileUpload: vi.fn(), getServerFileUpload: vi.fn(), putServerFileUploadPart: vi.fn(), deleteServerFileUpload: vi.fn(), commitServerFileUpload: vi.fn(), })); vi.mock("@/lib/api", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, api: { ...actual.api, status: mocks.status, stop: mocks.stop, listServerFiles: mocks.listServerFiles, readServerFile: mocks.readServerFile, writeServerFile: mocks.writeServerFile, createServerFile: mocks.createServerFile, deleteServerFile: mocks.deleteServerFile, mkdirServerFolder: mocks.mkdirServerFolder, renameServerFile: mocks.renameServerFile, uploadServerFile: mocks.uploadServerFile, listServerFileOps: mocks.listServerFileOps, unzipServerFile: mocks.unzipServerFile, downloadServerFile: mocks.downloadServerFile, exportStatus: mocks.exportStatus, exportDownloadURL: mocks.exportDownloadURL, beginServerFileUpload: mocks.beginServerFileUpload, getServerFileUpload: mocks.getServerFileUpload, putServerFileUploadPart: mocks.putServerFileUploadPart, deleteServerFileUpload: mocks.deleteServerFileUpload, commitServerFileUpload: mocks.commitServerFileUpload, }, }; }); vi.mock("@/lib/tier", () => ({ useTier: () => ({ isAdmin: true, loading: false }) })); const t = (key: string) => i18next.t(key); function renderFiles() { return render( } /> , ); } async function openEditor() { renderFiles(); await userEvent.click(await screen.findByText("server.properties")); const dialog = await screen.findByRole("dialog"); return { dialog, editor: within(dialog).getByRole("textbox") as HTMLTextAreaElement }; } let opsNow: FileOp[] = []; const fileOp = (over: Partial = {}): FileOp => ({ id: "op1", op: "unzip", path: "pack.zip", state: "running", started_at: "2026-09-28T00:00:00Z", done: 0, total: 0, ...over, }); const stopped = { name: "lobby", subdomain: "lobby", phase: "Stopped", desiredState: "Stopped", ready: false }; beforeEach(() => { mocks.writeServerFile.mockReset(); mocks.listServerFiles.mockReset(); mocks.listServerFiles.mockImplementation((_name: string, path: string) => Promise.resolve({ path, truncated: false, entries: [ { name: "server.properties", size: 8, is_dir: false, mod_time: "2026-09-01T00:00:00Z" }, { name: "world", size: 0, is_dir: true, mod_time: "2026-09-01T00:00:00Z" }, ], }), ); mocks.readServerFile.mockReset(); mocks.readServerFile.mockImplementation((_name: string, path: string) => Promise.resolve({ path, content: btoa("motd=hi\n"), sha256: "abc" }), ); for (const m of [mocks.createServerFile, mocks.deleteServerFile, mocks.mkdirServerFolder, mocks.renameServerFile, mocks.uploadServerFile]) { m.mockReset(); } for (const m of [ mocks.unzipServerFile, mocks.downloadServerFile, mocks.exportStatus, mocks.exportDownloadURL, mocks.beginServerFileUpload, mocks.getServerFileUpload, mocks.putServerFileUploadPart, mocks.deleteServerFileUpload, mocks.commitServerFileUpload, ]) { m.mockReset(); } // The server's background ops, as each read finds them. opsNow = []; mocks.listServerFileOps.mockReset(); mocks.listServerFileOps.mockImplementation(async () => ({ ops: opsNow })); localStorage.clear(); mocks.stop.mockReset(); mocks.status.mockReset(); mocks.status.mockResolvedValue(stopped); }); afterEach(() => { vi.useRealTimers(); }); describe("ServerFiles editor", () => { it("closes an unchanged file at once", async () => { const { dialog } = await openEditor(); await userEvent.click(within(dialog).getByRole("button", { name: t("common:cancel") })); expect(screen.queryByRole("dialog")).toBeNull(); }); it("asks before dropping edits, and keep editing keeps the text", async () => { const { editor } = await openEditor(); await userEvent.type(editor, "pvp=false"); await userEvent.keyboard("{Escape}"); const dialog = screen.getByRole("dialog"); expect(within(dialog).getByText(t("files:discard_prompt"))).toBeTruthy(); await userEvent.click(within(dialog).getByRole("button", { name: t("files:keep_editing") })); expect(within(dialog).queryByText(t("files:discard_prompt"))).toBeNull(); expect((within(dialog).getByRole("textbox") as HTMLTextAreaElement).value).toBe("motd=hi\npvp=false"); }); it("drops the edits only after discard is confirmed", async () => { const { dialog, editor } = await openEditor(); await userEvent.type(editor, "pvp=false"); await userEvent.click(within(dialog).getByRole("button", { name: t("common:cancel") })); await userEvent.click(within(dialog).getByRole("button", { name: t("files:discard") })); expect(screen.queryByRole("dialog")).toBeNull(); expect(mocks.writeServerFile).not.toHaveBeenCalled(); }); it("guards leaving the page while edits are unsaved", async () => { const { editor } = await openEditor(); const clean = new Event("beforeunload", { cancelable: true }); window.dispatchEvent(clean); expect(clean.defaultPrevented).toBe(false); await userEvent.type(editor, "x"); const dirty = new Event("beforeunload", { cancelable: true }); window.dispatchEvent(dirty); expect(dirty.defaultPrevented).toBe(true); }); }); describe("ServerFiles on a running server", () => { it("lists the files once the server has stopped, without a reload", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); mocks.status.mockResolvedValue({ ...stopped, phase: "Running", desiredState: "Running", ready: true }); renderFiles(); expect(await screen.findByText(t("files:stopped_required_title"))).toBeTruthy(); expect(screen.queryByText("server.properties")).toBeNull(); mocks.status.mockResolvedValue(stopped); await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_FAST_MS)); expect(await screen.findByText("server.properties")).toBeTruthy(); expect(screen.queryByText(t("files:stopped_required_title"))).toBeNull(); // Stopped is what it waited for: nothing more to reread. const reads = mocks.status.mock.calls.length; await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_FAST_MS * 3)); expect(mocks.status.mock.calls.length).toBe(reads); }); it("asks before stopping a server with players on it", async () => { mocks.stop.mockResolvedValue(undefined); mocks.status.mockResolvedValue({ ...stopped, phase: "Running", desiredState: "Running", ready: true, playersOnline: 2 }); renderFiles(); await userEvent.click(await screen.findByRole("button", { name: t("servers:stop") })); expect(mocks.stop).not.toHaveBeenCalled(); expect(screen.getByText(i18next.t("servers:stop_confirm_players", { count: 2 }))).toBeTruthy(); const reads = mocks.status.mock.calls.length; await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); expect(mocks.stop).toHaveBeenCalledWith("lobby"); // The page rereads at once to follow the stop. await waitFor(() => expect(mocks.status.mock.calls.length).toBeGreaterThan(reads)); }); it("asks when the player count cannot be read", async () => { mocks.status.mockResolvedValue({ ...stopped, phase: "Running", desiredState: "Running", ready: true, playersOnline: 0, playerCountUnknown: true, }); renderFiles(); await userEvent.click(await screen.findByRole("button", { name: t("servers:stop") })); expect(mocks.stop).not.toHaveBeenCalled(); expect(screen.getByText(t("servers:stop_confirm_unknown"))).toBeTruthy(); }); it("offers a failed start a stop, sent without asking, and nothing that would start it", async () => { mocks.stop.mockResolvedValue(undefined); // Nobody is on a server that never came up, so even an unreadable count does not ask. mocks.status.mockResolvedValue({ ...stopped, phase: "Failed", desiredState: "Running", ready: false, playersOnline: 0, playerCountUnknown: true, }); renderFiles(); await screen.findByText(t("files:stopped_required_title")); expect(screen.queryByRole("button", { name: t("servers:retry_start") })).toBeNull(); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); expect(mocks.stop).toHaveBeenCalledWith("lobby"); }); }); describe("ServerFiles from the keyboard", () => { it("opens a folder and then a file without a mouse", async () => { renderFiles(); const folder = await screen.findByRole("button", { name: i18next.t("files:open_folder", { name: "world" }) }); folder.focus(); expect(document.activeElement).toBe(folder); await userEvent.keyboard(" "); expect(mocks.listServerFiles).toHaveBeenLastCalledWith("lobby", "world"); const file = await screen.findByRole("button", { name: i18next.t("files:open_file", { name: "server.properties" }) }); file.focus(); expect(document.activeElement).toBe(file); await userEvent.keyboard("{Enter}"); expect(await screen.findByRole("dialog")).toBeTruthy(); // One press reads the file once: the button's click and the row's are one handler. expect(mocks.readServerFile.mock.calls).toEqual([["lobby", "world/server.properties"]]); }); it("still opens from a click anywhere on the row", async () => { renderFiles(); const size = await screen.findByText("8 B"); await userEvent.click(size); expect(await screen.findByRole("dialog")).toBeTruthy(); expect(mocks.readServerFile.mock.calls).toEqual([["lobby", "server.properties"]]); }); }); describe("ServerFiles folder answers arriving out of order", () => { type Listing = { path: string; truncated: boolean; entries: { name: string; size: number; is_dir: boolean; mod_time: string }[] }; function deferred() { let resolve!: (v: Listing) => void; let reject!: (e: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } const entry = (name: string, is_dir = false) => ({ name, size: 8, is_dir, mod_time: "2026-09-01T00:00:00Z" }); const listing = (path: string, ...names: string[]): Listing => ({ path, truncated: false, entries: names.map((n) => entry(n)) }); let world: ReturnType; let plugins: ReturnType; beforeEach(() => { world = deferred(); plugins = deferred(); mocks.listServerFiles.mockImplementation((_name: string, path: string) => { if (path === "world") return world.promise; if (path === "plugins") return plugins.promise; return Promise.resolve({ path, truncated: false, entries: [entry("world", true), entry("plugins", true)] }); }); }); const folder = (name: string) => screen.findByRole("button", { name: i18next.t("files:open_folder", { name }) }); const refresh = () => screen.getByRole("button", { name: i18next.t("files:refresh") }) as HTMLButtonElement; it.each([ ["answers", (d: ReturnType) => d.resolve(listing("world", "level.dat"))], ["fails", (d: ReturnType) => d.reject(new Error("world listing broke"))], ])("keeps the folder clicked last when the one left behind %s late", async (_label, settle) => { renderFiles(); await userEvent.click(await folder("world")); await userEvent.click(await folder("plugins")); await act(async () => plugins.resolve(listing("plugins", "config.yml"))); expect(await screen.findByText("config.yml")).toBeTruthy(); await act(async () => settle(world)); expect(screen.getByText("config.yml")).toBeTruthy(); expect(screen.queryByText("level.dat")).toBeNull(); expect(screen.getByRole("button", { name: "plugins" })).toBeTruthy(); expect(screen.queryByRole("button", { name: "world" })).toBeNull(); expect(refresh().disabled).toBe(false); }); it("keeps loading while the folder clicked last is still on its way", async () => { renderFiles(); await userEvent.click(await folder("world")); await userEvent.click(await folder("plugins")); await act(async () => world.resolve(listing("world", "level.dat"))); expect(screen.queryByText("level.dat")).toBeNull(); expect(refresh().disabled).toBe(true); await act(async () => plugins.resolve(listing("plugins", "config.yml"))); expect(await screen.findByText("config.yml")).toBeTruthy(); expect(refresh().disabled).toBe(false); }); }); const button = (key: string, name?: string) => screen.getByRole("button", { name: i18next.t(`files:${key}`, name === undefined ? {} : { name }) }) as HTMLButtonElement; describe("ServerFiles changes", () => { it("offers no rename for what Felis manages, and one for everything else", async () => { renderFiles(); await screen.findByText("world"); expect(button("rename_item", "server.properties").disabled).toBe(true); expect(button("rename_item", "world").disabled).toBe(false); expect(button("delete_item", "server.properties").disabled).toBe(false); }); it("renames within the folder and rereads it", async () => { mocks.renameServerFile.mockResolvedValue({ path: "world/world", to: "world/world_old", status: "renamed" }); renderFiles(); await userEvent.click(await screen.findByRole("button", { name: i18next.t("files:open_folder", { name: "world" }) })); await screen.findByRole("button", { name: "world" }); await userEvent.click(screen.getByRole("button", { name: i18next.t("files:rename_item", { name: "world" }) })); const dialog = screen.getByRole("dialog"); const field = within(dialog).getByRole("textbox") as HTMLInputElement; expect(field.value).toBe("world"); const lists = mocks.listServerFiles.mock.calls.length; await userEvent.clear(field); await userEvent.type(field, " world_old {Enter}"); expect(mocks.renameServerFile.mock.calls).toEqual([["lobby", "world/world", "world/world_old"]]); expect(await screen.findByText(i18next.t("files:renamed", { from: "world", to: "world_old" }))).toBeTruthy(); expect(screen.queryByRole("dialog")).toBeNull(); expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1); expect(mocks.listServerFiles).toHaveBeenLastCalledWith("lobby", "world"); }); it("keeps rename off until the name changes", async () => { renderFiles(); await userEvent.click(await screen.findByRole("button", { name: i18next.t("files:rename_item", { name: "world" }) })); const dialog = screen.getByRole("dialog"); const submit = within(dialog).getByRole("button", { name: t("files:rename") }) as HTMLButtonElement; expect(submit.disabled).toBe(true); await userEvent.type(within(dialog).getByRole("textbox"), "2"); expect(submit.disabled).toBe(false); }); it("says why a name cannot be used and sends nothing", async () => { renderFiles(); await userEvent.click(await screen.findByRole("button", { name: t("files:new_folder") })); const dialog = screen.getByRole("dialog"); const field = within(dialog).getByRole("textbox"); const submit = within(dialog).getByRole("button", { name: t("files:create") }) as HTMLButtonElement; // An empty field has nothing to say yet, and nothing to create. expect(within(dialog).queryByText(t("files:name_required"))).toBeNull(); expect(submit.disabled).toBe(true); await userEvent.type(field, "world"); expect(within(dialog).getByText(t("files:name_taken"))).toBeTruthy(); expect(submit.disabled).toBe(true); await userEvent.clear(field); await userEvent.type(field, "a/b{Enter}"); expect(within(dialog).getByText(t("files:name_slash"))).toBeTruthy(); expect(field.getAttribute("aria-invalid")).toBe("true"); expect(mocks.mkdirServerFolder).not.toHaveBeenCalled(); }); it("makes a folder in the folder on screen", async () => { mocks.mkdirServerFolder.mockResolvedValue({ path: "world/datapacks", status: "created" }); renderFiles(); await userEvent.click(await screen.findByRole("button", { name: i18next.t("files:open_folder", { name: "world" }) })); await screen.findByRole("button", { name: "world" }); const lists = mocks.listServerFiles.mock.calls.length; await userEvent.click(screen.getByRole("button", { name: t("files:new_folder") })); await userEvent.type(within(screen.getByRole("dialog")).getByRole("textbox"), "datapacks{Enter}"); expect(mocks.mkdirServerFolder.mock.calls).toEqual([["lobby", "world/datapacks"]]); expect(await screen.findByText(i18next.t("files:folder_created", { path: "world/datapacks" }))).toBeTruthy(); expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1); expect(mocks.listServerFiles).toHaveBeenLastCalledWith("lobby", "world"); }); it("sends a name once, and stays open until the answer comes", async () => { let answer!: (v: unknown) => void; mocks.mkdirServerFolder.mockReturnValue(new Promise((res) => (answer = res))); renderFiles(); await userEvent.click(await screen.findByRole("button", { name: t("files:new_folder") })); const dialog = screen.getByRole("dialog"); await userEvent.type(within(dialog).getByRole("textbox"), "datapacks"); const create = within(dialog).getByRole("button", { name: t("files:create") }); await userEvent.click(create); await userEvent.click(create); await userEvent.keyboard("{Escape}"); expect(mocks.mkdirServerFolder).toHaveBeenCalledTimes(1); expect(screen.getByRole("dialog")).toBe(dialog); await act(async () => answer({ path: "datapacks", status: "created" })); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); }); it("keeps the dialog open with the server's refusal", async () => { const refusal = { status: 409, code: "file_exists", message: "something is already at datapacks" }; mocks.mkdirServerFolder.mockRejectedValue(refusal); renderFiles(); await userEvent.click(await screen.findByRole("button", { name: t("files:new_folder") })); const dialog = screen.getByRole("dialog"); await userEvent.type(within(dialog).getByRole("textbox"), "datapacks{Enter}"); expect(await within(dialog).findByText(humanizeError(refusal))).toBeTruthy(); expect(screen.getByRole("dialog")).toBe(dialog); }); it("opens a new file in the editor, and its first save expects the file it made", async () => { mocks.createServerFile.mockResolvedValue({ path: "notes.txt", status: "written", sha256: "e".repeat(64) }); mocks.writeServerFile.mockResolvedValue({ path: "notes.txt", status: "written", sha256: "f".repeat(64) }); renderFiles(); await userEvent.click(await screen.findByRole("button", { name: t("files:new_file") })); await userEvent.type(within(screen.getByRole("dialog")).getByRole("textbox"), "notes.txt{Enter}"); expect(mocks.createServerFile.mock.calls).toEqual([["lobby", "notes.txt", ""]]); const editor = await screen.findByRole("dialog"); expect(within(editor).getByText("notes.txt")).toBeTruthy(); await userEvent.type(within(editor).getByRole("textbox"), "hi"); await userEvent.click(within(editor).getByRole("button", { name: t("files:save") })); expect(mocks.writeServerFile.mock.calls).toEqual([["lobby", "notes.txt", btoa("hi"), "e".repeat(64)]]); }); it("asks before deleting a folder with everything in it, then rereads", async () => { mocks.deleteServerFile.mockResolvedValue({ path: "world/world", status: "deleted" }); renderFiles(); await userEvent.click(await screen.findByRole("button", { name: i18next.t("files:open_folder", { name: "world" }) })); await screen.findByRole("button", { name: "world" }); await userEvent.click(screen.getByRole("button", { name: i18next.t("files:delete_item", { name: "world" }) })); const dialog = screen.getByRole("dialog"); expect(within(dialog).getByText(i18next.t("files:delete_folder_title", { name: "world" }))).toBeTruthy(); expect(within(dialog).getByText(t("files:delete_folder_body"))).toBeTruthy(); expect(mocks.deleteServerFile).not.toHaveBeenCalled(); const lists = mocks.listServerFiles.mock.calls.length; await userEvent.click(within(dialog).getByRole("button", { name: t("files:delete") })); expect(mocks.deleteServerFile.mock.calls).toEqual([["lobby", "world/world"]]); expect(await screen.findByText(i18next.t("files:deleted", { path: "world/world" }))).toBeTruthy(); expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1); expect(mocks.listServerFiles).toHaveBeenLastCalledWith("lobby", "world"); }); it("words a file's deletion as a file's", async () => { renderFiles(); await userEvent.click(await screen.findByRole("button", { name: i18next.t("files:delete_item", { name: "server.properties" }) })); const dialog = screen.getByRole("dialog"); expect(within(dialog).getByText(i18next.t("files:delete_file_title", { name: "server.properties" }))).toBeTruthy(); expect(within(dialog).getByText(t("files:delete_file_body"))).toBeTruthy(); // The row's own click never fired: the file did not open behind it. expect(mocks.readServerFile).not.toHaveBeenCalled(); }); it("says why the proxy secret's file does not open, and reads nothing", async () => { mocks.listServerFiles.mockImplementation((_name: string, path: string) => Promise.resolve({ path, truncated: false, entries: path === "config" ? [{ name: "paper-global.yml", size: 900, is_dir: false, mod_time: "2026-09-01T00:00:00Z" }] : [{ name: "config", size: 0, is_dir: true, mod_time: "2026-09-01T00:00:00Z" }], }), ); renderFiles(); await userEvent.click(await screen.findByRole("button", { name: i18next.t("files:open_folder", { name: "config" }) })); await userEvent.click(await screen.findByText("paper-global.yml")); expect(screen.getByText(t("files:secret_config_unreadable"))).toBeTruthy(); expect(mocks.readServerFile).not.toHaveBeenCalled(); expect(button("rename_item", "paper-global.yml").disabled).toBe(true); }); it("lists folders first and numbers in counting order", async () => { mocks.listServerFiles.mockResolvedValue({ path: "", truncated: false, entries: ["r.10.0.mca", "world", "r.2.0.mca", "logs"].map((name) => ({ name, size: 1, is_dir: !name.endsWith(".mca"), mod_time: "2026-09-01T00:00:00Z", })), }); renderFiles(); await screen.findByText("logs"); const names = screen .getAllByRole("button", { name: /^(Open|打开)/ }) .map((b) => b.textContent); expect(names).toEqual(["logs", "world", "r.2.0.mca", "r.10.0.mca"]); }); }); describe("ServerFiles uploads", () => { type Pending = { resolve: () => void; reject: (e: unknown) => void; signal?: AbortSignal; progress?: (sent: number) => void; }; let pending: Pending[]; beforeEach(() => { pending = []; mocks.uploadServerFile.mockImplementation( ( _name: string, path: string, file: File, _overwrite: boolean, opts?: { signal?: AbortSignal; onProgress?: (sent: number) => void }, ) => new Promise((resolve, reject) => { pending.push({ resolve: () => resolve({ path, status: "uploaded", sha256: "a", size: file.size }), reject, signal: opts?.signal, progress: opts?.onProgress, }); opts?.signal?.addEventListener("abort", () => reject(new DOMException("cancelled", "AbortError"))); }), ); }); const file = (name: string, body = "bytes") => new File([body], name); const pick = (...files: File[]) => fireEvent.change(screen.getByTestId("upload-input"), { target: { files } }); const queue = () => screen.getByRole("region", { name: t("files:uploads_label") }); const sentAs = () => mocks.uploadServerFile.mock.calls.map((c) => [c[1], c[3]]); it("sends one at a time and rereads the folder once the queue is empty", async () => { renderFiles(); await screen.findByText("world"); const lists = mocks.listServerFiles.mock.calls.length; pick(file("a.jar"), file("b.jar")); await waitFor(() => expect(sentAs()).toEqual([["a.jar", false]])); expect(within(queue()).getByText(t("files:upload_queued"))).toBeTruthy(); await act(async () => pending[0].resolve()); await waitFor(() => expect(sentAs()).toEqual([["a.jar", false], ["b.jar", false]])); expect(mocks.listServerFiles.mock.calls.length).toBe(lists); await act(async () => pending[1].resolve()); await waitFor(() => expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1)); expect(within(queue()).getAllByText(t("files:upload_done"))).toHaveLength(2); }); it("rereads once at the end even when the folder changes on the way", async () => { renderFiles(); await screen.findByText("world"); pick(file("a.jar"), file("b.jar")); await waitFor(() => expect(pending).toHaveLength(1)); await act(async () => pending[0].resolve()); await waitFor(() => expect(pending).toHaveLength(2)); const lists = mocks.listServerFiles.mock.calls.length; await userEvent.click(screen.getByRole("button", { name: i18next.t("files:open_folder", { name: "world" }) })); await screen.findByRole("button", { name: "world" }); expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1); await act(async () => pending[1].resolve()); await waitFor(() => expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 2)); expect(mocks.listServerFiles).toHaveBeenLastCalledWith("lobby", "world"); // That reread was the landing's: going back is one listing, as always. await userEvent.click(screen.getByRole("button", { name: t("files:up") })); await waitFor(() => expect(screen.queryByRole("button", { name: "world" })).toBeNull()); expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 3); }); it("lands in the folder on screen when it was added", async () => { renderFiles(); await userEvent.click(await screen.findByRole("button", { name: i18next.t("files:open_folder", { name: "world" }) })); await screen.findByRole("button", { name: "world" }); pick(file("level.dat_old")); await waitFor(() => expect(sentAs()).toEqual([["world/level.dat_old", false]])); }); it("waits on a file already there until told to replace it", async () => { renderFiles(); await screen.findByText("world"); pick(file("server.properties")); expect(await within(queue()).findByText(t("files:upload_exists"))).toBeTruthy(); expect(mocks.uploadServerFile).not.toHaveBeenCalled(); await userEvent.click(within(queue()).getByRole("button", { name: t("files:upload_replace") })); await waitFor(() => expect(sentAs()).toEqual([["server.properties", true]])); }); it("drops a skipped file without sending it", async () => { renderFiles(); await screen.findByText("world"); pick(file("server.properties")); await userEvent.click(await within(queue()).findByRole("button", { name: t("files:upload_skip") })); expect(screen.queryByRole("region", { name: t("files:uploads_label") })).toBeNull(); expect(mocks.uploadServerFile).not.toHaveBeenCalled(); }); const sized = (name: string, size: number) => { const f = file(name); Object.defineProperty(f, "size", { value: size }); return f; }; const withFree = (free: number) => mocks.listServerFiles.mockImplementation((_name: string, path: string) => Promise.resolve({ path, truncated: false, free_bytes: free, entries: [{ name: "world", size: 0, is_dir: true, mod_time: "2026-09-01T00:00:00Z" }], }), ); it("refuses a name a folder holds without sending or offering a retry", async () => { renderFiles(); await screen.findByText("world"); pick(file("world")); expect(await within(queue()).findByText(t("files:upload_folder_there"))).toBeTruthy(); expect(within(queue()).queryByRole("button", { name: t("files:upload_retry") })).toBeNull(); expect(mocks.uploadServerFile).not.toHaveBeenCalled(); }); it("refuses what the volume has no room for, counting the files ahead in the batch, and retries once there is room", async () => { withFree(100); renderFiles(); await screen.findByText("world"); pick(sized("a.jar", 60), sized("b.jar", 50), sized("c.jar", 40)); expect(await within(queue()).findByText(i18next.t("files:upload_no_room", { free: "40 B", size: "50 B" }))).toBeTruthy(); await waitFor(() => expect(sentAs()).toEqual([["a.jar", false]])); await act(async () => pending[0].resolve()); await waitFor(() => expect(sentAs()).toEqual([["a.jar", false], ["c.jar", false]])); await act(async () => pending[1].resolve()); await waitFor(() => expect(within(queue()).getAllByText(t("files:upload_done"))).toHaveLength(2)); // The listing after they landed still says 100 B free. await userEvent.click(within(queue()).getByRole("button", { name: t("files:upload_retry") })); await waitFor(() => expect(sentAs()).toEqual([["a.jar", false], ["c.jar", false], ["b.jar", false]])); }); it("sends when the listing could not tell how much room there is", async () => { withFree(0); renderFiles(); await screen.findByText("world"); pick(sized("a.jar", 60)); await waitFor(() => expect(sentAs()).toEqual([["a.jar", false]])); }); it("keeps a retry refused while the latest listing has no room for the file", async () => { withFree(10); renderFiles(); await screen.findByText("world"); pick(sized("a.jar", 60)); await within(queue()).findByText(i18next.t("files:upload_no_room", { free: "10 B", size: "60 B" })); withFree(20); await userEvent.click(screen.getByRole("button", { name: t("files:refresh") })); await waitFor(() => expect(mocks.listServerFiles).toHaveBeenCalledTimes(2)); await userEvent.click(within(queue()).getByRole("button", { name: t("files:upload_retry") })); expect(await within(queue()).findByText(i18next.t("files:upload_no_room", { free: "20 B", size: "60 B" }))).toBeTruthy(); expect(mocks.uploadServerFile).not.toHaveBeenCalled(); }); it("sends a file of exactly one request's worth in one request", async () => { renderFiles(); await screen.findByText("world"); pick(sized("edge.zip", ONE_REQUEST_BYTES)); await waitFor(() => expect(sentAs()).toEqual([["edge.zip", false]])); expect(mocks.beginServerFileUpload).not.toHaveBeenCalled(); }); describe("a file over one request's worth", () => { const PART = 32 * 1024 * 1024; const SIZE = ONE_REQUEST_BYTES + 1; const KEY = "felis-file-upload:lobby:world.zip"; const at = (received: number) => ({ id: "s1", path: "world.zip", size: SIZE, received, part_max_bytes: PART }); let parts: { offset: number; signal?: AbortSignal }[]; beforeEach(() => { parts = []; mocks.beginServerFileUpload.mockResolvedValue(at(0)); mocks.putServerFileUploadPart.mockImplementation( async (_n: string, _id: string, offset: number, _part: Blob, opts?: { signal?: AbortSignal }) => { parts.push({ offset, signal: opts?.signal }); return at(Math.min(offset + PART, SIZE)); }, ); mocks.commitServerFileUpload.mockResolvedValue({ op: fileOp({ id: "land1", op: "upload", path: "world.zip" }) }); mocks.deleteServerFileUpload.mockResolvedValue(null); }); it("goes up in parts, shows the Job writing it, and rereads once it lands", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); renderFiles(); await screen.findByText("world"); const lists = mocks.listServerFiles.mock.calls.length; pick(sized("world.zip", SIZE)); await waitFor(() => expect(mocks.commitServerFileUpload.mock.calls).toEqual([["lobby", "s1", false]])); expect(parts.map((p) => p.offset)).toEqual([0, PART, 2 * PART]); expect(mocks.uploadServerFile).not.toHaveBeenCalled(); expect(await within(queue()).findByText(t("files:upload_landing"))).toBeTruthy(); opsNow = [fileOp({ id: "land1", op: "upload", path: "world.zip", done: SIZE / 4, total: SIZE })]; await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS)); expect(within(queue()).getByText(i18next.t("files:upload_landing_progress", { percent: 25 }))).toBeTruthy(); expect(within(queue()).getByRole("progressbar").getAttribute("aria-valuenow")).toBe("25"); // Its own row follows it; the list of background operations leaves it out. fireEvent.click(screen.getByRole("button", { name: t("files:refresh") })); await waitFor(() => expect(mocks.listServerFileOps.mock.calls.length).toBeGreaterThanOrEqual(2)); expect(screen.queryByRole("region", { name: t("files:ops_label") })).toBeNull(); const before = mocks.listServerFiles.mock.calls.length; opsNow = [fileOp({ id: "land1", op: "upload", path: "world.zip", state: "succeeded", done: SIZE, total: SIZE })]; await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS)); expect(await within(queue()).findByText(t("files:upload_done"))).toBeTruthy(); await waitFor(() => expect(mocks.listServerFiles.mock.calls.length).toBe(before + 1)); expect(before).toBe(lists + 1); // the refresh expect(localStorage.getItem(KEY)).toBeNull(); }); it("says why the Job landing it failed, and offers a retry", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); renderFiles(); await screen.findByText("world"); pick(sized("world.zip", SIZE)); await waitFor(() => expect(mocks.commitServerFileUpload).toHaveBeenCalled()); opsNow = [ fileOp({ id: "land1", op: "upload", path: "world.zip", state: "failed", error: { code: "volume_full", message: "", need: 2048, avail: 1024 }, }), ]; await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS)); expect( await within(queue()).findByText(i18next.t("files:op_volume_full", { need: "2.0 KiB", avail: "1.0 KiB" })), ).toBeTruthy(); expect(within(queue()).getByRole("button", { name: t("files:upload_retry") })).toBeTruthy(); }); it("asks to replace when the Job found a file there, and lands the bytes already sent", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); renderFiles(); await screen.findByText("world"); pick(sized("world.zip", SIZE)); await waitFor(() => expect(mocks.commitServerFileUpload).toHaveBeenCalledTimes(1)); opsNow = [ fileOp({ id: "land1", op: "upload", path: "world.zip", state: "failed", error: { code: "file_exists", message: "" } }), ]; await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS)); mocks.getServerFileUpload.mockResolvedValue(at(SIZE)); mocks.commitServerFileUpload.mockResolvedValue({ op: fileOp({ id: "land2", op: "upload", path: "world.zip", state: "succeeded" }), }); fireEvent.click(await within(queue()).findByRole("button", { name: t("files:upload_replace") })); await waitFor(() => expect(mocks.commitServerFileUpload).toHaveBeenLastCalledWith("lobby", "s1", true)); expect(parts).toHaveLength(3); expect(mocks.beginServerFileUpload).toHaveBeenCalledTimes(1); expect(await within(queue()).findByText(t("files:upload_done"))).toBeTruthy(); }); it.each([ ["dismissed", { code: "volume_full", message: "" }, "upload_dismiss", { name: "world.zip" }], ["skipped", { code: "file_exists", message: "" }, "upload_skip", {}], ])("gives the session back when a refused one is %s", async (_label, error, key, opts) => { vi.useFakeTimers({ shouldAdvanceTime: true }); renderFiles(); await screen.findByText("world"); pick(sized("world.zip", SIZE)); await waitFor(() => expect(mocks.commitServerFileUpload).toHaveBeenCalledTimes(1)); opsNow = [fileOp({ id: "land1", op: "upload", path: "world.zip", state: "failed", error })]; await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS)); fireEvent.click(await within(queue()).findByRole("button", { name: i18next.t(`files:${key}`, opts) })); await waitFor(() => expect(mocks.deleteServerFileUpload.mock.calls).toEqual([["lobby", "s1"]])); expect(localStorage.getItem(KEY)).toBeNull(); expect(screen.queryByRole("region", { name: t("files:uploads_label") })).toBeNull(); }); it("gives the session back when skipped with the rest", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); renderFiles(); await screen.findByText("world"); pick(sized("world.zip", SIZE)); await waitFor(() => expect(mocks.commitServerFileUpload).toHaveBeenCalledTimes(1)); opsNow = [ fileOp({ id: "land1", op: "upload", path: "world.zip", state: "failed", error: { code: "file_exists", message: "" } }), ]; await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS)); await within(queue()).findByRole("button", { name: t("files:upload_replace") }); pick(file("server.properties")); fireEvent.click(await within(queue()).findByRole("button", { name: t("files:upload_skip_all") })); await waitFor(() => expect(mocks.deleteServerFileUpload.mock.calls).toEqual([["lobby", "s1"]])); expect(screen.queryByRole("region", { name: t("files:uploads_label") })).toBeNull(); }); it("gives the session back when cancelled", async () => { mocks.putServerFileUploadPart.mockImplementation( (_n: string, _id: string, offset: number, _part: Blob, opts?: { signal?: AbortSignal }) => new Promise((_resolve, reject) => { parts.push({ offset, signal: opts?.signal }); opts?.signal?.addEventListener("abort", () => reject(new DOMException("cancelled", "AbortError"))); }), ); renderFiles(); await screen.findByText("world"); pick(sized("world.zip", SIZE)); await waitFor(() => expect(parts).toHaveLength(1)); expect(localStorage.getItem(KEY)).not.toBeNull(); await userEvent.click(within(queue()).getByRole("button", { name: i18next.t("files:upload_cancel", { name: "world.zip" }) })); await waitFor(() => expect(mocks.deleteServerFileUpload.mock.calls).toEqual([["lobby", "s1"]])); expect(localStorage.getItem(KEY)).toBeNull(); }); it("keeps the session for a resume when the page goes away", async () => { mocks.putServerFileUploadPart.mockImplementation( (_n: string, _id: string, offset: number, _part: Blob, opts?: { signal?: AbortSignal }) => new Promise((_resolve, reject) => { parts.push({ offset, signal: opts?.signal }); opts?.signal?.addEventListener("abort", () => reject(new DOMException("cancelled", "AbortError"))); }), ); const { unmount } = renderFiles(); await screen.findByText("world"); pick(sized("world.zip", SIZE)); await waitFor(() => expect(parts).toHaveLength(1)); unmount(); await act(async () => {}); expect(parts[0].signal?.aborted).toBe(true); expect(mocks.deleteServerFileUpload).not.toHaveBeenCalled(); expect(JSON.parse(localStorage.getItem(KEY) ?? "null")).toMatchObject({ id: "s1", size: SIZE }); }); }); it("holds the queue and every change while a background op runs, then carries on", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); opsNow = [fileOp()]; renderFiles(); const ops = await screen.findByRole("region", { name: t("files:ops_label") }); expect(within(ops).getByText(i18next.t("files:op_unzip", { path: "pack.zip" }))).toBeTruthy(); expect(within(ops).getByText(t("files:op_preparing"))).toBeTruthy(); const lists = mocks.listServerFiles.mock.calls.length; pick(file("a.jar")); expect(await within(queue()).findByText(t("files:upload_queued"))).toBeTruthy(); for (const b of [button("new_file"), button("new_folder"), button("rename_item", "world"), button("delete_item", "world")]) { expect(b.disabled).toBe(true); expect(b.title).toBe(t("files:wait_for_op")); } opsNow = [fileOp({ done: 512, total: 2048 })]; await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS)); expect(within(ops).getByText(i18next.t("files:op_progress", { done: "512 B", total: "2.0 KiB", percent: 25 }))).toBeTruthy(); expect(within(ops).getByRole("progressbar").getAttribute("aria-valuenow")).toBe("25"); expect(mocks.uploadServerFile).not.toHaveBeenCalled(); opsNow = [fileOp({ state: "succeeded", files: 3, bytes: 2048, done: 2048, total: 2048 })]; await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS)); await waitFor(() => expect(sentAs()).toEqual([["a.jar", false]])); expect(within(ops).getByText(i18next.t("files:op_unzip_done", { count: 3, bytes: "2.0 KiB" }))).toBeTruthy(); // What it extracted is in the folder now. await waitFor(() => expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1)); fireEvent.click(within(ops).getByRole("button", { name: i18next.t("files:op_dismiss", { path: "pack.zip" }) })); expect(screen.queryByRole("region", { name: t("files:ops_label") })).toBeNull(); }); it("asks about a file that appeared since the listing", async () => { renderFiles(); await screen.findByText("world"); pick(file("icon.png")); await waitFor(() => expect(pending).toHaveLength(1)); await act(async () => pending[0].reject({ status: 409, code: "file_exists", message: "something is already at icon.png" })); expect(await within(queue()).findByRole("button", { name: t("files:upload_replace") })).toBeTruthy(); expect(within(queue()).getByText(t("files:upload_exists"))).toBeTruthy(); }); it("offers a retry after a failure and sends it again", async () => { const full = { status: 507, code: "volume_full", message: "no space left" }; renderFiles(); await screen.findByText("world"); pick(file("big.jar")); await waitFor(() => expect(pending).toHaveLength(1)); await act(async () => pending[0].reject(full)); expect(await within(queue()).findByText(humanizeError(full))).toBeTruthy(); await userEvent.click(within(queue()).getByRole("button", { name: t("files:upload_retry") })); await waitFor(() => expect(sentAs()).toEqual([["big.jar", false], ["big.jar", false]])); }); it("cancels the upload in flight and rereads nothing", async () => { renderFiles(); await screen.findByText("world"); const lists = mocks.listServerFiles.mock.calls.length; pick(file("a.jar")); await waitFor(() => expect(pending).toHaveLength(1)); await userEvent.click(within(queue()).getByRole("button", { name: i18next.t("files:upload_cancel", { name: "a.jar" }) })); expect(pending[0].signal?.aborted).toBe(true); await waitFor(() => expect(screen.queryByRole("region", { name: t("files:uploads_label") })).toBeNull()); expect(mocks.listServerFiles.mock.calls.length).toBe(lists); }); it("stops the upload in flight when the page goes away", async () => { const { unmount } = renderFiles(); await screen.findByText("world"); pick(file("a.jar")); await waitFor(() => expect(pending).toHaveLength(1)); unmount(); expect(pending[0].signal?.aborted).toBe(true); }); it("holds every other change, and leaving the page, while an upload runs", async () => { renderFiles(); await screen.findByText("world"); pick(file("a.jar")); await waitFor(() => expect(pending).toHaveLength(1)); for (const b of [button("new_file"), button("new_folder"), button("rename_item", "world"), button("delete_item", "world")]) { expect(b.disabled).toBe(true); } const leave = new Event("beforeunload", { cancelable: true }); window.dispatchEvent(leave); expect(leave.defaultPrevented).toBe(true); await act(async () => pending[0].resolve()); await waitFor(() => expect(button("new_file").disabled).toBe(false)); expect(button("delete_item", "world").disabled).toBe(false); }); describe("with several files already there", () => { beforeEach(() => { mocks.listServerFiles.mockImplementation((_name: string, path: string) => Promise.resolve({ path, truncated: false, entries: ["a.yml", "b.yml"].map((name) => ({ name, size: 8, is_dir: false, mod_time: "2026-09-01T00:00:00Z" })), }), ); }); it("replaces every waiting file at once", async () => { renderFiles(); await screen.findByText("a.yml"); pick(file("a.yml"), file("b.yml"), file("c.yml")); await waitFor(() => expect(sentAs()).toEqual([["c.yml", false]])); await userEvent.click(within(queue()).getByRole("button", { name: t("files:upload_replace_all") })); await act(async () => pending[0].resolve()); await waitFor(() => expect(pending).toHaveLength(2)); await act(async () => pending[1].resolve()); await waitFor(() => expect(sentAs()).toEqual([["c.yml", false], ["a.yml", true], ["b.yml", true]])); }); it("skips every waiting file at once and keeps the rest", async () => { renderFiles(); await screen.findByText("a.yml"); pick(file("a.yml"), file("b.yml"), file("c.yml")); await waitFor(() => expect(pending).toHaveLength(1)); await userEvent.click(within(queue()).getByRole("button", { name: t("files:upload_skip_all") })); expect(within(queue()).queryByText(t("files:upload_exists"))).toBeNull(); expect(within(queue()).getByText("c.yml")).toBeTruthy(); expect(sentAs()).toEqual([["c.yml", false]]); }); }); it("clears the finished uploads and keeps the ones still asking", async () => { renderFiles(); await screen.findByText("world"); pick(file("a.jar"), file("server.properties")); await waitFor(() => expect(pending).toHaveLength(1)); await act(async () => pending[0].resolve()); await within(queue()).findByText(t("files:upload_done")); await userEvent.click(within(queue()).getByRole("button", { name: t("files:upload_clear_done") })); expect(within(queue()).queryByText("a.jar")).toBeNull(); expect(within(queue()).getByText("server.properties")).toBeTruthy(); }); it("shows the file being written once all of it is sent, and no longer offers a cancel", async () => { renderFiles(); await screen.findByText("world"); pick(file("a.jar", "0123456789")); await waitFor(() => expect(pending).toHaveLength(1)); const cancel = i18next.t("files:upload_cancel", { name: "a.jar" }); await act(async () => pending[0].progress?.(4)); expect(within(queue()).getByRole("progressbar").getAttribute("aria-valuenow")).toBe("40"); expect(within(queue()).getByRole("button", { name: cancel })).toBeTruthy(); await act(async () => pending[0].progress?.(10)); expect(within(queue()).getByText(t("files:upload_landing"))).toBeTruthy(); expect(within(queue()).queryByRole("button", { name: cancel })).toBeNull(); }); it("takes dropped files and names the folders it skipped", async () => { renderFiles(); const table = await screen.findByRole("table"); const dataTransfer = { types: ["Files"], files: [], items: [ { kind: "file", webkitGetAsEntry: () => ({ isDirectory: true }), getAsFile: () => null }, { kind: "file", webkitGetAsEntry: () => ({ isDirectory: false }), getAsFile: () => file("a.jar") }, ], }; fireEvent.drop(table, { dataTransfer }); await waitFor(() => expect(sentAs()).toEqual([["a.jar", false]])); expect(screen.getByText(i18next.t("files:upload_no_folders", { count: 1 }))).toBeTruthy(); }); it("keeps a file dropped beside the list from replacing the page", async () => { renderFiles(); const table = await screen.findByRole("table"); const beside = { types: ["Files"], dropEffect: "move" }; const onList = { types: ["Files"], dropEffect: "move" }; expect(fireEvent.dragOver(document.body, { dataTransfer: beside })).toBe(false); expect(beside.dropEffect).toBe("none"); expect(fireEvent.dragOver(table, { dataTransfer: onList })).toBe(false); expect(onList.dropEffect).toBe("copy"); // Dragging text is the browser's own business. expect(fireEvent.dragOver(document.body, { dataTransfer: { types: ["text/plain"], dropEffect: "move" } })).toBe(true); }); }); describe("ServerFiles archives and downloads", () => { const entry = (name: string, is_dir = false) => ({ name, size: 8, is_dir, mod_time: "2026-09-01T00:00:00Z" }); beforeEach(() => { mocks.listServerFiles.mockImplementation((_name: string, path: string) => Promise.resolve({ path, truncated: false, entries: path === "config" ? [entry("paper-global.yml"), entry("bukkit.yml")] : [entry("server.properties"), entry("Pack.ZIP"), entry("old.zip", true), entry("config", true), entry("world", true)], }), ); }); const conflicted = (over: Partial = {}) => fileOp({ id: "u1", path: "Pack.ZIP", state: "failed", error: { code: "file_exists", message: "", conflicts: ["world/level.dat", "ops.json"], conflict_count: 3 }, ...over, }); const opsBanner = () => screen.getByRole("region", { name: t("files:ops_label") }); const poll = () => act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS)); it("extracts an archive, lists what it would replace, and replaces them once confirmed", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); renderFiles(); await screen.findByText("Pack.ZIP"); expect(screen.queryByRole("button", { name: i18next.t("files:unzip_item", { name: "server.properties" }) })).toBeNull(); expect(screen.queryByRole("button", { name: i18next.t("files:unzip_item", { name: "old.zip" }) })).toBeNull(); const first = fileOp({ id: "u1", path: "Pack.ZIP" }); mocks.unzipServerFile.mockResolvedValueOnce({ op: first }); opsNow = [first]; fireEvent.click(button("unzip_item", "Pack.ZIP")); const banner = await screen.findByRole("region", { name: t("files:ops_label") }); expect(within(banner).getByText(i18next.t("files:op_unzip", { path: "Pack.ZIP" }))).toBeTruthy(); expect(mocks.unzipServerFile.mock.calls).toEqual([["lobby", "Pack.ZIP", false]]); expect(button("unzip_item", "Pack.ZIP").disabled).toBe(true); expect(button("unzip_item", "Pack.ZIP").title).toBe(t("files:wait_for_op")); opsNow = [conflicted()]; await poll(); const dialog = await screen.findByRole("dialog"); expect(within(dialog).getByText(i18next.t("files:unzip_conflicts_title", { name: "Pack.ZIP", count: 3 }))).toBeTruthy(); expect(within(dialog).getAllByRole("listitem").map((li) => li.textContent)).toEqual(["world/level.dat", "ops.json"]); expect(within(dialog).getByText(i18next.t("files:unzip_conflicts_more", { count: 1 }))).toBeTruthy(); const lists = mocks.listServerFiles.mock.calls.length; const second = fileOp({ id: "u2", path: "Pack.ZIP" }); mocks.unzipServerFile.mockResolvedValueOnce({ op: second }); opsNow = [second, conflicted()]; fireEvent.click(within(dialog).getByRole("button", { name: t("files:unzip_overwrite") })); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); expect(mocks.unzipServerFile.mock.calls).toEqual([ ["lobby", "Pack.ZIP", false], ["lobby", "Pack.ZIP", true], ]); // The refused try is dismissed: the one replacing takes its place. expect(within(opsBanner()).getAllByRole("listitem")).toHaveLength(1); expect(within(opsBanner()).getByText(t("files:op_preparing"))).toBeTruthy(); opsNow = [fileOp({ id: "u2", path: "Pack.ZIP", state: "succeeded", files: 5, bytes: 1024 }), conflicted()]; await poll(); expect(within(opsBanner()).getByText(i18next.t("files:op_unzip_done", { count: 5, bytes: "1.0 KiB" }))).toBeTruthy(); await waitFor(() => expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1)); expect(screen.queryByRole("dialog")).toBeNull(); }); it("holds every change while an extraction starts, and asks at once when it ended before a read", async () => { let answer!: (v: { op: FileOp }) => void; mocks.unzipServerFile.mockReturnValueOnce(new Promise((res) => (answer = res))); renderFiles(); await screen.findByText("Pack.ZIP"); fireEvent.click(button("unzip_item", "Pack.ZIP")); for (const b of [button("new_file"), button("download_item", "server.properties"), button("delete_item", "world")]) { expect(b.disabled).toBe(true); expect(b.title).toBe(t("files:wait_for_op")); } mocks.uploadServerFile.mockResolvedValue({ path: "a.jar", status: "uploaded", sha256: "a", size: 5 }); fireEvent.change(screen.getByTestId("upload-input"), { target: { files: [new File(["bytes"], "a.jar")] } }); await act(async () => {}); expect(mocks.uploadServerFile).not.toHaveBeenCalled(); await act(async () => answer({ op: conflicted() })); const dialog = await screen.findByRole("dialog"); expect(within(dialog).getByText(i18next.t("files:unzip_conflicts_title", { name: "Pack.ZIP", count: 3 }))).toBeTruthy(); await waitFor(() => expect(mocks.uploadServerFile.mock.calls.map((c) => c[1])).toEqual(["a.jar"])); }); it("asks nothing about an extraction started here that failed for another reason", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); const started = fileOp({ id: "u1", path: "Pack.ZIP" }); mocks.unzipServerFile.mockResolvedValueOnce({ op: started }); renderFiles(); await screen.findByText("Pack.ZIP"); opsNow = [started]; fireEvent.click(button("unzip_item", "Pack.ZIP")); await screen.findByRole("region", { name: t("files:ops_label") }); expect(mocks.unzipServerFile.mock.calls).toEqual([["lobby", "Pack.ZIP", false]]); opsNow = [conflicted({ error: { code: "archive_invalid", message: "zip: not a valid zip file" } })]; await poll(); expect(within(opsBanner()).getByText(t("files:archive_invalid"))).toBeTruthy(); expect(within(opsBanner()).queryByRole("button", { name: t("files:op_conflicts") })).toBeNull(); expect(screen.queryByRole("dialog")).toBeNull(); }); it("asks nothing at once about an extraction started elsewhere, and offers its conflicts", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); opsNow = [fileOp({ id: "u9", path: "maps/Pack.ZIP" })]; renderFiles(); await within(await screen.findByRole("region", { name: t("files:ops_label") })).findByText(t("files:op_preparing")); opsNow = [conflicted({ id: "u9", path: "maps/Pack.ZIP", error: { code: "file_exists", message: "", conflicts: ["a.txt"] } })]; await poll(); expect(within(opsBanner()).getByText(i18next.t("files:op_unzip_conflicts", { count: 1 }))).toBeTruthy(); expect(screen.queryByRole("dialog")).toBeNull(); fireEvent.click(within(opsBanner()).getByRole("button", { name: t("files:op_conflicts") })); const dialog = await screen.findByRole("dialog"); expect(within(dialog).getByText(i18next.t("files:unzip_conflicts_title", { name: "Pack.ZIP", count: 1 }))).toBeTruthy(); expect(within(dialog).getAllByRole("listitem").map((li) => li.textContent)).toEqual(["a.txt"]); expect(within(dialog).queryByText(i18next.t("files:unzip_conflicts_more", { count: 0 }))).toBeNull(); mocks.unzipServerFile.mockResolvedValueOnce({ op: fileOp({ id: "u10", path: "maps/Pack.ZIP" }) }); fireEvent.click(within(dialog).getByRole("button", { name: t("files:unzip_overwrite") })); await waitFor(() => expect(mocks.unzipServerFile.mock.calls).toEqual([["lobby", "maps/Pack.ZIP", true]])); }); it("says why an extraction could not start, and lets it be tried again", async () => { const held = { status: 409, code: "maintenance_in_progress", message: "a backup holds the world" }; mocks.unzipServerFile.mockRejectedValueOnce(held); renderFiles(); await screen.findByText("Pack.ZIP"); fireEvent.click(button("unzip_item", "Pack.ZIP")); expect(await screen.findByText(humanizeError(held))).toBeTruthy(); expect(button("unzip_item", "Pack.ZIP").disabled).toBe(false); expect(screen.queryByRole("region", { name: t("files:ops_label") })).toBeNull(); }); it("says when the background ops cannot be read, until a refresh gets through", async () => { const broke = { status: 500, code: "internal", message: "" }; mocks.listServerFileOps.mockRejectedValueOnce(broke); renderFiles(); const ops = await screen.findByRole("region", { name: t("files:ops_label") }); expect(within(ops).getByText(i18next.t("files:ops_refresh_failed", { reason: humanizeError(broke) }))).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: t("files:refresh") })); await waitFor(() => expect(screen.queryByRole("region", { name: t("files:ops_label") })).toBeNull()); }); function watchLinks() { const clicked: { href: string | null; download: string; hidden: boolean; connected: boolean }[] = []; vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { clicked.push({ href: this.getAttribute("href"), download: this.download, hidden: this.hidden, connected: this.isConnected }); }); return clicked; } // readyAfter answers "pending" for the first n reads of the ticket, then "ready". function readyAfter(n: number) { let reads = 0; mocks.exportStatus.mockImplementation(async () => ({ state: reads++ < n ? "pending" : "ready" })); } const exportPoll = () => act(() => vi.advanceTimersByTimeAsync(EXPORT_POLL_MS)); it("hands a file to the browser once it is ready, holding every change while it is prepared", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); const clicked = watchLinks(); mocks.downloadServerFile.mockResolvedValue({ ticket: "t1", state: "pending", filename: "server.properties" }); mocks.exportDownloadURL.mockResolvedValue("/api/v1/exports/t1/download"); mocks.uploadServerFile.mockResolvedValue({ path: "a.jar", status: "uploaded", sha256: "a", size: 5 }); readyAfter(1); renderFiles(); await screen.findByText("Pack.ZIP"); fireEvent.click(button("download_item", "server.properties")); await waitFor(() => expect(mocks.downloadServerFile.mock.calls).toEqual([["lobby", "server.properties", false]])); expect(button("download_item", "server.properties").title).toBe( i18next.t("files:download_preparing", { name: "server.properties" }), ); for (const b of [button("new_file"), button("download_folder_item", "world"), button("delete_item", "world")]) { expect(b.disabled).toBe(true); expect(b.title).toBe(t("files:wait_for_download")); } fireEvent.change(screen.getByTestId("upload-input"), { target: { files: [new File(["bytes"], "a.jar")] } }); await exportPoll(); expect(mocks.exportStatus.mock.calls).toEqual([["t1"]]); expect(clicked).toEqual([]); expect(mocks.uploadServerFile).not.toHaveBeenCalled(); await exportPoll(); expect(clicked).toEqual([{ href: "/api/v1/exports/t1/download", download: "server.properties", hidden: true, connected: true }]); expect(await screen.findByText(i18next.t("files:download_started_props", { filename: "server.properties" }))).toBeTruthy(); expect(button("new_file").disabled).toBe(false); await waitFor(() => expect(mocks.uploadServerFile.mock.calls.map((c) => c[1])).toEqual(["a.jar"])); }); it("abandons a download the page left before it was ready", async () => { const clicked = watchLinks(); mocks.downloadServerFile.mockResolvedValue({ ticket: "t1", state: "pending", filename: "world.zip" }); readyAfter(0); const { unmount } = renderFiles(); await screen.findByText("Pack.ZIP"); vi.useFakeTimers(); await act(async () => { fireEvent.click(button("download_folder_item", "world")); }); unmount(); await exportPoll(); expect(mocks.exportStatus).not.toHaveBeenCalled(); expect(clicked).toEqual([]); }); it.each([ ["config", "download_started_config"], ["world", "download_started"], ])("downloads the folder %s as a zip, and says what it leaves out", async (folder, note) => { watchLinks(); mocks.downloadServerFile.mockResolvedValue({ ticket: "t1", state: "pending", filename: `lobby-${folder}.zip` }); mocks.exportDownloadURL.mockResolvedValue("/api/v1/exports/t1/download"); readyAfter(0); renderFiles(); await screen.findByText("Pack.ZIP"); vi.useFakeTimers(); await act(async () => { fireEvent.click(button("download_folder_item", folder)); }); await exportPoll(); expect(mocks.downloadServerFile.mock.calls).toEqual([["lobby", folder, true]]); expect(screen.getByText(i18next.t(`files:${note}`, { filename: `lobby-${folder}.zip` }))).toBeTruthy(); }); it("offers no download of the proxy secret, and one of the files beside it", async () => { renderFiles(); fireEvent.click(await screen.findByRole("button", { name: i18next.t("files:open_folder", { name: "config" }) })); await screen.findByText("bukkit.yml"); expect(button("download_item", "paper-global.yml").disabled).toBe(true); expect(button("download_item", "paper-global.yml").title).toBe(t("files:secret_config_no_download")); expect(button("download_item", "bukkit.yml").disabled).toBe(false); }); it.each([ [ "refused as too many", () => mocks.downloadServerFile.mockRejectedValue({ status: 429, code: "export_busy", message: "one at a time" }), () => t("files:download_busy"), ], [ "refused otherwise", () => mocks.downloadServerFile.mockRejectedValue({ status: 409, code: "maintenance_in_progress", message: "a backup holds the world" }), () => i18next.t("files:download_failed_because", { reason: humanizeError({ status: 409, code: "maintenance_in_progress", message: "a backup holds the world" }), }), ], [ "failed with a reason", () => mocks.exportStatus.mockResolvedValue({ state: "failed", message: "tar: world: Cannot open" }), () => i18next.t("files:download_failed_because", { reason: "tar: world: Cannot open" }), ], ["failed without one", () => mocks.exportStatus.mockResolvedValue({ state: "failed" }), () => t("files:download_failed")], ])("says why a download could not be prepared when %s", async (_label, arrange, words) => { const clicked = watchLinks(); mocks.downloadServerFile.mockResolvedValue({ ticket: "t1", state: "pending", filename: "world.zip" }); arrange(); renderFiles(); await screen.findByText("Pack.ZIP"); vi.useFakeTimers(); await act(async () => { fireEvent.click(button("download_folder_item", "world")); }); await exportPoll(); expect(screen.getByText(words())).toBeTruthy(); expect(clicked).toEqual([]); expect(mocks.exportDownloadURL).not.toHaveBeenCalled(); expect(button("download_folder_item", "world").disabled).toBe(false); }); });