From 4cca76acb05315c9238075a89d83f44f9b602300 Mon Sep 17 00:00:00 2001 From: SAPHID Date: Mon, 12 Oct 2026 00:19:15 +0000 Subject: [PATCH 1/3] perf(preview): release original browser downloads after saving copies Co-Authored-By: GPT-6.1 Sol --- apps/server/src/preview/ServerBrowser.test.ts | 184 ++++++++++++++++-- apps/server/src/preview/ServerBrowser.ts | 2 + 2 files changed, 175 insertions(+), 11 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 385223a42d397..bae94fb78e0b4 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -1,4 +1,6 @@ import * as NodeEvents from "node:events"; +// @effect-diagnostics-next-line nodeBuiltinImport:off -- fake Playwright download callbacks use Promise-based filesystem I/O. +import * as NodeFSP from "node:fs/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; @@ -16,6 +18,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; @@ -785,6 +788,7 @@ it.live("a tab opened to a file the browser cannot show reports the file to down const written = Promise.withResolvers(); page.emit("download", { failure: async () => null, + delete: async () => {}, saveAs: (path: string) => { Queue.offerUnsafe(saved, path); return written.promise; @@ -830,6 +834,7 @@ it.live("a file a blank tab opened is offered to download if the tab moves on wh const written = Promise.withResolvers(); page.emit("download", { failure: async () => null, + delete: async () => {}, saveAs: (path: string) => { Queue.offerUnsafe(saved, path); return written.promise; @@ -873,6 +878,7 @@ it.live("a download from a superseded navigation leaves the newer navigation loa const written = Promise.withResolvers(); page.emit("download", { failure: async () => null, + delete: async () => {}, saveAs: (path: string) => { Queue.offerUnsafe(saved, path); return written.promise; @@ -1043,24 +1049,28 @@ it.live("a page download is saved, offered to the controller, and listed for the const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); yield* viewer.input({ type: "takeControl" }); yield* Queue.clear(viewer.output); - // Stands in for Chromium, which writes the file itself. - const saved = yield* Queue.unbounded(); - const written = Promise.withResolvers(); + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const sourceDir = yield* fs.makeTempDirectoryScoped(); + const source = pathService.join(sourceDir, "original.csv"); + yield* fs.writeFileString(source, "a,b"); + const deleted = Promise.withResolvers(); + const deleteOriginal = vi.fn(async () => { + await NodeFSP.rm(source); + deleted.resolve(); + }); page.emit("download", { failure: async () => null, - saveAs: (path: string) => { - Queue.offerUnsafe(saved, path); - return written.promise; - }, + delete: deleteOriginal, + saveAs: (path: string) => NodeFSP.copyFile(source, path), suggestedFilename: () => "report.csv", url: () => "blob:http://localhost:5173/1", }); - const fs = yield* FileSystem.FileSystem; - const path = yield* Queue.take(saved); - yield* fs.writeFileString(path, "a,b"); - written.resolve(); let offered = yield* Queue.take(viewer.output); while (offered._tag !== "download") offered = yield* Queue.take(viewer.output); + expect(deleteOriginal).toHaveBeenCalledOnce(); + yield* Effect.promise(() => deleted.promise); + expect(yield* fs.exists(source)).toBe(false); expect(offered).toMatchObject({ _tag: "download", fileName: "report.csv", sizeBytes: 3 }); const file = yield* browser.openDownload({ threadId: scope.thread.threadId, @@ -1068,6 +1078,7 @@ it.live("a page download is saved, offered to the controller, and listed for the downloadId: offered.id, }); expect(Option.isSome(file) && file.value.fileName).toBe("report.csv"); + if (Option.isSome(file)) expect(yield* fs.readFileString(file.value.path)).toBe("a,b"); const status = yield* broker.invoke({ scope, tabId, @@ -1090,6 +1101,157 @@ it.live("a page download is saved, offered to the controller, and listed for the ).pipe(Effect.provide(layer)), ); +it.live("a file navigation settles before artifact deletion can start another navigation", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + const events = yield* manager.subscribeEvents; + yield* Effect.yieldNow; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + yield* browser.attachViewer(viewerInput(opened.tabId, true)); + const page = contexts[0]!.page; + const url = "https://example.com/report.csv"; + page.emit("requestfailed", { + url: () => url, + method: () => "GET", + isNavigationRequest: () => true, + frame: () => page, + failure: () => ({ errorText: "net::ERR_ABORTED" }), + }); + const saved = yield* Queue.unbounded(); + const written = Promise.withResolvers(); + const deleting = Promise.withResolvers(); + page.emit("download", { + failure: async () => null, + saveAs: (path: string) => { + Queue.offerUnsafe(saved, path); + return written.promise; + }, + delete: async () => { + page.emit("request", { + url: () => "https://example.com/next", + method: () => "GET", + isNavigationRequest: () => true, + frame: () => page, + }); + deleting.resolve(); + }, + suggestedFilename: () => "report.csv", + url: () => url, + }); + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(yield* Queue.take(saved), "a,b"); + written.resolve(); + yield* Effect.promise(() => deleting.promise); + // The newer navigation's status follows any status settled before deletion. + const received = []; + let event; + do { + event = yield* PubSub.take(events); + received.push(event); + } while ( + event.type !== "navigated" || + event.snapshot.navStatus._tag !== "Loading" || + event.snapshot.navStatus.url !== "https://example.com/next" + ); + expect(received).toContainEqual( + expect.objectContaining({ + type: "failed", + download: { id: expect.any(String), fileName: "report.csv" }, + }), + ); + }), + ).pipe(Effect.provide(layer)), +); + +it.live.each(["saved", "oversized", "save failed", "delete failed"] as const)( + "a %s download releases its original without losing the saved copy or status", + (outcome) => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const broker = yield* Broker.PreviewAutomationBroker; + const manager = yield* Manager.PreviewManager; + const events = yield* manager.subscribeEvents; + yield* Effect.yieldNow; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); + const page = contexts[0]!.page; + const fs = yield* FileSystem.FileSystem; + const sourceDir = yield* fs.makeTempDirectoryScoped(); + const pathService = yield* Path.Path; + const source = pathService.join(sourceDir, "original"); + yield* fs.writeFileString(source, "a,b"); + const url = "https://example.com/report.csv"; + page.emit("requestfailed", { + url: () => url, + method: () => "GET", + isNavigationRequest: () => true, + frame: () => page, + failure: () => ({ errorText: "net::ERR_ABORTED" }), + }); + let savedPath = ""; + const deleted = Promise.withResolvers(); + const deleteOriginal = vi.fn(async () => { + try { + if (outcome === "delete failed") throw new Error("context closed"); + await NodeFSP.rm(source); + } finally { + deleted.resolve(); + } + }); + page.emit("download", { + failure: async () => null, + saveAs: async (path: string) => { + savedPath = path; + await NodeFSP.copyFile(source, path); + if (outcome === "oversized") { + // Sparse artifacts exercise the real size limit without allocating 1 GiB. + await NodeFSP.truncate(source, 1024 * 1024 * 1024 + 1); + await NodeFSP.truncate(path, 1024 * 1024 * 1024 + 1); + } + if (outcome === "save failed") throw new Error("save interrupted"); + }, + delete: deleteOriginal, + suggestedFilename: () => "report.csv", + url: () => url, + }); + // Wait for both navigation status and artifact cleanup without polling. + let settled = yield* PubSub.take(events); + while (settled.type !== "failed") settled = yield* PubSub.take(events); + expect(deleteOriginal).toHaveBeenCalledOnce(); + yield* Effect.promise(() => deleted.promise); + const retainedBytes = (yield* fs.exists(source)) + ? Number((yield* fs.stat(source)).size) + : 0; + yield* Effect.logInfo("download artifact measurement", { + outcome, + originalBytes: retainedBytes, + }); + expect(retainedBytes).toBe(outcome === "delete failed" ? 3 : 0); + const accepted = outcome === "saved" || outcome === "delete failed"; + expect(yield* fs.exists(savedPath)).toBe(accepted); + const status = yield* broker.invoke({ + scope, + tabId: PreviewTabId.make(opened.tabId), + operation: "status", + input: {}, + }); + expect(status.downloads).toHaveLength(accepted ? 1 : 0); + if (accepted) { + expect(settled).toMatchObject({ download: { fileName: "report.csv" } }); + expect(yield* fs.readFileString(savedPath)).toBe("a,b"); + } else { + expect(settled).not.toHaveProperty("download"); + } + expect((yield* Queue.clear(viewer.output)).some((item) => item._tag === "download")).toBe( + false, + ); + }), + ).pipe(Effect.provide(layer)), +); + it.live("a page's file picker goes to the controller and takes its uploaded files", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 939d112c49081..e3effb42690e7 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -1009,6 +1009,8 @@ const make = Effect.gen(function* () { if (navigation !== null && !tab.closing) { settleAbortedNavigation(tab, navigation, download.url(), offered); } + // saveAs copies the artifact; cached profile contexts otherwise retain the original. + await download.delete().catch(constVoid); } }; From 643a3235fc1750777853603365199e442537403a Mon Sep 17 00:00:00 2001 From: SAPHID Date: Wed, 14 Oct 2026 17:17:12 +0000 Subject: [PATCH 2/3] test(preview): narrow download cleanup tests to save and failed save Drops the node:fs diagnostics opt-out and the navigation-ordering and oversized cases; the two remaining cases wait on the original being released. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 188 +++--------------- 1 file changed, 30 insertions(+), 158 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index bae94fb78e0b4..1caec94f299e6 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -1,6 +1,4 @@ import * as NodeEvents from "node:events"; -// @effect-diagnostics-next-line nodeBuiltinImport:off -- fake Playwright download callbacks use Promise-based filesystem I/O. -import * as NodeFSP from "node:fs/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; @@ -18,7 +16,6 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; @@ -1049,36 +1046,35 @@ it.live("a page download is saved, offered to the controller, and listed for the const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); yield* viewer.input({ type: "takeControl" }); yield* Queue.clear(viewer.output); - const fs = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; - const sourceDir = yield* fs.makeTempDirectoryScoped(); - const source = pathService.join(sourceDir, "original.csv"); - yield* fs.writeFileString(source, "a,b"); - const deleted = Promise.withResolvers(); - const deleteOriginal = vi.fn(async () => { - await NodeFSP.rm(source); - deleted.resolve(); - }); + // Stands in for Chromium, which writes the file itself. + const saved = yield* Queue.unbounded(); + const written = Promise.withResolvers(); + const released = Promise.withResolvers(); page.emit("download", { failure: async () => null, - delete: deleteOriginal, - saveAs: (path: string) => NodeFSP.copyFile(source, path), + delete: async () => released.resolve(), + saveAs: (path: string) => { + Queue.offerUnsafe(saved, path); + return written.promise; + }, suggestedFilename: () => "report.csv", url: () => "blob:http://localhost:5173/1", }); + const fs = yield* FileSystem.FileSystem; + const path = yield* Queue.take(saved); + yield* fs.writeFileString(path, "a,b"); + written.resolve(); let offered = yield* Queue.take(viewer.output); while (offered._tag !== "download") offered = yield* Queue.take(viewer.output); - expect(deleteOriginal).toHaveBeenCalledOnce(); - yield* Effect.promise(() => deleted.promise); - expect(yield* fs.exists(source)).toBe(false); expect(offered).toMatchObject({ _tag: "download", fileName: "report.csv", sizeBytes: 3 }); + // Playwright keeps the original until its context closes; saving a copy must release it. + yield* Effect.promise(() => released.promise); const file = yield* browser.openDownload({ threadId: scope.thread.threadId, tabId, downloadId: offered.id, }); expect(Option.isSome(file) && file.value.fileName).toBe("report.csv"); - if (Option.isSome(file)) expect(yield* fs.readFileString(file.value.path)).toBe("a,b"); const status = yield* broker.invoke({ scope, tabId, @@ -1101,157 +1097,33 @@ it.live("a page download is saved, offered to the controller, and listed for the ).pipe(Effect.provide(layer)), ); -it.live("a file navigation settles before artifact deletion can start another navigation", () => +it.live("a download that fails to save still releases the browser's original", () => Effect.scoped( Effect.gen(function* () { - const browser = yield* ServerBrowser.ServerBrowser; - const manager = yield* Manager.PreviewManager; - const events = yield* manager.subscribeEvents; - yield* Effect.yieldNow; - const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); - yield* browser.attachViewer(viewerInput(opened.tabId, true)); + const { broker, tabId } = yield* ready; const page = contexts[0]!.page; - const url = "https://example.com/report.csv"; - page.emit("requestfailed", { - url: () => url, - method: () => "GET", - isNavigationRequest: () => true, - frame: () => page, - failure: () => ({ errorText: "net::ERR_ABORTED" }), - }); - const saved = yield* Queue.unbounded(); - const written = Promise.withResolvers(); - const deleting = Promise.withResolvers(); + const released = Promise.withResolvers(); page.emit("download", { failure: async () => null, - saveAs: (path: string) => { - Queue.offerUnsafe(saved, path); - return written.promise; - }, - delete: async () => { - page.emit("request", { - url: () => "https://example.com/next", - method: () => "GET", - isNavigationRequest: () => true, - frame: () => page, - }); - deleting.resolve(); + delete: async () => released.resolve(), + saveAs: async () => { + throw new Error("save interrupted"); }, suggestedFilename: () => "report.csv", - url: () => url, + url: () => "blob:http://localhost:5173/1", }); - const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString(yield* Queue.take(saved), "a,b"); - written.resolve(); - yield* Effect.promise(() => deleting.promise); - // The newer navigation's status follows any status settled before deletion. - const received = []; - let event; - do { - event = yield* PubSub.take(events); - received.push(event); - } while ( - event.type !== "navigated" || - event.snapshot.navStatus._tag !== "Loading" || - event.snapshot.navStatus.url !== "https://example.com/next" - ); - expect(received).toContainEqual( - expect.objectContaining({ - type: "failed", - download: { id: expect.any(String), fileName: "report.csv" }, - }), - ); + yield* Effect.promise(() => released.promise); + const status = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(status.downloads).toEqual([]); }), ).pipe(Effect.provide(layer)), ); -it.live.each(["saved", "oversized", "save failed", "delete failed"] as const)( - "a %s download releases its original without losing the saved copy or status", - (outcome) => - Effect.scoped( - Effect.gen(function* () { - const browser = yield* ServerBrowser.ServerBrowser; - const broker = yield* Broker.PreviewAutomationBroker; - const manager = yield* Manager.PreviewManager; - const events = yield* manager.subscribeEvents; - yield* Effect.yieldNow; - const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); - const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); - const page = contexts[0]!.page; - const fs = yield* FileSystem.FileSystem; - const sourceDir = yield* fs.makeTempDirectoryScoped(); - const pathService = yield* Path.Path; - const source = pathService.join(sourceDir, "original"); - yield* fs.writeFileString(source, "a,b"); - const url = "https://example.com/report.csv"; - page.emit("requestfailed", { - url: () => url, - method: () => "GET", - isNavigationRequest: () => true, - frame: () => page, - failure: () => ({ errorText: "net::ERR_ABORTED" }), - }); - let savedPath = ""; - const deleted = Promise.withResolvers(); - const deleteOriginal = vi.fn(async () => { - try { - if (outcome === "delete failed") throw new Error("context closed"); - await NodeFSP.rm(source); - } finally { - deleted.resolve(); - } - }); - page.emit("download", { - failure: async () => null, - saveAs: async (path: string) => { - savedPath = path; - await NodeFSP.copyFile(source, path); - if (outcome === "oversized") { - // Sparse artifacts exercise the real size limit without allocating 1 GiB. - await NodeFSP.truncate(source, 1024 * 1024 * 1024 + 1); - await NodeFSP.truncate(path, 1024 * 1024 * 1024 + 1); - } - if (outcome === "save failed") throw new Error("save interrupted"); - }, - delete: deleteOriginal, - suggestedFilename: () => "report.csv", - url: () => url, - }); - // Wait for both navigation status and artifact cleanup without polling. - let settled = yield* PubSub.take(events); - while (settled.type !== "failed") settled = yield* PubSub.take(events); - expect(deleteOriginal).toHaveBeenCalledOnce(); - yield* Effect.promise(() => deleted.promise); - const retainedBytes = (yield* fs.exists(source)) - ? Number((yield* fs.stat(source)).size) - : 0; - yield* Effect.logInfo("download artifact measurement", { - outcome, - originalBytes: retainedBytes, - }); - expect(retainedBytes).toBe(outcome === "delete failed" ? 3 : 0); - const accepted = outcome === "saved" || outcome === "delete failed"; - expect(yield* fs.exists(savedPath)).toBe(accepted); - const status = yield* broker.invoke({ - scope, - tabId: PreviewTabId.make(opened.tabId), - operation: "status", - input: {}, - }); - expect(status.downloads).toHaveLength(accepted ? 1 : 0); - if (accepted) { - expect(settled).toMatchObject({ download: { fileName: "report.csv" } }); - expect(yield* fs.readFileString(savedPath)).toBe("a,b"); - } else { - expect(settled).not.toHaveProperty("download"); - } - expect((yield* Queue.clear(viewer.output)).some((item) => item._tag === "download")).toBe( - false, - ); - }), - ).pipe(Effect.provide(layer)), -); - it.live("a page's file picker goes to the controller and takes its uploaded files", () => Effect.scoped( Effect.gen(function* () { From 3df1ec7d8ec14f1f70a796dd5d42bb09273f0c88 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 02:41:55 +0000 Subject: [PATCH 3/3] test(preview): verify download cleanup preserves saved copies --- apps/server/src/preview/ServerBrowser.test.ts | 212 +++++++++++------- 1 file changed, 127 insertions(+), 85 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 96fca008db987..050b587087fcd 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -1,4 +1,6 @@ import * as NodeEvents from "node:events"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - Playwright download mocks perform real file operations outside the Effect runtime. +import * as NodeFSP from "node:fs/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; @@ -930,7 +932,15 @@ it.live("a tab opened to a file the browser cannot show reports the file to down const written = Promise.withResolvers(); page.emit("download", { failure: async () => null, - delete: async () => {}, + delete: async () => { + // A new navigation during cleanup must not erase the settled download status. + page.emit("request", { + url: () => "https://example.com/next", + method: () => "GET", + isNavigationRequest: () => true, + frame: () => page, + }); + }, saveAs: (path: string) => { Queue.offerUnsafe(saved, path); return written.promise; @@ -941,9 +951,23 @@ it.live("a tab opened to a file the browser cannot show reports the file to down const fs = yield* FileSystem.FileSystem; yield* fs.writeFileString(yield* Queue.take(saved), "%PDF"); written.resolve(); - let status = yield* PubSub.take(events); - while (status.type !== "failed") status = yield* PubSub.take(events); - expect(status).toMatchObject({ url: pdf, download: { fileName: "paper.pdf" } }); + const received = []; + let status; + do { + status = yield* PubSub.take(events); + received.push(status); + } while ( + status.type !== "navigated" || + status.snapshot.navStatus._tag !== "Loading" || + status.snapshot.navStatus.url !== "https://example.com/next" + ); + expect(received).toContainEqual( + expect.objectContaining({ + type: "failed", + url: pdf, + download: { id: expect.any(String), fileName: "paper.pdf" }, + }), + ); expect((yield* Queue.clear(viewer.output)).some((item) => item._tag === "download")).toBe( false, ); @@ -1220,89 +1244,107 @@ it.live("copy keys reach the viewer from a frame in its own process", () => ).pipe(Effect.provide(layer)), ); -it.live("a page download is saved, offered to the controller, and listed for the agent", () => - Effect.scoped( - Effect.gen(function* () { - const { browser, broker, tabId } = yield* ready; - const page = contexts[0]!.page; - const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); - yield* viewer.input({ type: "takeControl" }); - yield* Queue.clear(viewer.output); - const saved = yield* Queue.unbounded(); - const written = Promise.withResolvers(); - const released = Promise.withResolvers(); - page.emit("download", { - failure: async () => null, - delete: async () => released.resolve(), - saveAs: (path: string) => { - Queue.offerUnsafe(saved, path); - return written.promise; - }, - suggestedFilename: () => "report.csv", - url: () => "blob:http://localhost:5173/1", - }); - const fs = yield* FileSystem.FileSystem; - const path = yield* Queue.take(saved); - yield* fs.writeFileString(path, "a,b"); - written.resolve(); - let offered = yield* Queue.take(viewer.output); - while (offered._tag !== "download") offered = yield* Queue.take(viewer.output); - expect(offered).toMatchObject({ _tag: "download", fileName: "report.csv", sizeBytes: 3 }); - // Playwright keeps the original until its context closes; saving a copy must release it. - yield* Effect.promise(() => released.promise); - const file = yield* browser.openDownload({ - threadId: scope.thread.threadId, - tabId, - downloadId: offered.id, - }); - expect(Option.isSome(file) && file.value.fileName).toBe("report.csv"); - const status = yield* broker.invoke({ - scope, - tabId, - operation: "status", - input: {}, - }); - expect(status.downloads).toEqual([ - expect.objectContaining({ fileName: "report.csv", sizeBytes: 3 }), - ]); - expect( - Option.isNone( - yield* browser.openDownload({ - threadId: scope.thread.threadId, - tabId, - downloadId: "guess", - }), - ), - ).toBe(true); - }), - ).pipe(Effect.provide(layer)), +it.live.each([false, true])( + "a page download is saved, offered to the controller, and listed for the agent (delete fails: %s)", + (deleteFails) => + Effect.scoped( + Effect.gen(function* () { + const { browser, broker, tabId } = yield* ready; + const page = contexts[0]!.page; + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* viewer.input({ type: "takeControl" }); + yield* Queue.clear(viewer.output); + const fs = yield* FileSystem.FileSystem; + const original = `${yield* fs.makeTempDirectoryScoped()}/chromium-download`; + yield* fs.writeFileString(original, "a,b"); + const released = Promise.withResolvers(); + page.emit("download", { + failure: async () => null, + delete: async () => { + try { + if (deleteFails) throw new Error("context closed"); + await NodeFSP.rm(original); + } finally { + released.resolve(); + } + }, + saveAs: (path: string) => NodeFSP.copyFile(original, path), + suggestedFilename: () => "report.csv", + url: () => "blob:http://localhost:5173/1", + }); + let offered = yield* Queue.take(viewer.output); + while (offered._tag !== "download") offered = yield* Queue.take(viewer.output); + expect(offered).toMatchObject({ _tag: "download", fileName: "report.csv", sizeBytes: 3 }); + yield* Effect.promise(() => released.promise); + expect(yield* fs.exists(original)).toBe(deleteFails); + const file = yield* browser.openDownload({ + threadId: scope.thread.threadId, + tabId, + downloadId: offered.id, + }); + expect(Option.isSome(file) && file.value.fileName).toBe("report.csv"); + expect(yield* fs.readFileString(Option.getOrThrow(file).path)).toBe("a,b"); + const status = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(status.downloads).toEqual([ + expect.objectContaining({ fileName: "report.csv", sizeBytes: 3 }), + ]); + expect( + Option.isNone( + yield* browser.openDownload({ + threadId: scope.thread.threadId, + tabId, + downloadId: "guess", + }), + ), + ).toBe(true); + }), + ).pipe(Effect.provide(layer)), ); -it.live("a download that fails to save still releases the browser's original", () => - Effect.scoped( - Effect.gen(function* () { - const { broker, tabId } = yield* ready; - const page = contexts[0]!.page; - const released = Promise.withResolvers(); - page.emit("download", { - failure: async () => null, - delete: async () => released.resolve(), - saveAs: async () => { - throw new Error("save interrupted"); - }, - suggestedFilename: () => "report.csv", - url: () => "blob:http://localhost:5173/1", - }); - yield* Effect.promise(() => released.promise); - const status = yield* broker.invoke({ - scope, - tabId, - operation: "status", - input: {}, - }); - expect(status.downloads).toEqual([]); - }), - ).pipe(Effect.provide(layer)), +it.live.each(["save failed", "oversized"])( + "a rejected download releases the original and keeps no saved copy (%s)", + (outcome) => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const fs = yield* FileSystem.FileSystem; + const original = `${yield* fs.makeTempDirectoryScoped()}/chromium-download`; + yield* fs.writeFileString(original, "a,b"); + let savedPath: string | undefined; + const released = Promise.withResolvers(); + contexts[0]!.page.emit("download", { + failure: async () => null, + delete: async () => { + await NodeFSP.rm(original); + released.resolve(); + }, + saveAs: async (path: string) => { + savedPath = path; + await NodeFSP.copyFile(original, path); + if (outcome === "save failed") throw new Error("save interrupted"); + // A sparse file exercises the size limit without writing a GiB of data. + await NodeFSP.truncate(path, 1024 ** 3 + 1); + }, + suggestedFilename: () => "report.csv", + url: () => "blob:http://localhost:5173/1", + }); + yield* Effect.promise(() => released.promise); + expect(yield* fs.exists(original)).toBe(false); + if (savedPath !== undefined) expect(yield* fs.exists(savedPath)).toBe(false); + const status = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(status.downloads).toEqual([]); + }), + ).pipe(Effect.provide(layer)), ); it.live("a page's file picker goes to the controller and takes its uploaded files", () =>