Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 21 additions & 15 deletions src/renderer/pages/backup/BackupCreateFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
import environment from "shared/environment";
import checks from "renderer/compatibility/checks";
import { useTranslation } from "react-i18next";
import legacyDownload from "js-file-download";
import pLimit from "p-limit";
import { delay } from "shared/tools";

const notAvailable = !environment.isElectron && !checks.hasFilesystemApi;

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

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

Check warning on line 185 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 +204,7 @@
includeLabels,
},
})
.then((result) => {
.then(async (result) => {
clearInterval(interval);
setProgress(100);

Expand All @@ -211,20 +214,23 @@
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);
});
// Limit concurrent downloads to avoid Chromium's rapid
// simultaneous download blocking (~10 at a time). Each slot
// holds for 200ms after triggering so Chromium registers it
// before the next batch starts.
const limit = pLimit(10);
await Promise.all(
files.map((file) =>
limit(async () => {
legacyDownload(
Buffer.from(file.base64Data, "base64"),
file.fileName,
"application/octet-stream"
);
await delay(200);
})
)
);

void message.success(
t(`{{count}} files downloaded`, { count: files.length })
Expand Down
201 changes: 176 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,173 @@ 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 (proves the 10-file Chromium limit is
// bypassed by downloading in batches of 10 with a 200ms delay per slot).
// With pLimit(10), 11 files need 2 batches (~400ms total).
await waitFor(
() => {
expect(downloadedFiles).toHaveLength(modelCount);
},
{ timeout: 4000 }
);

// Verify each model file was included
modelNames.forEach((name) => {
expect(downloadedFiles).toContain(`${name}.yml`);
});
}, 10000); // extend test timeout to accommodate batched downloads with 200ms delay per slot
});
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