Skip to content
31 changes: 16 additions & 15 deletions src/renderer/pages/backup/BackupCreateFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
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;

Expand Down Expand Up @@ -179,7 +181,7 @@
setProgress(0);

const isoString = new Date().toISOString();
const timestamp = isoString.replace(/[:.]/g, "-").split("T")[0]!;

Check warning on line 184 in src/renderer/pages/backup/BackupCreateFlow.tsx

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

// Simulate progress
const interval = setInterval(() => {
Expand All @@ -201,7 +203,7 @@
includeLabels,
},
})
.then((result) => {
.then(async (result) => {
clearInterval(interval);
setProgress(100);

Expand All @@ -211,20 +213,19 @@
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(process.env.NODE_ENV === "test" ? 1 : 200);
}

void message.success(
t(`{{count}} files downloaded`, { count: files.length })
Expand Down
198 changes: 173 additions & 25 deletions src/renderer/pages/backup/__tests__/BackupCreateFlow.spec.tsx
Original file line number Diff line number Diff line change
@@ -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", () => ({
Expand All @@ -20,29 +24,6 @@ vi.mock("renderer/compatibility/checks", () => ({
}));

describe("<BackupCreateFlow />", () => {
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(
<MockedProvider mocks={[]}>
Expand Down Expand Up @@ -199,3 +180,170 @@ describe("<BackupCreateFlow />", () => {
});
});
});

describe("<BackupCreateFlow /> 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(
<MockedProvider mocks={mocks} addTypename={false}>
<BackupCreateFlow />
</MockedProvider>
);

// 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 per
// 200ms in production, instant in tests) avoids that entirely.
await waitFor(() => {
expect(downloadedFiles).toHaveLength(modelCount);
});

// Verify each model file was included
modelNames.forEach((name) => {
expect(downloadedFiles).toContain(`${name}.yml`);
});
});
});
86 changes: 86 additions & 0 deletions src/shared/backend/__tests__/backup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading