diff --git a/src/renderer/pages/backup/BackupCreateFlow.tsx b/src/renderer/pages/backup/BackupCreateFlow.tsx index 8495059..b80081f 100644 --- a/src/renderer/pages/backup/BackupCreateFlow.tsx +++ b/src/renderer/pages/backup/BackupCreateFlow.tsx @@ -21,6 +21,8 @@ import { useMutation, gql, useQuery } from "@apollo/client"; import environment from "shared/environment"; import checks from "renderer/compatibility/checks"; import { useTranslation } from "react-i18next"; +import legacyDownload from "js-file-download"; +import { delay } from "shared/tools"; const notAvailable = !environment.isElectron && !checks.hasFilesystemApi; @@ -201,7 +203,7 @@ const BackupCreateFlow: React.FC = () => { includeLabels, }, }) - .then((result) => { + .then(async (result) => { clearInterval(interval); setProgress(100); @@ -211,20 +213,19 @@ const BackupCreateFlow: React.FC = () => { base64Data: string; }[]; - // Download each file - files.forEach((file: { fileName: string; base64Data: string }) => { - const blob = new Blob([Buffer.from(file.base64Data, "base64")], { - type: "application/octet-stream", - }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = file.fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - }); + // Trigger downloads sequentially with a 200ms gap between each. + // Chromium blocks programmatic downloads when more than ~10 are + // fired simultaneously; a sequential approach avoids that entirely. + // eslint-disable-next-line no-restricted-syntax + for (const file of files) { + legacyDownload( + Buffer.from(file.base64Data, "base64"), + file.fileName, + "application/octet-stream" + ); + // eslint-disable-next-line no-await-in-loop + await delay(200); + } void message.success( t(`{{count}} files downloaded`, { count: files.length }) diff --git a/src/renderer/pages/backup/__tests__/BackupCreateFlow.spec.tsx b/src/renderer/pages/backup/__tests__/BackupCreateFlow.spec.tsx index d857348..e3dc3c7 100644 --- a/src/renderer/pages/backup/__tests__/BackupCreateFlow.spec.tsx +++ b/src/renderer/pages/backup/__tests__/BackupCreateFlow.spec.tsx @@ -1,9 +1,13 @@ import React from "react"; import { render } from "test-utils/testing-library"; -import { screen, waitFor } from "@testing-library/react"; +import { screen, waitFor, fireEvent } from "@testing-library/react"; import { MockedProvider } from "@apollo/client/testing"; import BackupCreateFlow from "renderer/pages/backup/BackupCreateFlow"; -import { describe, it, expect, vi, beforeAll, afterAll } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import gql from "graphql-tag"; +import legacyDownload from "js-file-download"; + +vi.mock("js-file-download"); // Mock environment vi.mock("shared/environment", () => ({ @@ -20,29 +24,6 @@ vi.mock("renderer/compatibility/checks", () => ({ })); describe("", () => { - beforeAll(() => { - // Mock document.createElement for download links - const originalCreateElement = document.createElement.bind(document); - vi.spyOn(document, "createElement").mockImplementation((( - tagName: string - ) => { - const element = originalCreateElement(tagName); - if (tagName === "a") { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any - (element as any).click = vi.fn(); - } - return element; - }) as typeof document.createElement); - - // Mock URL.createObjectURL and revokeObjectURL - global.URL.createObjectURL = vi.fn(() => "mock-url"); - global.URL.revokeObjectURL = vi.fn(); - }); - - afterAll(() => { - vi.restoreAllMocks(); - }); - it("should render the component", () => { render( @@ -199,3 +180,173 @@ describe("", () => { }); }); }); + +describe(" sequential individual download", () => { + let downloadedFiles: string[]; + const mockLegacyDownload = vi.mocked(legacyDownload); + + beforeEach(() => { + downloadedFiles = []; + mockLegacyDownload.mockImplementation((data: unknown, filename: string) => { + downloadedFiles.push(filename); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should download all selected individual model files, not just the first 10", async () => { + const directoryId = "test-dir-id"; + const modelCount = 11; + const modelNames = Array.from( + { length: modelCount }, + (_, i) => `model${i + 1}` + ); + const base64 = Buffer.from("content").toString("base64"); + + const mocks = [ + { + request: { + query: gql` + mutation PickSdcardDirectory { + pickSdcardDirectory { + id + } + } + `, + }, + result: { + data: { + pickSdcardDirectory: { id: directoryId }, + }, + }, + }, + { + request: { + query: gql` + query SdcardDirectoryInfo($directoryId: ID!) { + sdcardModelsDirectory(id: $directoryId) { + id + name + isValid + hasLabels + pack { + target + version + } + } + } + `, + variables: { directoryId }, + }, + result: { + data: { + sdcardModelsDirectory: { + id: directoryId, + name: "MODELS", + isValid: true, + hasLabels: false, + pack: null, + }, + }, + }, + }, + { + request: { + query: gql` + query SdcardModelsWithNames($directoryId: ID!) { + sdcardModelsWithNames(directoryId: $directoryId) { + fileName + displayName + } + } + `, + variables: { directoryId }, + }, + result: { + data: { + sdcardModelsWithNames: modelNames.map((name) => ({ + fileName: name, + displayName: `Model ${name}`, + })), + }, + }, + }, + { + request: { + query: gql` + mutation DownloadIndividualModels( + $directoryId: ID! + $selectedModels: [String!]! + $includeLabels: Boolean + ) { + downloadIndividualModels( + directoryId: $directoryId + selectedModels: $selectedModels + includeLabels: $includeLabels + ) { + fileName + base64Data + } + } + `, + variables: { + directoryId, + selectedModels: modelNames, + includeLabels: false, + }, + }, + result: { + data: { + downloadIndividualModels: modelNames.map((name) => ({ + fileName: `${name}.yml`, + base64Data: base64, + })), + }, + }, + }, + ]; + + render( + + + + ); + + // Trigger SD card selection + fireEvent.click(screen.getByText("Select SD Card")); + + // Wait for Apollo mutation response and models to load + await waitFor( + () => { + expect(screen.getByText("Select all")).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Select all models + fireEvent.click(screen.getByText("Select all")); + + // Switch to individual .yml format + fireEvent.click(screen.getByText("Individual .yml files")); + + // Trigger backup download + fireEvent.click(screen.getByRole("button", { name: /create backup/i })); + + // All 11 files should be downloaded. Chromium blocks simultaneous + // programmatic downloads above ~10; the sequential approach (one every + // 200ms) avoids that entirely. 11 files → ~2200ms total. + await waitFor( + () => { + expect(downloadedFiles).toHaveLength(modelCount); + }, + { timeout: 5000 } + ); + + // Verify each model file was included + modelNames.forEach((name) => { + expect(downloadedFiles).toContain(`${name}.yml`); + }); + }, 15000); // 11 files × 200ms = ~2200ms, plus test overhead +}); diff --git a/src/shared/backend/__tests__/backup.spec.ts b/src/shared/backend/__tests__/backup.spec.ts index 269fa11..a9f7d1a 100644 --- a/src/shared/backend/__tests__/backup.spec.ts +++ b/src/shared/backend/__tests__/backup.spec.ts @@ -1395,6 +1395,92 @@ describe("Backup", () => { expect(content).toContain("My Quad"); }); + it("should download all models when many are selected (e.g. 43)", async () => { + const modelsPath = await setupSdcardDirectory(tempDir.path); + + // Create 43 model files to simulate a real-world scenario + const modelNames: string[] = []; + for (let i = 1; i <= 43; i += 1) { + const modelName = `model${i}`; + modelNames.push(modelName); + await fs.writeFile( + path.join(modelsPath, `${modelName}.yml`), + createModelYaml(`Model ${i}`) + ); + } + + // Pick directory + const handle = await getOriginPrivateDirectory( + nodeAdapter, + tempDir.path + ); + // @ts-expect-error readonly but testing + handle.name = tempDir.path; + requestWritableDirectory.mockResolvedValueOnce(handle); + + const pickResult = await backend.mutate({ + mutation: gql` + mutation { + pickSdcardDirectory { + id + } + } + `, + }); + + const directoryId = (pickResult.data?.pickSdcardDirectory as any)?.id; + + // Download all 43 models + const { data, errors } = await backend.mutate({ + mutation: gql` + mutation DownloadModels( + $directoryId: ID! + $selectedModels: [String!]! + ) { + downloadIndividualModels( + directoryId: $directoryId + selectedModels: $selectedModels + ) { + fileName + base64Data + } + } + `, + variables: { + directoryId, + selectedModels: modelNames, + }, + }); + + expect(errors).toBeFalsy(); + expect(data?.downloadIndividualModels).toHaveLength(43); + + // Verify all 43 files are present + const downloadedFiles = ( + data?.downloadIndividualModels as { + fileName: string; + base64Data: string; + }[] + ).map((m) => m.fileName); + + for (let i = 1; i <= 43; i += 1) { + expect(downloadedFiles).toContain(`model${i}.yml`); + } + + // Verify content of a few samples + const model25 = ( + data?.downloadIndividualModels as { + fileName: string; + base64Data: string; + }[] + ).find((m) => m.fileName === "model25.yml"); + const content = Buffer.from( + model25?.base64Data ?? "", + "base64" + ).toString("utf-8"); + expect(content).toContain("Model 25"); + }); + it("should include labels file when requested", async () => { const modelsPath = await setupSdcardDirectory(tempDir.path);