From 68b660e54798cb36bb120724f13415d751db038e Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 7 Apr 2026 23:53:43 +0200 Subject: [PATCH] fix(tests): use node:buffer.Blob so URL.createObjectURL works on Node 22 Node 22 URL.createObjectURL strictly requires a native node:buffer Blob and throws ERR_INVALID_ARG_TYPE when given a jsdom Blob (caught by fetchImageAsBlob, returning ''). Node 24 relaxed this check, masking the failure locally. Tests 007, 011: replace MSW/Response-based fetch mocks with direct vi.spyOn(fetch) mocks returning node:buffer Blobs via a duck-typed response object. The real URL.createObjectURL now handles the correct Blob type and returns a genuine blob: URL on all Node versions. Test 012: URL.createObjectURL identity varies across Node versions making it impossible to spy on reliably. Replace createObjectURLSpy assertion with a completedFetches counter in the fetch mock, which proves the same semantic guarantee (6 requests ran, 7th was cleared). setup.ts: restore the original conditional guard so the vi.fn fallback only applies when URL.createObjectURL is completely absent, not overwriting a working real implementation. --- client/tests/setup.ts | 14 ++++++---- client/tests/unit/api/authUrl.test.ts | 40 +++++++++++++++++---------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/client/tests/setup.ts b/client/tests/setup.ts index d801eee1..0ff906a7 100644 --- a/client/tests/setup.ts +++ b/client/tests/setup.ts @@ -59,11 +59,15 @@ globalThis.ResizeObserver = vi.fn().mockImplementation(() => ({ disconnect: vi.fn(), })) as unknown as typeof ResizeObserver; -// URL.createObjectURL / revokeObjectURL — in jsdom, modules access globals -// through window.URL (not globalThis.URL — these are different objects on CI). -// Targeting window.URL is required so the mock is visible to source modules. -Object.defineProperty(window.URL, 'createObjectURL', { writable: true, configurable: true, value: vi.fn(() => 'blob:mock') }); -Object.defineProperty(window.URL, 'revokeObjectURL', { writable: true, configurable: true, value: vi.fn() }); +// URL.createObjectURL / revokeObjectURL — Node 22 URL.createObjectURL requires +// a native node:buffer Blob; passing a jsdom Blob throws ERR_INVALID_ARG_TYPE. +// Tests that need blob URLs should mock fetch to return node:buffer Blobs so +// the real URL.createObjectURL works. For tests that only need the method to +// exist without returning a real URL, stub it here as a vi.fn fallback. +if (typeof URL.createObjectURL === 'undefined') { + Object.defineProperty(URL, 'createObjectURL', { writable: true, configurable: true, value: vi.fn(() => 'blob:mock') }); + Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: vi.fn() }); +} // Element.prototype.scrollIntoView — jsdom doesn't implement it Element.prototype.scrollIntoView = vi.fn(); diff --git a/client/tests/unit/api/authUrl.test.ts b/client/tests/unit/api/authUrl.test.ts index c7e68362..d91ad08c 100644 --- a/client/tests/unit/api/authUrl.test.ts +++ b/client/tests/unit/api/authUrl.test.ts @@ -8,8 +8,7 @@ const flushPromises = () => new Promise(r => setTimeout(r, 10)); beforeEach(() => { clearImageQueue(); - vi.restoreAllMocks(); // remove vi.spyOn() wrappers, restoring to the setup.ts vi.fn() - vi.clearAllMocks(); // reset accumulated call counts on window.URL mocks from setup.ts + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -82,15 +81,16 @@ describe('fetchImageAsBlob', () => { describe('FE-COMP-AUTHURL-007: successful fetch returns blob object URL', () => { it('resolves to a blob URL for a valid image response', async () => { - server.use( - http.get('/uploads/photo.jpg', () => - new HttpResponse(new Blob(['fake-image'], { type: 'image/jpeg' }), { - status: 200, - }) - ) - ); + // Node 22 URL.createObjectURL requires a native node:buffer Blob, not a + // jsdom Blob — passing the wrong type throws ERR_INVALID_ARG_TYPE (caught, + // returns ''). Mock fetch directly with a Node Blob so the real + // URL.createObjectURL works without any mocking needed. + const { Blob: NodeBlob } = await import('node:buffer'); + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + blob: () => Promise.resolve(new NodeBlob(['fake-image'], { type: 'image/jpeg' }) as unknown as Blob), + } as unknown as Response); const result = await fetchImageAsBlob('/uploads/photo.jpg'); - // URL.createObjectURL is native in Node 20+; just assert it's a blob URL expect(result).toMatch(/^blob:/); }); }); @@ -159,10 +159,14 @@ describe('fetchImageAsBlob', () => { describe('FE-COMP-AUTHURL-011: queued request runs after active slot frees', () => { it('7th request eventually resolves once one of the 6 active slots is freed', async () => { const resolvers: Array<() => void> = []; + const { Blob: NodeBlob } = await import('node:buffer'); vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { await new Promise(r => resolvers.push(r)); - return new Response(new Blob(['img'], { type: 'image/jpeg' }), { status: 200 }); + return { + ok: true, + blob: () => Promise.resolve(new NodeBlob(['img'], { type: 'image/jpeg' }) as unknown as Blob), + } as unknown as Response; }); const urls = Array.from({ length: 7 }, (_, i) => `/uploads/queue${i}.jpg`); @@ -194,11 +198,19 @@ describe('clearImageQueue', () => { describe('FE-COMP-AUTHURL-012: clearImageQueue discards pending entries', () => { it('removes queued items so they never execute after active slots drain', async () => { const resolvers: Array<() => void> = []; - const createObjectURLSpy = vi.spyOn(window.URL, 'createObjectURL'); + // Track completions via fetch mock instead of URL.createObjectURL spy — + // URL.createObjectURL is a Node built-in whose identity varies across + // Node versions, making it unreliable to spy on in jsdom tests on CI. + let completedFetches = 0; + const { Blob: NodeBlob } = await import('node:buffer'); vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { await new Promise(r => resolvers.push(r)); - return new Response(new Blob(['img'], { type: 'image/jpeg' }), { status: 200 }); + completedFetches++; + return { + ok: true, + blob: () => Promise.resolve(new NodeBlob(['img'], { type: 'image/jpeg' }) as unknown as Blob), + } as unknown as Response; }); const urls = Array.from({ length: 7 }, (_, i) => `/uploads/clear${i}.jpg`); @@ -215,7 +227,7 @@ describe('clearImageQueue', () => { await flushPromises(); // 6 active slots completed; queue was cleared so the 7th never ran - expect(createObjectURLSpy).toHaveBeenCalledTimes(6); + expect(completedFetches).toBe(6); // First 6 promises resolved; 7th is orphaned (never resolves) await Promise.all(promises.slice(0, 6));