import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
* Re-import the module with a clean state so internal singletons
* like _inflight / _errorCache start fresh.
* @returns {Promise<{snapFetch: (url: string, opts?: any)=>Promise<any>}>}
*/
async function importFresh() {
vi.resetModules()
return import('../src/modules/snapFetch.js')
}
const ORIGIN = globalThis.location?.origin || 'http://localhost'
const SAME = `${ORIGIN}/assets/a.css`
const CROSS = 'https://cdn.example/x.png'
function mockFetchOnce(status = 200, body = 'ok', headers = {}) {
globalThis.fetch = vi.fn(async (_input, _init) => new Response(body, { status, headers }))
}
function mockFetchNetworkError() {
globalThis.fetch = vi.fn(async () => {
throw new TypeError('Failed to fetch')
})
}
function mockFetchTimeoutAware() {
globalThis.fetch = vi.fn((input, init) => {
return new Promise((_, reject) => {
const err = Object.assign(new Error('timeout'), { name: 'AbortError' })
const signal = init?.signal
if (signal?.aborted) return reject(err)
signal?.addEventListener('abort', () => reject(err), { once: true })
})
})
}
function deferred() {
const d = {}
d.promise = new Promise((res, rej) => {
d.resolve = res
d.reject = rej
})
return d
}
beforeEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('snapFetch (happy paths)', () => {
it('returns text when as:"text"', async () => {
const { snapFetch } = await importFresh()
mockFetchOnce(200, 'hello', { 'content-type': 'text/plain' })
const res = await snapFetch(`${ORIGIN}/hello.txt`, { as: 'text' })
expect(res.ok).toBe(true)
expect(res.status).toBe(200)
expect(res.data).toBe('hello')
})
it('returns Blob when as:"blob"', async () => {
const { snapFetch } = await importFresh()
mockFetchOnce(200, 'BLOB!', { 'content-type': 'image/png' })
const res = await snapFetch(`${ORIGIN}/p.png`, { as: 'blob' })
expect(res.ok).toBe(true)
expect(res.data).toBeInstanceOf(Blob)
expect(res.mime).toMatch(/image\/png/i)
})
it('returns DataURL when as:"dataURL"', async () => {
const { snapFetch } = await importFresh()
mockFetchOnce(200, 'PNGDATA', { 'content-type': 'image/png' })
const res = await snapFetch(`${ORIGIN}/p.png`, { as: 'dataURL' })
expect(res.ok).toBe(true)
expect(typeof res.data).toBe('string')
expect(res.data.startsWith('data:')).toBe(true)
})
})
describe('snapFetch (errors are non-throwing)', () => {
it('maps HTTP error to { ok:false, reason:"http_error" }', async () => {
const { snapFetch } = await importFresh()
mockFetchOnce(404, '', {})
const res = await snapFetch(`${ORIGIN}/miss.css`, { as: 'text' })
expect(res.ok).toBe(false)
expect(res.status).toBe(404)
expect(res.reason).toBe('http_error')
})
it('maps network error to { ok:false, reason:"network" }', async () => {
const { snapFetch } = await importFresh()
mockFetchNetworkError()
const res = await snapFetch(CROSS, { as: 'blob' })
expect(res.ok).toBe(false)
expect(res.status).toBe(0)
expect(res.reason).toBe('network')
})
it('maps timeout to { ok:false, reason:"timeout" }', async () => {
vi.useFakeTimers()
const { snapFetch } = await importFresh()
mockFetchTimeoutAware()
const p = snapFetch(CROSS, { as: 'blob', timeout: 123 })
vi.advanceTimersByTime(123)
const res = await p
expect(res.ok).toBe(false)
expect(res.reason).toBe('timeout')
expect(res.status).toBe(0)
})
})
describe('snapFetch (inflight dedup + error TTL)', () => {
it('deduplicates inflight requests to same key', async () => {
const { snapFetch } = await importFresh()
const d = deferred()
globalThis.fetch = vi.fn((_input, _init) => d.promise)
const reqA = snapFetch(`${ORIGIN}/a.png`, { as: 'blob', timeout: 999 })
const reqB = snapFetch(`${ORIGIN}/a.png`, { as: 'blob', timeout: 999 })
expect(globalThis.fetch).toHaveBeenCalledTimes(1)
d.resolve(new Response('IMG', { status: 200, headers: { 'content-type': 'image/png' } }))
const [resA, resB] = await Promise.all([reqA, reqB])
expect(resA.ok && resB.ok).toBe(true)
expect(resA.status).toBe(200)
expect(resB.status).toBe(200)
})
it('caches errors for errorTTL and does not re-fetch within TTL', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2020-01-01T00:00:00Z'))
const { snapFetch } = await importFresh()
const spy = vi.fn()
.mockResolvedValueOnce(new Response('', { status: 500 }))
.mockResolvedValueOnce(new Response('OK', { status: 200, headers: { 'content-type': 'image/png' } }))
globalThis.fetch = spy
const r1 = await snapFetch(`${ORIGIN}/fail.png`, { as: 'blob', errorTTL: 8000 })
expect(r1.ok).toBe(false)
expect(spy).toHaveBeenCalledTimes(1)
const r2 = await snapFetch(`${ORIGIN}/fail.png`, { as: 'blob', errorTTL: 8000 })
expect(r2.ok).toBe(false)
expect(r2.fromCache).toBe(true)
expect(spy).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(8001)
vi.setSystemTime(new Date('2020-01-01T00:00:08.001Z'))
const r3 = await snapFetch(`${ORIGIN}/fail.png`, { as: 'blob', errorTTL: 8000 })
expect(r3.ok).toBe(true)
expect(spy).toHaveBeenCalledTimes(2)
})
})
describe('snapFetch (proxy & credentials)', () => {
it('same-origin → credentials: include, no proxy applied', async () => {
const { snapFetch } = await importFresh()
const spy = vi.fn(async (_input, _init) => new Response('ok', { status: 200 }))
globalThis.fetch = spy
const url = SAME
await snapFetch(url, { as: 'text', useProxy: 'https://proxy.example/p?url={url}' })
expect(spy.mock.calls[0][0]).toBe(url)
expect(spy.mock.calls[0][1]?.credentials).toBe('include')
})
it('cross-origin + proxy template replaces {url}', async () => {
const { snapFetch } = await importFresh()
mockFetchOnce(200, 'ok', {})
const res = await snapFetch(CROSS, {
as: 'blob',
useProxy: 'https://proxy.example/p?url={url}'
})
expect(res.ok).toBe(true)
expect(res.url).toBe('https://proxy.example/p?url=' + encodeURIComponent(CROSS))
})
it('cross-origin + base proxy appends ?url=', async () => {
const { snapFetch } = await importFresh()
mockFetchOnce(200, 'ok', {})
const res = await snapFetch(CROSS, {
as: 'text',
useProxy: 'https://proxy.example/p?'
})
expect(res.ok).toBe(true)
expect(res.url).toBe('https://proxy.example/p?url=' + encodeURIComponent(CROSS))
})
})
describe('snapFetch (logging & silent mode)', () => {
it('dedupes console messages; silent:true suppresses logs', async () => {
const { snapFetch } = await importFresh()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockFetchOnce(404, '', {})
const r1 = await snapFetch(`${ORIGIN}/missing.png`, { as: 'blob' })
expect(r1.ok).toBe(false)
const warnCountAfter1 = warnSpy.mock.calls.length
const r2 = await snapFetch(`${ORIGIN}/missing.png`, { as: 'blob' })
expect(r2.ok).toBe(false)
expect(warnSpy.mock.calls.length).toBe(warnCountAfter1)
const totalBefore = errSpy.mock.calls.length + warnSpy.mock.calls.length
mockFetchNetworkError()
const r3 = await snapFetch(CROSS, { as: 'blob' })
expect(r3.ok).toBe(false)
const totalAfter = errSpy.mock.calls.length + warnSpy.mock.calls.length
expect([0, 1]).toContain(totalAfter - totalBefore)
mockFetchNetworkError()
const r4 = await snapFetch('https://cdn.example/b.png', { as: 'blob', silent: true })
expect(r4.ok).toBe(false)
const afterSilent = errSpy.mock.calls.length + warnSpy.mock.calls.length
expect(afterSilent).toBe(totalAfter)
warnSpy.mockRestore()
errSpy.mockRestore()
})
})
describe('snapFetch (about:blank scheme)', () => {
it('returns a 1x1 png dataURL for as:"dataURL"', async () => {
const { snapFetch } = await importFresh()
const r = await snapFetch('about:blank', { as: 'dataURL' })
expect(r.ok).toBe(true)
expect(r.status).toBe(200)
expect(r.mime).toBe('image/png')
expect(String(r.data).startsWith('data:image/png;base64,')).toBe(true)
})
it('returns an empty string for as:"text"', async () => {
const { snapFetch } = await importFresh()
const r = await snapFetch('about:blank', { as: 'text' })
expect(r.ok).toBe(true)
expect(r.data).toBe('')
})
it('returns an empty Blob for the default blob mode', async () => {
const { snapFetch } = await importFresh()
const r = await snapFetch('about:blank')
expect(r.ok).toBe(true)
expect(r.data).toBeInstanceOf(Blob)
expect(r.data.size).toBe(0)
})
it('does not go through fetch for about:blank', async () => {
const { snapFetch } = await importFresh()
const spy = vi.fn()
globalThis.fetch = spy
await snapFetch('about:blank', { as: 'dataURL' })
expect(spy).not.toHaveBeenCalled()
})
})
describe('snapFetch (credential inference fallback)', () => {
it('falls back to credentials:"omit" when the URL cannot be parsed', async () => {
const { snapFetch } = await importFresh()
mockFetchNetworkError()
const r = await snapFetch('https://', { as: 'blob', timeout: 200, silent: true })
expect(r.ok).toBe(false)
})
})