@@ -4,8 +4,10 @@ import path from "node:path";
44import type { Page } from "playwright-core" ;
55import { afterEach , describe , expect , it , vi } from "vitest" ;
66import { DEFAULT_DOWNLOAD_DIR } from "./paths.js" ;
7+ import { createDownloadCaptureForPage } from "./pw-download-capture.js" ;
78import {
89 ensurePageState ,
10+ isDownloadStartingNavigationError ,
911 refLocator ,
1012 rememberRoleRefsForTarget ,
1113 restoreRoleRefsForTarget ,
@@ -39,6 +41,14 @@ function fakePage(): {
3941 handlers . set ( event , list ) ;
4042 return undefined as unknown ;
4143 } ) ;
44+ const off = vi . fn ( ( event : string , cb : ( ...args : unknown [ ] ) => void ) => {
45+ const list = handlers . get ( event ) ?? [ ] ;
46+ handlers . set (
47+ event ,
48+ list . filter ( ( handler ) => handler !== cb ) ,
49+ ) ;
50+ return undefined as unknown ;
51+ } ) ;
4252 const getByRole = vi . fn ( ( ) => ( { nth : vi . fn ( ( ) => ( { ok : true } ) ) } ) ) ;
4353 const frameLocator = vi . fn ( ( ) => ( {
4454 getByRole : vi . fn ( ( ) => ( { nth : vi . fn ( ( ) => ( { ok : true } ) ) } ) ) ,
@@ -48,6 +58,7 @@ function fakePage(): {
4858
4959 const page = {
5060 on,
61+ off,
5162 getByRole,
5263 frameLocator,
5364 locator,
@@ -239,6 +250,96 @@ describe("pw-session ensurePageState", () => {
239250 expect ( download . saveAs ) . not . toHaveBeenCalled ( ) ;
240251 } ) ;
241252
253+ it ( "captures navigation downloads under managed paths" , async ( ) => {
254+ const { page, handlers } = fakePage ( ) ;
255+ const state = ensurePageState ( page ) ;
256+ const capture = createDownloadCaptureForPage ( page , state , 1_000 ) ;
257+ const saveAs = vi . fn ( async ( outPath : string ) => {
258+ await fs . writeFile ( outPath , "attachment" , "utf8" ) ;
259+ } ) ;
260+ const download = {
261+ url : ( ) => "https://example.com/export.csv" ,
262+ suggestedFilename : ( ) => "export.csv" ,
263+ saveAs,
264+ } ;
265+
266+ for ( const handler of handlers . get ( "download" ) ?? [ ] ) {
267+ handler ( download ) ;
268+ }
269+
270+ const result = await capture . promise ;
271+ expect ( result . url ) . toBe ( "https://example.com/export.csv" ) ;
272+ expect ( result . suggestedFilename ) . toBe ( "export.csv" ) ;
273+ expect ( path . dirname ( result . path ) ) . toBe ( DEFAULT_DOWNLOAD_DIR ) ;
274+ expect ( path . basename ( result . path ) ) . toMatch ( / - e x p o r t \. c s v $ / ) ;
275+ expect ( firstSavePath ( saveAs ) ) . not . toBe ( result . path ) ;
276+ await expect ( fs . readFile ( result . path , "utf8" ) ) . resolves . toBe ( "attachment" ) ;
277+ } ) ;
278+
279+ it ( "validates captured navigation downloads before saving managed bytes" , async ( ) => {
280+ const { page, handlers } = fakePage ( ) ;
281+ const state = ensurePageState ( page ) ;
282+ const blocked = new Error ( "blocked download" ) ;
283+ const beforeSave = vi . fn ( async ( ) => {
284+ throw blocked ;
285+ } ) ;
286+ const capture = createDownloadCaptureForPage ( page , state , 1_000 , { beforeSave } ) ;
287+ const saveAs = vi . fn ( async ( outPath : string ) => {
288+ await fs . writeFile ( outPath , "blocked" , "utf8" ) ;
289+ } ) ;
290+ const download = {
291+ url : ( ) => "http://127.0.0.1:18080/export.csv" ,
292+ suggestedFilename : ( ) => "export.csv" ,
293+ saveAs,
294+ } ;
295+
296+ for ( const handler of handlers . get ( "download" ) ?? [ ] ) {
297+ handler ( download ) ;
298+ }
299+
300+ await expect ( capture . promise ) . rejects . toBe ( blocked ) ;
301+ expect ( beforeSave ) . toHaveBeenCalledWith ( {
302+ url : "http://127.0.0.1:18080/export.csv" ,
303+ suggestedFilename : "export.csv" ,
304+ } ) ;
305+ expect ( saveAs ) . not . toHaveBeenCalled ( ) ;
306+ } ) ;
307+
308+ it ( "lets explicit download owners arm while passive capture yields" , ( ) => {
309+ const { page } = fakePage ( ) ;
310+ const state = ensurePageState ( page ) ;
311+ state . downloadWaiterDepth = 1 ;
312+
313+ const passive = createDownloadCaptureForPage ( page , state , 1_000 ) ;
314+ const explicit = createDownloadCaptureForPage ( page , state , 1_000 , { mode : "explicit" } ) ;
315+
316+ expect ( passive . armed ) . toBe ( false ) ;
317+ expect ( explicit . armed ) . toBe ( true ) ;
318+ expect ( state . downloadWaiterDepth ) . toBe ( 2 ) ;
319+ explicit . cancel ( ) ;
320+ expect ( state . downloadWaiterDepth ) . toBe ( 1 ) ;
321+ } ) ;
322+
323+ it ( "recognizes Playwright download-starting navigation aborts" , ( ) => {
324+ expect ( isDownloadStartingNavigationError ( new Error ( "page.goto: Download is starting" ) ) ) . toBe (
325+ true ,
326+ ) ;
327+ expect ( isDownloadStartingNavigationError ( new Error ( "page.goto: net::ERR_ABORTED" ) ) ) . toBe ( false ) ;
328+ expect (
329+ isDownloadStartingNavigationError (
330+ new Error ( "page.goto: net::ERR_ABORTED at http://127.0.0.1:3333/download" ) ,
331+ "http://127.0.0.1:3333/download" ,
332+ ) ,
333+ ) . toBe ( true ) ;
334+ expect (
335+ isDownloadStartingNavigationError (
336+ new Error ( "page.goto: net::ERR_ABORTED at http://127.0.0.1:3333/other" ) ,
337+ "http://127.0.0.1:3333/download" ,
338+ ) ,
339+ ) . toBe ( false ) ;
340+ expect ( isDownloadStartingNavigationError ( new Error ( "Navigation failed" ) ) ) . toBe ( false ) ;
341+ } ) ;
342+
242343 it ( "tracks page errors and network requests (best-effort)" , ( ) => {
243344 const { page, handlers } = fakePage ( ) ;
244345 const state = ensurePageState ( page ) ;
0 commit comments