import { describe, it, expect, beforeEach, vi } from 'vitest' import { render } from '@testing-library/react' // Hoisted mock for html2canvas — tests reach into `mockToBlob` to switch // between "blob produced" and "toBlob returned null" without re-mocking. type ToBlobCb = (blob: Blob | null) => void const mockToBlob = vi.fn((cb: ToBlobCb) => cb(new Blob(['png-bytes']))) vi.mock('html2canvas', () => ({ default: () => Promise.resolve({ toBlob: mockToBlob }), })) import { toCsvString, downloadToCSV, triggerLinkDownload, downloadDOMToPNG, sanitizeFilename, } from './exports' import { CSVIcon, PNGIcon } from './icons' let csvText = '' let revokeSpy: ReturnType beforeEach(() => { csvText = '' mockToBlob.mockReset() mockToBlob.mockImplementation((cb: ToBlobCb) => cb(new Blob(['png-bytes']))) vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock') revokeSpy = vi .spyOn(URL, 'revokeObjectURL') .mockImplementation(() => undefined) const RealBlob = global.Blob vi.stubGlobal( 'Blob', class extends RealBlob { constructor(parts: BlobPart[], opts?: BlobPropertyBag) { csvText = typeof parts[0] === 'string' ? parts[0] : '' super(parts, opts) } }, ) }) describe('toCsvString', () => { it('joins rows with newlines and cells with commas', () => { expect( toCsvString([ ['name', 'value'], ['a', 1], ['b', 2], ]), ).toBe('name,value\na,1\nb,2') }) it('emits blank cells for null and undefined', () => { expect(toCsvString([[null, undefined, 'x']])).toBe(',,x') }) it('stringifies numbers and booleans', () => { expect(toCsvString([[1, false, true]])).toBe('1,false,true') }) it('JSON.stringifies objects and arrays', () => { expect(toCsvString([[{ a: 1 }, [1, 2]]])).toBe('"{""a"":1}","[1,2]"') }) it('quotes cells containing comma / quote / newline', () => { expect(toCsvString([['a,b', 'he said "hi"', 'line1\nline2']])).toBe( '"a,b","he said ""hi""","line1\nline2"', ) }) describe('formula injection guard', () => { it.each([ [ '=', '=HYPERLINK("https://attacker.example")', '\'=HYPERLINK("https://attacker.example")', ], ['+', '+1+1', "'+1+1"], ['-', '-2+1', "'-2+1"], ['@', '@SUM(A1:A2)', "'@SUM(A1:A2)"], ['\\t', '\tleading-tab', "'\tleading-tab"], ['\\r', '\rleading-cr', "'\rleading-cr"], ])( 'prefixes cells starting with %s with a single quote', (_label, raw, expectedRaw) => { // The escape step also quotes if the value contains comma/quote/newline, // so we re-derive the exact wire representation from the raw expected. const expected = /[",\n\r]/.test(expectedRaw) ? `"${expectedRaw.replace(/"/g, '""')}"` : expectedRaw expect(toCsvString([[raw]])).toBe(expected) }, ) it('does not touch values where the dangerous char is not leading', () => { expect(toCsvString([['a=b']])).toBe('a=b') expect(toCsvString([['1+2']])).toBe('1+2') }) }) }) describe('downloadToCSV', () => { it('serialises rows then wraps them in a URL handle with a working revoke', () => { const handle = downloadToCSV([['x', 1]]) expect(handle.url).toBe('blob:mock') expect(csvText).toBe('x,1') handle.revoke?.() expect(revokeSpy).toHaveBeenCalledWith('blob:mock') }) }) describe('triggerLinkDownload', () => { it('synthesises an anchor, clicks it, then removes it', () => { const appendSpy = vi.spyOn(document.body, 'appendChild') triggerLinkDownload({ url: 'blob:x', filename: 'demo.csv' }) expect(appendSpy).toHaveBeenCalled() const anchor = appendSpy.mock.calls[0]?.[0] as HTMLAnchorElement expect(anchor.href).toBe('blob:x') expect(anchor.download).toBe('demo.csv') expect(anchor.isConnected).toBe(false) }) it('applies sanitizeFilename to the anchor download attribute', () => { const appendSpy = vi.spyOn(document.body, 'appendChild') const before = appendSpy.mock.calls.length triggerLinkDownload({ url: 'blob:y', filename: '..\\evil\r\nname.csv', }) const anchor = appendSpy.mock.calls[before]?.[0] as HTMLAnchorElement expect(anchor.download).toBe('.._evil__name.csv') }) }) describe('sanitizeFilename', () => { it('replaces path separators, NUL, and ASCII control chars with underscore', () => { expect(sanitizeFilename('a/b\\c\x00\x07d\r\ne.csv')).toBe('a_b_c__d__e.csv') }) it('clamps long filenames to 200 chars', () => { const long = 'x'.repeat(500) + '.csv' const out = sanitizeFilename(long) expect(out.length).toBe(200) expect(out.startsWith('x')).toBe(true) }) it('falls back to "download" for empty or whitespace-only input', () => { expect(sanitizeFilename('')).toBe('download') // Whitespace stays — sanitizer only handles control bytes — but a leading // NUL run that empties the string still falls back. expect(sanitizeFilename('\x00\x00\x00')).toBe('___') }) }) describe('downloadDOMToPNG', () => { it('rasterises the element via html2canvas and resolves with a handle', async () => { const handle = await downloadDOMToPNG({ element: document.createElement('div'), }) expect(handle.url).toBe('blob:mock') handle.revoke?.() expect(revokeSpy).toHaveBeenCalledWith('blob:mock') }) it('rejects when canvas.toBlob returns null', async () => { mockToBlob.mockImplementation((cb: ToBlobCb) => cb(null)) await expect( downloadDOMToPNG({ element: document.createElement('div') }), ).rejects.toThrow(/toBlob/) }) it('forwards pixelRatio and backgroundColor overrides', async () => { // We can't easily inspect the call args without re-hoisting the mock, // so just guard the happy path still works with overrides supplied. const handle = await downloadDOMToPNG({ element: document.createElement('div'), pixelRatio: 4, backgroundColor: '#fff', }) expect(handle.url).toBe('blob:mock') }) }) describe('download icons', () => { it('CSVIcon renders an SVG element', () => { const { container } = render() expect(container.querySelector('svg')).not.toBeNull() }) it('PNGIcon renders an SVG element', () => { const { container } = render() expect(container.querySelector('svg')).not.toBeNull() }) })