Skip to content
43 changes: 28 additions & 15 deletions src/renderer/pages/backup/BackupCreateFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@
setProgress(0);

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

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

Expand All @@ -211,20 +211,33 @@
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);
});
// Download each file sequentially with a delay to avoid
// Chromium's rapid download blocking (limits ~10 concurrent
// programmatic downloads)
await files.reduce(
(chain, file) =>
chain.then(
() =>
new Promise<void>((resolve) => {
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);
setTimeout(resolve, 200);
})
),
Promise.resolve()
);

void message.success(
t(`{{count}} files downloaded`, { count: files.length })
Expand Down
198 changes: 196 additions & 2 deletions src/renderer/pages/backup/__tests__/BackupCreateFlow.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
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,
beforeAll,
afterAll,
beforeEach,
afterEach,
} from "vitest";
import gql from "graphql-tag";

// Mock environment
vi.mock("shared/environment", () => ({
Expand Down Expand Up @@ -199,3 +209,187 @@ describe("<BackupCreateFlow />", () => {
});
});
});

describe("<BackupCreateFlow /> sequential individual download", () => {
let downloadedFiles: string[];

beforeEach(() => {
downloadedFiles = [];

// Track which files are downloaded via link.click
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(() => {
downloadedFiles.push((element as HTMLAnchorElement).download);
});
}
return element;
}) as typeof document.createElement);

global.URL.createObjectURL = vi.fn(() => "mock-url");
global.URL.revokeObjectURL = vi.fn();
});

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 sequentially with a delay between each file).
// Allow enough time for 11 * 200ms sequential downloads.
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 11 * 200ms sequential downloads
});
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