This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-7611-6e56294657faaa5fd1271b093460568b7b41c415 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 310ab88e4c78da14182284199bde34a1d22d489b Author: Eugene Gu <[email protected]> AuthorDate: Thu Aug 13 04:06:19 2026 +0000 chore(frontend): downloading workflows as a ZIP no longer saves each one individually (#7611) ### What changes were proposed in this PR? Selecting several workflows and using the toolbar's "Download added workflow as a ZIP file" action saved one loose `.json` file per selected workflow **in addition to** the archive, so N selected workflows produced N+1 downloads. Root cause is in `frontend/src/app/dashboard/service/user/download/download.service.ts`. `downloadWorkflow(id, name)` both retrieves a workflow and saves it to disk — the save is the `tap(this.saveFile.bind(this))` at the end of the pipe — which is exactly what the per-row download action needs. `createWorkflowsZip` reused that same method purely to obtain each blob, so the save fired for every entry before the blob was added to the archive. This is a regression rather than intended behaviour: before #2920 (`57984370c`, "Refactor Frontend to Centralize Downloads Using DownloadService") the bulk path assembled the zip inline and called `saveAs` exactly once. That refactor moved the logic into `DownloadService` and reused `downloadWorkflow` for retrieval, inheriting its save side effect. The fix splits retrieval from saving: - new private `retrieveWorkflowItem(id, name)` returns the `DownloadableItem` (blob + file name) **without** saving — it is the former body of `downloadWorkflow` minus the `tap`; - `downloadWorkflow` is now `retrieveWorkflowItem(...).pipe(tap(this.saveFile.bind(this)))`, i.e. behaviour is unchanged for the three per-row callers (`user-workflow-list-item.component.ts`, `list-item.component.ts`, `card-item.component.ts`), which subscribe without a value handler and rely solely on that side effect; - `createWorkflowsZip` calls `retrieveWorkflowItem` directly. `A.pipe(map, tap)` and `A.pipe(map).pipe(tap)` compose the same chain, so the emitted value, timing, subscription semantics and error propagation of `downloadWorkflow` are unchanged. The public API is untouched and no call site needed updating. **Before** — three workflows selected, then "Download as ZIP": the archive plus `test1.json`, `test2.json`, `test3.json`, four files in total. <img width="1493" height="755" alt="Screenshot 2026-08-12 at 3 48 45 PM" src="https://github.com/user-attachments/assets/7a7b8cb0-330a-452f-b853-b7aabfc04acd" /> **After** — same three workflows, same action: only `workflowExports-*.zip`. <img width="1482" height="750" alt="Screenshot 2026-08-12 at 4 43 38 PM" src="https://github.com/user-attachments/assets/bdc64245-91bc-4fa0-a06d-2b80e2871706" /> ### Any related issues, documentation, discussions? Closes #7608 ### How was this PR tested? Seven cases were added to `frontend/src/app/dashboard/service/user/download/download.service.spec.ts` (23 → 30). Three of them fail on `main` and pass with this change. Pinning the fix: - `saves only the zip, not one JSON per workflow, when several workflows are zipped` — three workflows, asserts `saveAs` is called exactly once with the archive, and that no `Alpha.json` / `Beta.json` / `Gamma.json` was saved - `saves only the zip for a single-workflow selection` — the N=1 boundary, which a plain "one extra file" check would miss Guarding the other direction, so the bug cannot be "fixed" by deleting the save: - `still saves the file when a single workflow is downloaded on its own` — passes before and after; it fails if `downloadWorkflow` stops saving, which would break the per-row download action Edge cases: - `saves nothing when one of the workflows fails to retrieve` — the archive aborts as a whole, so the workflows that did come back must not be left behind as loose files (previously they were already saved by the time `forkJoin` errored) - `writes the workflow content into the zip entries` — reads an entry back out of the produced archive and parses it, since the pre-existing tests only asserted entry *names* - `does not save anything when the standalone workflow download fails` — error propagates, nothing is written; mirrors the existing dataset / single-file error cases - `saves nothing for an empty selection` — `forkJoin([])` completes without emitting, so nothing is retrieved and no empty archive is written (the toolbar already guards this case) ``` cd frontend node --max-old-space-size=8192 ./node_modules/@angular/cli/bin/ng test --watch=false \ --include="src/app/dashboard/service/user/download/download.service.spec.ts" ``` `Test Files 1 passed (1)` / `Tests 30 passed (30)`. Reverting only the source change makes exactly three of them fail. The specs of the three components that depend on `downloadWorkflow` still saving were also run and pass unchanged (`list-item`, `card-item`, `user-workflow-list-item`). Manually verified against a local stack, as shown in the screenshots above: create three workflows, select them, click the ZIP download action, and compare the browser's download list before and after the change. ### Was this PR authored or co-authored using generative AI tooling? Co-authored by: Claude Code (Claude Opus 5) --- .../service/user/download/download.service.spec.ts | 106 ++++++++++++++++++++- .../service/user/download/download.service.ts | 27 ++++-- 2 files changed, 122 insertions(+), 11 deletions(-) diff --git a/frontend/src/app/dashboard/service/user/download/download.service.spec.ts b/frontend/src/app/dashboard/service/user/download/download.service.spec.ts index 7bc180e737..0cbdf9ee57 100644 --- a/frontend/src/app/dashboard/service/user/download/download.service.spec.ts +++ b/frontend/src/app/dashboard/service/user/download/download.service.spec.ts @@ -227,7 +227,7 @@ describe("DownloadService", () => { // ─── createWorkflowsZip / nameWorkflow (real zip assembly) ──────────────── // These drive downloadWorkflowsAsZip through the real (un-mocked) private - // createWorkflowsZip → downloadWorkflow → nameWorkflow chain, so the produced + // createWorkflowsZip → retrieveWorkflowItem → nameWorkflow chain, so the produced // blob is a genuine zip we can load back and inspect. it("assembles a real zip with one JSON entry per workflow", async () => { @@ -266,6 +266,110 @@ describe("DownloadService", () => { expect(Object.keys(loaded.files).sort()).toEqual(["Dup-1.json", "Dup-2.json", "Dup.json"]); }); + // ─── zip download must not also save each workflow individually ─────────── + // Regression: createWorkflowsZip used to reuse downloadWorkflow purely to obtain + // the blob, and downloadWorkflow also saves to disk, so a zip of N workflows + // wrote N+1 files. + + it("saves only the zip, not one JSON per workflow, when several workflows are zipped", async () => { + workflowPersistServiceSpy.retrieveWorkflow.mockReturnValue(of({ content: { op: "x" } } as any)); + + const result = await firstValueFrom( + downloadService.downloadWorkflowsAsZip([ + { id: 1, name: "Alpha" }, + { id: 2, name: "Beta" }, + { id: 3, name: "Gamma" }, + ]) + ); + + expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledTimes(1); + expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledWith(result, expect.stringMatching(/^workflowExports-.*\.zip$/)); + + const savedNames = fileSaverServiceSpy.saveAs.mock.calls.map(([, fileName]) => fileName); + expect(savedNames).not.toContain("Alpha.json"); + expect(savedNames).not.toContain("Beta.json"); + expect(savedNames).not.toContain("Gamma.json"); + }); + + it("saves only the zip for a single-workflow selection", async () => { + workflowPersistServiceSpy.retrieveWorkflow.mockReturnValue(of({ content: { op: "x" } } as any)); + + await firstValueFrom(downloadService.downloadWorkflowsAsZip([{ id: 1, name: "Solo" }])); + + expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledTimes(1); + expect(fileSaverServiceSpy.saveAs.mock.calls[0][1]).toMatch(/^workflowExports-.*\.zip$/); + }); + + // The other direction: the standalone single-workflow download action must + // keep saving, so the fix cannot simply drop the save from downloadWorkflow. + it("still saves the file when a single workflow is downloaded on its own", async () => { + workflowPersistServiceSpy.retrieveWorkflow.mockReturnValue(of({ content: { op: "x" } } as any)); + + const item = await firstValueFrom(downloadService.downloadWorkflow(42, "MyWorkflow")); + + expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledTimes(1); + expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledWith(item.blob, "MyWorkflow.json"); + }); + + it("saves nothing when one of the workflows fails to retrieve", async () => { + // The zip aborts as a whole, so the workflows that did come back must not be + // left behind as loose files. + workflowPersistServiceSpy.retrieveWorkflow.mockImplementation((id: number) => + id === 2 ? throwError(() => new Error("retrieve fail")) : (of({ content: { op: "x" } }) as any) + ); + + await expect( + firstValueFrom( + downloadService.downloadWorkflowsAsZip([ + { id: 1, name: "Alpha" }, + { id: 2, name: "Beta" }, + { id: 3, name: "Gamma" }, + ]) + ) + ).rejects.toThrow("retrieve fail"); + + expect(fileSaverServiceSpy.saveAs).not.toHaveBeenCalled(); + expect(notificationServiceSpy.error).toHaveBeenCalledWith("Error downloading workflows as ZIP"); + }); + + it("saves nothing for an empty selection", async () => { + // The toolbar never calls this with an empty selection, but forkJoin([]) + // completes without emitting, so the chain must end without writing an + // empty zip to disk. + let completed = false; + await new Promise<void>(resolve => + downloadService.downloadWorkflowsAsZip([]).subscribe({ + complete: () => { + completed = true; + resolve(); + }, + }) + ); + + expect(completed).toBe(true); + expect(fileSaverServiceSpy.saveAs).not.toHaveBeenCalled(); + expect(workflowPersistServiceSpy.retrieveWorkflow).not.toHaveBeenCalled(); + }); + + it("writes the workflow content into the zip entries", async () => { + workflowPersistServiceSpy.retrieveWorkflow.mockReturnValue(of({ content: { op: "x" } } as any)); + + const result = await firstValueFrom(downloadService.downloadWorkflowsAsZip([{ id: 1, name: "Alpha" }])); + + // Reading the entry back proves the blob handed to JSZip is the serialized + // workflow, not an empty or misrouted payload. + const loaded = await JSZip.loadAsync(result); + expect(JSON.parse(await loaded.files["Alpha.json"].async("string"))).toEqual({ op: "x" }); + }); + + it("does not save anything when the standalone workflow download fails", async () => { + workflowPersistServiceSpy.retrieveWorkflow.mockReturnValue(throwError(() => new Error("nope"))); + + await expect(firstValueFrom(downloadService.downloadWorkflow(42, "MyWorkflow"))).rejects.toThrow("nope"); + + expect(fileSaverServiceSpy.saveAs).not.toHaveBeenCalled(); + }); + // ─── downloadOperatorsResult ────────────────────────────────────────────── it("downloads a single operator file directly when there's exactly one file", async () => { diff --git a/frontend/src/app/dashboard/service/user/download/download.service.ts b/frontend/src/app/dashboard/service/user/download/download.service.ts index 2b7e5544e2..fb5d67fb70 100644 --- a/frontend/src/app/dashboard/service/user/download/download.service.ts +++ b/frontend/src/app/dashboard/service/user/download/download.service.ts @@ -62,15 +62,7 @@ export class DownloadService { ) {} downloadWorkflow(id: number, name: string): Observable<DownloadableItem> { - return this.workflowPersistService.retrieveWorkflow(id).pipe( - map(({ content }) => { - const workflowJson = JSON.stringify(content, null, 2); - const fileName = `${name}.json`; - const blob = new Blob([workflowJson], { type: "text/plain;charset=utf-8" }); - return { blob, fileName }; - }), - tap(this.saveFile.bind(this)) - ); + return this.retrieveWorkflowItem(id, name).pipe(tap(this.saveFile.bind(this))); } downloadDataset(id: number, name: string): Observable<Blob> { @@ -278,10 +270,25 @@ export class DownloadService { ); } + /** + * Builds the downloadable JSON item of a workflow without saving it, so callers that + * only need the blob (e.g. zip assembly) do not trigger a file save as a side effect. + */ + private retrieveWorkflowItem(id: number, name: string): Observable<DownloadableItem> { + return this.workflowPersistService.retrieveWorkflow(id).pipe( + map(({ content }) => { + const workflowJson = JSON.stringify(content, null, 2); + const fileName = `${name}.json`; + const blob = new Blob([workflowJson], { type: "text/plain;charset=utf-8" }); + return { blob, fileName }; + }) + ); + } + private createWorkflowsZip(workflowEntries: Array<{ id: number; name: string }>): Observable<Blob> { const zip = new JSZip(); const downloadObservables = workflowEntries.map(entry => - this.downloadWorkflow(entry.id, entry.name).pipe( + this.retrieveWorkflowItem(entry.id, entry.name).pipe( tap(({ blob, fileName }) => { zip.file(this.nameWorkflow(fileName, zip), blob); })
