+ Range
{v.minValue ?? '—'} – {v.maxValue ?? '—'}
diff --git a/packages/frontend/src/staging/datasetZip.ts b/packages/frontend/src/staging/datasetZip.ts
index 5ea0a35..bab6ea7 100644
--- a/packages/frontend/src/staging/datasetZip.ts
+++ b/packages/frontend/src/staging/datasetZip.ts
@@ -1,17 +1,24 @@
// Builds and streams the downloadable Psych-DS dataset zip from the staged file store.
//
-// Uses fflate's AsyncZipDeflate (DEFLATE compressed, worker-backed when available) to emit
-// output chunks as each file finishes compressing — so neither the full input set nor the
-// complete output zip ever lives in the JS heap at once. On Chromium (showSaveFilePicker) each
-// chunk is written to the user-chosen file as it arrives, bounding peak heap to roughly one
-// file's working set. On other browsers (or if the picker fails) chunks are collected into a
-// Blob and downloaded via an object URL.
+// Files are compressed strictly one at a time. For each entry we create a single fflate
+// AsyncZipDeflate, feed the file's bytes in bounded ~1 MiB slices (read lazily from the
+// disk-backed Blob), and wait for that entry's final compressed chunk before starting the next
+// one. fflate spawns one worker per live AsyncZipDeflate and buffers later entries until earlier
+// ones finish, so processing sequentially keeps exactly one worker (and roughly one file's
+// working set) resident at a time rather than N workers plus most of the compressed dataset in
+// the heap. Output chunks are emitted as they are produced: on Chromium (showSaveFilePicker) each
+// is written to the user-chosen file as it arrives, bounding peak heap to about one file's
+// working set; on other browsers (or if the picker fails) chunks are collected into a Blob and
+// downloaded via an object URL.
import { Zip, AsyncZipDeflate } from 'fflate';
import { DATASET_DESCRIPTION_FILENAME } from '../datasetLayout';
import { blobDownload } from '../download';
import type { DatasetFileSource } from './stagedFileStore';
+/** Slice size for feeding a file into the deflater — bounds peak heap to ~one slice per file. */
+const PUSH_CHUNK_BYTES = 1 << 20; // 1 MiB
+
const readmeContents = (projectName: string): string =>
`# ${projectName}\nHuman-readable description of the project and dataset.`;
@@ -36,35 +43,68 @@ interface ZipSink {
/**
* Runs the zip build and routes each output chunk to `onChunk`. Resolves once the zip is
- * complete (all chunks delivered). Each data file is read from the store one at a time.
+ * complete (all chunks delivered). Entries are compressed strictly one at a time: each file is
+ * read from the store lazily, pushed into its own deflater in ~1 MiB slices, and awaited to its
+ * final compressed chunk before the next entry's deflater is created.
*/
async function buildZip(opts: BuildDatasetZipOptions, onChunk: (dat: Uint8Array) => void): Promise {
return new Promise((resolve, reject) => {
+ let failed = false;
+ const fail = (err: unknown) => { if (!failed) { failed = true; reject(err); } };
+
const zip = new Zip((err, dat, final) => {
- if (err) { reject(err); return; }
+ if (err) { fail(err); return; }
onChunk(dat);
if (final) resolve();
});
- const addEntry = (filename: string, content: Uint8Array): void => {
+ // Add one entry, feed it in bounded slices, and resolve only once its final compressed chunk
+ // has been emitted — so no second AsyncZipDeflate (hence no second worker) is created until
+ // this one is done.
+ const addEntrySequential = (filename: string, content: Blob): Promise => {
const entry = new AsyncZipDeflate(filename, { level: 6 });
zip.add(entry);
- entry.push(content, true);
+ // zip.add installs the routing handler that streams this entry's bytes into the archive;
+ // wrap it so we also learn when this entry has emitted its final chunk.
+ const routeToZip = entry.ondata!;
+ return new Promise((resolveEntry, rejectEntry) => {
+ entry.ondata = (err, dat, final) => {
+ routeToZip(err, dat, final);
+ if (err) rejectEntry(err);
+ else if (final) resolveEntry();
+ };
+ void (async () => {
+ try {
+ const size = content.size;
+ if (size === 0) { entry.push(new Uint8Array(0), true); return; }
+ for (let off = 0; off < size; off += PUSH_CHUNK_BYTES) {
+ const end = Math.min(off + PUSH_CHUNK_BYTES, size);
+ const slice = new Uint8Array(await content.slice(off, end).arrayBuffer());
+ entry.push(slice, end >= size);
+ }
+ } catch (e) {
+ rejectEntry(e);
+ }
+ })();
+ });
};
(async () => {
try {
- addEntry(DATASET_DESCRIPTION_FILENAME, new TextEncoder().encode(opts.metadataJson));
+ await addEntrySequential(
+ DATASET_DESCRIPTION_FILENAME,
+ new Blob([new TextEncoder().encode(opts.metadataJson)]),
+ );
if (opts.dataFiles) {
for await (const [path, blob] of opts.dataFiles.entries()) {
- addEntry(path, new Uint8Array(await blob.arrayBuffer()));
+ await addEntrySequential(path, blob);
}
}
- addEntry('README.md', new TextEncoder().encode(readmeContents(opts.projectName)));
- addEntry('CHANGES.md', new TextEncoder().encode(CHANGES_CONTENTS));
+ await addEntrySequential('README.md', new Blob([readmeContents(opts.projectName)]));
+ await addEntrySequential('CHANGES.md', new Blob([CHANGES_CONTENTS]));
zip.end();
} catch (e) {
- reject(e);
+ fail(e);
}
})();
});
diff --git a/packages/frontend/tests/AppShell.test.tsx b/packages/frontend/tests/AppShell.test.tsx
index 14734d5..3f456db 100644
--- a/packages/frontend/tests/AppShell.test.tsx
+++ b/packages/frontend/tests/AppShell.test.tsx
@@ -26,22 +26,38 @@ jest.mock("../src/components/PreviewDrawer", () => ({
),
}));
-jest.mock("../src/pages/ProjectInfo", () => ({
- __esModule: true,
- default: ({ onComplete }: any) => (
-
- ProjectInfo page
- Complete ProjectInfo
-
- ),
- emptyProjectInfoSession: () => ({ name: "", description: "", optional: {}, optionalOpen: false }),
- OPTIONAL_FIELDS: [],
-}));
+jest.mock("../src/pages/ProjectInfo", () => {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const React = require("react");
+ return {
+ __esModule: true,
+ // Real ProjectInfo reports a successful existing-metadata load into the session; the shell
+ // gates Data pre-completion on that, so the stub mirrors it when a file is present.
+ default: ({ onComplete, existingMetadataFile, session, onSessionChange }: any) => {
+ React.useEffect(() => {
+ if (existingMetadataFile) {
+ onSessionChange({ ...session, loadStatus: "loaded", loadToken: "tok" });
+ }
+ }, []);
+ return (
+
+ ProjectInfo page
+ Complete ProjectInfo
+
+ );
+ },
+ emptyProjectInfoSession: () => ({
+ name: "", description: "", optional: {}, optionalOpen: false, loadStatus: "idle", loadToken: null,
+ }),
+ OPTIONAL_FIELDS: [],
+ applyProjectInfoFields: jest.fn(),
+ };
+});
// A fake staged store the DataUpload stub can push into the session, so Start Over cleanup can be
// asserted. The `mock` prefix lets the hoisted jest.mock factory reference it (jest rule).
const mockStagedClear = jest.fn();
-const mockStagedStore = { clear: mockStagedClear } as any;
+const mockStagedStore = { clear: mockStagedClear, paths: () => [] } as any;
jest.mock("../src/pages/DataUpload", () => ({
__esModule: true,
diff --git a/packages/frontend/tests/AppShellIntegration.test.tsx b/packages/frontend/tests/AppShellIntegration.test.tsx
new file mode 100644
index 0000000..19415ff
--- /dev/null
+++ b/packages/frontend/tests/AppShellIntegration.test.tsx
@@ -0,0 +1,71 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import JsPsychMetadata from "@jspsych/metadata";
+import AppShell from "../src/components/AppShell";
+
+// Integration test with the REAL ProjectInfo page inside the REAL AppShell (only the validator,
+// which Review lazy-loads, would touch the network — and we never open Review here). Guards the
+// state-loss bug where revisiting Project Info re-ran loadMetadata and clobbered session edits.
+
+const METADATA_JSON = JSON.stringify({
+ name: "Loaded Study",
+ description: "A loaded description.",
+ schemaVersion: "Psych-DS 0.4.0",
+ variableMeasured: [
+ { name: "rt", type: "PropertyValue", description: "reaction time" },
+ { name: "stimulus", type: "PropertyValue", description: "the stimulus" },
+ ],
+});
+
+function existingFile() {
+ return new File([METADATA_JSON], "dataset_description.json", { type: "application/json" });
+}
+
+describe("AppShell + real ProjectInfo (existing project)", () => {
+ test("loads existing metadata exactly once, even after revisiting Project Info", async () => {
+ const meta = new JsPsychMetadata();
+ const loadSpy = jest.spyOn(meta, "loadMetadata");
+
+ render();
+
+ // First mount loads the file once and populates the form.
+ await screen.findByText(/Loaded from/);
+ expect(loadSpy).toHaveBeenCalledTimes(1);
+ expect(screen.getByRole("textbox", { name: /Project name/ })).toHaveValue("Loaded Study");
+
+ // Continue — existing metadata is loaded, so Data is pre-completed and we skip to Variables.
+ await userEvent.click(screen.getByRole("button", { name: "Continue →" }));
+ await screen.findByText(/Review and edit the variables/);
+
+ // Simulate an edit made on another step: delete a variable directly on the instance.
+ meta.deleteVariable("rt");
+ expect(meta.getVariableNames()).not.toContain("rt");
+
+ // Navigate back to Project Info via the sidebar — this remounts the page.
+ await userEvent.click(screen.getByRole("button", { name: /Project Info/ }));
+ await screen.findByRole("textbox", { name: /Project name/ });
+
+ // loadMetadata must NOT have run again (it would resurrect the deleted variable and clobber edits).
+ expect(loadSpy).toHaveBeenCalledTimes(1);
+ expect(meta.getVariableNames()).not.toContain("rt");
+ // Session preserved — the name field still shows the loaded value.
+ expect(screen.getByRole("textbox", { name: /Project name/ })).toHaveValue("Loaded Study");
+ });
+
+ test("a failed metadata parse does not pre-complete Data or claim variables were loaded", async () => {
+ const meta = new JsPsychMetadata();
+ const badFile = new File(["not valid json {"], "dataset_description.json");
+
+ render();
+
+ await screen.findByText(/Failed to parse the metadata file/);
+
+ // Continuing must land on the Data step (not skip it), and Data must NOT show the
+ // "variables loaded from existing metadata" banner.
+ await userEvent.type(screen.getByRole("textbox", { name: /Project name/ }), "Manual");
+ await userEvent.click(screen.getByRole("button", { name: "Continue →" }));
+
+ expect(await screen.findByText(/Select your data folder/)).toBeInTheDocument();
+ expect(screen.queryByText(/Variables loaded from existing metadata/)).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/frontend/tests/DataUpload.test.tsx b/packages/frontend/tests/DataUpload.test.tsx
index 09e7b31..c2ac934 100644
--- a/packages/frontend/tests/DataUpload.test.tsx
+++ b/packages/frontend/tests/DataUpload.test.tsx
@@ -1,9 +1,10 @@
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import JSZip from "jszip";
-import { analyzeJoinKeys } from "@jspsych/metadata";
+import { analyzeJoinKeys, buildPsychDSDataFiles } from "@jspsych/metadata";
import DataUpload, { emptyDataSession } from "../src/pages/DataUpload";
import type { DataSession } from "../src/pages/DataUpload";
+import { createStagedFileStore } from "../src/staging/stagedFileStore";
jest.mock("@jspsych/metadata", () => ({
__esModule: true,
@@ -450,7 +451,10 @@ describe("DataUpload", () => {
await waitFor(() => {
const lastCall = onSessionChange.mock.calls.at(-1)?.[0] as DataSession;
expect(lastCall?.files).toHaveLength(1);
- expect(lastCall.files[0].name).toBe("data/sub01.csv");
+ // Zip entries now use their basename for the File name (the in-archive path is kept on
+ // webkitRelativePath), so a compliant/flat name reaches the Psych-DS builder.
+ expect(lastCall.files[0].name).toBe("sub01.csv");
+ expect(lastCall.files[0].webkitRelativePath).toBe("data/sub01.csv");
});
});
@@ -468,4 +472,163 @@ describe("DataUpload", () => {
});
});
});
+
+ // ── session round-trip (fix 4) ─────────────────────────────────────────────
+
+ describe("session round-trip", () => {
+ test("restores 'ready' with the file list when files were picked but not processed", () => {
+ const session: DataSession = {
+ ...emptyDataSession,
+ files: [makeFile("sub01.csv"), makeFile("sub02.csv")],
+ };
+ render();
+
+ // Not dataProcessed and not existing-metadata, but session.files exist → 'ready', list shown.
+ expect(screen.getByRole("button", { name: "Process files" })).toBeInTheDocument();
+ expect(screen.getByText("sub01.csv")).toBeInTheDocument();
+ expect(screen.getByText("sub02.csv")).toBeInTheDocument();
+ });
+ });
+
+ // ── navigation lock while processing (fix 3) ───────────────────────────────
+
+ describe("processing busy state", () => {
+ test("reports busy=true while a run is in flight and busy=false when it finishes", async () => {
+ const onBusyChange = jest.fn();
+ let releaseGenerate: () => void = () => {};
+ meta.generate.mockImplementation(
+ () => new Promise((resolve) => { releaseGenerate = () => resolve(); }),
+ );
+
+ const { container } = render();
+ const input = container.querySelector("input[multiple]") as HTMLInputElement;
+ fireEvent.change(input, { target: { files: [makeFile("sub01.csv", "rt\n1")] } });
+ await userEvent.click(screen.getByRole("button", { name: "Process files" }));
+
+ // generate() is pending, so the run is still in flight — the shell must lock navigation.
+ await waitFor(() => expect(onBusyChange).toHaveBeenLastCalledWith(true));
+
+ releaseGenerate();
+ await waitFor(() => expect(onBusyChange).toHaveBeenLastCalledWith(false));
+ });
+ });
+
+ // ── batch consistency: additive vs replace (fix 2) ─────────────────────────
+
+ describe("batch consistency", () => {
+ async function storeWith(paths: string[]) {
+ const store = createStagedFileStore({ forceMemory: true });
+ for (const p of paths) await store.write(p, "x");
+ return store;
+ }
+
+ test("'Upload additional files' stages the new batch alongside the first (no clear)", async () => {
+ // Make the builder emit one datafile per input and record its name, mirroring real behaviour.
+ // Once-scoped so it doesn't leak into other tests (which expect the default empty result).
+ (buildPsychDSDataFiles as jest.Mock).mockImplementationOnce(
+ ({ base, usedArrayFilenames }: { base: string; usedArrayFilenames: Set }) => {
+ const filename = `${base}_data.csv`;
+ usedArrayFilenames.add(filename);
+ return [{ filename, content: "x" }];
+ },
+ );
+
+ const store = await storeWith(["data/subject-a_data.csv"]);
+ const session: DataSession = {
+ ...emptyDataSession,
+ files: [makeFile("a.json", "[]")],
+ convertedStore: store,
+ usedArrayFilenames: ["subject-a_data.csv"],
+ usedRawFilenames: ["a.json"],
+ fileStatuses: [{ name: "a.json", status: "success" }],
+ };
+
+ render();
+
+ // "Upload additional files" (additive) — does NOT confirm/reset.
+ await userEvent.click(screen.getByRole("button", { name: "Choose folder" }));
+ const input = document.querySelector("input[multiple]") as HTMLInputElement;
+ fireEvent.change(input, { target: { files: [makeFile("b.json", "[]")] } });
+ await userEvent.click(screen.getByRole("button", { name: "Process files" }));
+ await screen.findByRole("button", { name: "Continue →" });
+
+ // The store keeps batch A's output and gains batch B's — both are present.
+ expect(store.paths()).toEqual(expect.arrayContaining([
+ "data/subject-a_data.csv",
+ "data/subject-b_data.csv",
+ ]));
+ // Used-name sets persisted across batches (seeded from the session, extended by the new batch).
+ const last = onSessionChange.mock.calls.at(-1)?.[0] as DataSession;
+ expect(last.usedArrayFilenames).toEqual(expect.arrayContaining([
+ "subject-a_data.csv",
+ "subject-b_data.csv",
+ ]));
+ expect(last.usedRawFilenames).toEqual(expect.arrayContaining(["a.json", "b.json"]));
+ });
+
+ test("'Replace all data' confirms, clears the store, and fires the metadata reset", async () => {
+ const store = await storeWith(["data/subject-a_data.csv"]);
+ const clearSpy = jest.spyOn(store, "clear");
+ const onResetMetadata = jest.fn().mockResolvedValue(undefined);
+ const session: DataSession = {
+ ...emptyDataSession,
+ files: [makeFile("a.json", "[]")],
+ convertedStore: store,
+ usedArrayFilenames: ["subject-a_data.csv"],
+ };
+
+ render();
+
+ await userEvent.click(screen.getByRole("button", { name: "Replace all data instead" }));
+ // A destructive replace over staged data asks first.
+ expect(screen.getByRole("alertdialog")).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: "Yes, replace" }));
+
+ await waitFor(() => expect(onResetMetadata).toHaveBeenCalledTimes(1));
+ expect(clearSpy).toHaveBeenCalled();
+ });
+
+ test("Cancel on the replace confirm leaves the staged data intact", async () => {
+ const store = await storeWith(["data/subject-a_data.csv"]);
+ const clearSpy = jest.spyOn(store, "clear");
+ const onResetMetadata = jest.fn();
+ const session: DataSession = {
+ ...emptyDataSession,
+ files: [makeFile("a.json", "[]")],
+ convertedStore: store,
+ };
+ render();
+
+ await userEvent.click(screen.getByRole("button", { name: "Replace all data instead" }));
+ await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
+
+ expect(onResetMetadata).not.toHaveBeenCalled();
+ expect(clearSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ // ── zip basename handling (fix 8) ──────────────────────────────────────────
+
+ describe("zip basename handling", () => {
+ test("uses the entry basename for nested paths and reads entries as blobs", async () => {
+ const asyncMock = jest.fn().mockResolvedValue("rt\n1");
+ mockLoadAsync.mockResolvedValue({
+ files: { "nested/dir/sub01.csv": { dir: false, name: "nested/dir/sub01.csv", async: asyncMock } },
+ });
+
+ const { container } = render();
+ fireEvent.change(container.querySelector("input[accept='.zip']")!, {
+ target: { files: [makeFile("exp.zip")] },
+ });
+
+ await screen.findByRole("button", { name: "Process files" });
+ // Extracted as a blob (not text).
+ expect(asyncMock).toHaveBeenCalledWith("blob");
+ await waitFor(() => {
+ const last = onSessionChange.mock.calls.at(-1)?.[0] as DataSession;
+ expect(last.files[0].name).toBe("sub01.csv");
+ expect(last.files[0].webkitRelativePath).toBe("nested/dir/sub01.csv");
+ });
+ });
+ });
});
diff --git a/packages/frontend/tests/PreviewDrawer.test.tsx b/packages/frontend/tests/PreviewDrawer.test.tsx
index ca870ac..3f84b66 100644
--- a/packages/frontend/tests/PreviewDrawer.test.tsx
+++ b/packages/frontend/tests/PreviewDrawer.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from "@testing-library/react";
+import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import PreviewDrawer from "../src/components/PreviewDrawer";
@@ -28,9 +28,32 @@ describe("PreviewDrawer", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
- test("clicking the backdrop calls onClose", async () => {
- const { container } = render();
- await userEvent.click(container.querySelector(".backdrop")!);
+ test("opens as a modal dialog (showModal called)", () => {
+ render();
+ const dialog = screen.getByRole("dialog", { name: "JSON preview" });
+ expect(dialog).toHaveAttribute("open");
+ expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalled();
+ });
+
+ test("clicking the dialog backdrop (the dialog element itself) calls onClose", async () => {
+ render();
+ // A click whose target is the element (not its content) is a backdrop click.
+ await userEvent.click(screen.getByRole("dialog", { name: "JSON preview" }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ test("clicking inside the dialog content does not close it", async () => {
+ render();
+ await userEvent.click(screen.getByText("JSON Preview"));
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ test("Escape (dialog cancel) closes the drawer and moves focus into the dialog", () => {
+ render();
+ const dialog = screen.getByRole("dialog", { name: "JSON preview" });
+ // showModal traps focus inside the dialog; the close button lives there.
+ expect(dialog).toContainElement(screen.getByRole("button", { name: "Close preview" }));
+ fireEvent(dialog, new Event("cancel"));
expect(onClose).toHaveBeenCalledTimes(1);
});
diff --git a/packages/frontend/tests/Sidebar.test.tsx b/packages/frontend/tests/Sidebar.test.tsx
index 00a8511..4637e48 100644
--- a/packages/frontend/tests/Sidebar.test.tsx
+++ b/packages/frontend/tests/Sidebar.test.tsx
@@ -100,6 +100,23 @@ describe("Sidebar", () => {
});
});
+ // ── navigation lock (while data is processing) ─────────────────────────────
+
+ describe("locked", () => {
+ test("locks every step and Start over so a run can't be orphaned", async () => {
+ render(
+ true, locked: true })} />,
+ );
+ // Even otherwise-navigable steps are disabled while locked.
+ expect(screen.getByRole("button", { name: /Project Info/ })).toBeDisabled();
+ expect(screen.getByRole("button", { name: /^Data/ })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "← Start over" })).toBeDisabled();
+
+ await userEvent.click(screen.getByRole("button", { name: /^Data/ }));
+ expect(onNavigate).not.toHaveBeenCalled();
+ });
+ });
+
// ── start over dialog ─────────────────────────────────────────────────────
describe("start over dialog", () => {
diff --git a/packages/frontend/tests/datasetZip.test.ts b/packages/frontend/tests/datasetZip.test.ts
index 4384780..73f005a 100644
--- a/packages/frontend/tests/datasetZip.test.ts
+++ b/packages/frontend/tests/datasetZip.test.ts
@@ -1,5 +1,5 @@
import { unzipSync } from 'fflate';
-import { buildDatasetZipBlob } from '../src/staging/datasetZip';
+import { buildDatasetZipBlob, downloadDatasetZip } from '../src/staging/datasetZip';
import { createStagedFileStore } from '../src/staging/stagedFileStore';
async function readZip(blob: Blob): Promise> {
@@ -58,3 +58,65 @@ describe('buildDatasetZipBlob', () => {
expect(Object.keys(files).filter((k) => k.startsWith('data/'))).toHaveLength(0);
});
});
+
+type PickerWindow = { showSaveFilePicker?: (...args: unknown[]) => Promise };
+const pickerWindow = window as unknown as PickerWindow;
+
+describe('downloadDatasetZip', () => {
+ const originalPicker = pickerWindow.showSaveFilePicker;
+ afterEach(() => {
+ if (originalPicker === undefined) delete pickerWindow.showSaveFilePicker;
+ else pickerWindow.showSaveFilePicker = originalPicker;
+ });
+
+ test('returns false when the user aborts the save dialog', async () => {
+ pickerWindow.showSaveFilePicker = jest
+ .fn()
+ .mockRejectedValue(Object.assign(new Error('cancelled'), { name: 'AbortError' }));
+
+ const result = await downloadDatasetZip({ metadataJson: '{"name":"x"}', projectName: 'x' }, 'x.zip');
+ expect(result).toBe(false);
+ });
+
+ test('streams to the picked file and returns true on success', async () => {
+ const written: Uint8Array[] = [];
+ const writable = {
+ write: jest.fn(async (d: Uint8Array) => { written.push(d); }),
+ close: jest.fn(async () => {}),
+ abort: jest.fn(async () => {}),
+ };
+ pickerWindow.showSaveFilePicker = jest
+ .fn()
+ .mockResolvedValue({ createWritable: jest.fn().mockResolvedValue(writable) });
+
+ const result = await downloadDatasetZip({ metadataJson: '{"name":"x"}', projectName: 'x' }, 'x.zip');
+ expect(result).toBe(true);
+ expect(writable.write).toHaveBeenCalled();
+ expect(writable.close).toHaveBeenCalled();
+ expect(writable.abort).not.toHaveBeenCalled();
+ });
+
+ test('propagates streaming errors and aborts the sink', async () => {
+ const writable = {
+ write: jest.fn().mockRejectedValue(new Error('disk full')),
+ close: jest.fn(async () => {}),
+ abort: jest.fn(async () => {}),
+ };
+ pickerWindow.showSaveFilePicker = jest
+ .fn()
+ .mockResolvedValue({ createWritable: jest.fn().mockResolvedValue(writable) });
+
+ await expect(
+ downloadDatasetZip({ metadataJson: '{"name":"x"}', projectName: 'x' }, 'x.zip'),
+ ).rejects.toThrow('disk full');
+ expect(writable.abort).toHaveBeenCalled();
+ expect(writable.close).not.toHaveBeenCalled();
+ });
+
+ test('falls back to a blob download when the file-system picker is unavailable', async () => {
+ delete pickerWindow.showSaveFilePicker;
+ const result = await downloadDatasetZip({ metadataJson: '{"name":"x"}', projectName: 'x' }, 'x.zip');
+ expect(result).toBe(true);
+ expect(URL.createObjectURL).toHaveBeenCalled();
+ });
+});
diff --git a/packages/frontend/tests/datasetZipStreaming.test.ts b/packages/frontend/tests/datasetZipStreaming.test.ts
new file mode 100644
index 0000000..c688598
--- /dev/null
+++ b/packages/frontend/tests/datasetZipStreaming.test.ts
@@ -0,0 +1,68 @@
+// Verifies the memory-bounded contract of the zip builder: entries are compressed strictly one at
+// a time, so entry N+1's deflater isn't created (and its bytes aren't pushed) until entry N has
+// emitted its final chunk. fflate is faked so we can observe the exact ordering of create/push/final
+// events — with the real (worker-backed) build this ordering isn't directly observable.
+
+const mockEvents: string[] = [];
+
+jest.mock('fflate', () => {
+ class FakeAsyncZipDeflate {
+ ondata: ((err: unknown, dat: Uint8Array, final: boolean) => void) | null = null;
+ constructor(public filename: string) {
+ mockEvents.push(`create:${filename}`);
+ }
+ push(data: Uint8Array, final: boolean) {
+ mockEvents.push(`push:${this.filename}:${final}`);
+ // Emit asynchronously so that a missing `await` between entries would let their pushes
+ // interleave — the ordering assertions below would then fail.
+ Promise.resolve().then(() => {
+ this.ondata?.(null, data, final);
+ if (final) mockEvents.push(`final:${this.filename}`);
+ });
+ }
+ }
+ class FakeZip {
+ constructor(private cb: (err: unknown, dat: Uint8Array, final: boolean) => void) {}
+ add(entry: FakeAsyncZipDeflate) {
+ // Route an entry's chunks into the archive stream (never the whole-zip `final`).
+ entry.ondata = (err, dat) => this.cb(err, dat, false);
+ }
+ end() {
+ this.cb(null, new Uint8Array(0), true);
+ }
+ }
+ return { Zip: FakeZip, AsyncZipDeflate: FakeAsyncZipDeflate };
+});
+
+import { buildDatasetZipBlob } from '../src/staging/datasetZip';
+import { createStagedFileStore } from '../src/staging/stagedFileStore';
+
+beforeEach(() => {
+ mockEvents.length = 0;
+});
+
+test('compresses entries strictly sequentially (N+1 not started before N finishes)', async () => {
+ const store = createStagedFileStore({ forceMemory: true });
+ await store.write('data/a.csv', 'aaa');
+ await store.write('data/b.csv', 'bbb');
+
+ await buildDatasetZipBlob({ metadataJson: '{}', projectName: 'p', dataFiles: store });
+
+ const order = ['dataset_description.json', 'data/a.csv', 'data/b.csv', 'README.md', 'CHANGES.md'];
+
+ // Every entry was created and finished.
+ for (const name of order) {
+ expect(mockEvents).toContain(`create:${name}`);
+ expect(mockEvents).toContain(`final:${name}`);
+ }
+
+ // Each entry finishes before the next is created or pushed.
+ for (let i = 0; i < order.length - 1; i++) {
+ const finalI = mockEvents.indexOf(`final:${order[i]}`);
+ const createNext = mockEvents.indexOf(`create:${order[i + 1]}`);
+ const pushNext = mockEvents.indexOf(`push:${order[i + 1]}:true`);
+ expect(finalI).toBeGreaterThanOrEqual(0);
+ expect(finalI).toBeLessThan(createNext);
+ expect(finalI).toBeLessThan(pushNext);
+ }
+});
diff --git a/packages/frontend/tests/setup.ts b/packages/frontend/tests/setup.ts
index 8c0c3e3..70f9497 100644
--- a/packages/frontend/tests/setup.ts
+++ b/packages/frontend/tests/setup.ts
@@ -36,6 +36,22 @@ if (typeof (Blob.prototype as any).arrayBuffer !== 'function') {
URL.createObjectURL = jest.fn(() => "blob:mock-url");
URL.revokeObjectURL = jest.fn();
+// jsdom doesn't implement .showModal/close (used by Sidebar and PreviewDrawer). Stub them
+// so modal dialogs render as open and fire their close event on programmatic close.
+if (typeof HTMLDialogElement !== "undefined") {
+ HTMLDialogElement.prototype.showModal = jest
+ .fn()
+ .mockImplementation(function (this: HTMLDialogElement) {
+ this.setAttribute("open", "");
+ });
+ HTMLDialogElement.prototype.close = jest
+ .fn()
+ .mockImplementation(function (this: HTMLDialogElement) {
+ this.removeAttribute("open");
+ this.dispatchEvent(new Event("close"));
+ });
+}
+
beforeEach(() => {
localStorage.clear();
});