import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SnapAPI, SnapAPIError, ScreenshotOptions } from '../src/index.js'; // Mock fetch globally const mockFetch = vi.fn(); vi.stubGlobal('fetch', mockFetch); // Mock AbortController and AbortSignal const mockAbort = vi.fn(); const mockAbortController = { abort: mockAbort, signal: { aborted: false, addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), } as AbortSignal, }; // Properly mock AbortController as a constructor class MockAbortController { abort = mockAbort; signal = mockAbortController.signal; } vi.stubGlobal('AbortController', MockAbortController); vi.stubGlobal('setTimeout', vi.fn((fn: () => void, delay: number) => { // Return a timer ID that can be cleared return 123; })); vi.stubGlobal('clearTimeout', vi.fn()); describe('SnapAPI', () => { beforeEach(() => { vi.clearAllMocks(); mockAbort.mockClear(); }); afterEach(() => { vi.clearAllMocks(); }); describe('Constructor', () => { it('throws if no apiKey', () => { expect(() => new SnapAPI('')).toThrow('SnapAPI: apiKey is required'); expect(() => new SnapAPI(null as any)).toThrow('SnapAPI: apiKey is required'); expect(() => new SnapAPI(undefined as any)).toThrow('SnapAPI: apiKey is required'); }); it('sets defaults (baseUrl, timeout)', () => { const snap = new SnapAPI('test-key'); expect(snap['baseUrl']).toBe('https://snapapi.eu'); expect(snap['timeout']).toBe(30000); }); it('accepts custom config', () => { const snap = new SnapAPI('test-key', { baseUrl: 'https://custom.snapapi.com', timeout: 60000, }); expect(snap['baseUrl']).toBe('https://custom.snapapi.com'); expect(snap['timeout']).toBe(60000); }); it('strips trailing slash from baseUrl', () => { const snap = new SnapAPI('test-key', { baseUrl: 'https://custom.snapapi.com/', }); expect(snap['baseUrl']).toBe('https://custom.snapapi.com'); }); }); describe('capture()', () => { let snap: SnapAPI; beforeEach(() => { snap = new SnapAPI('test-api-key'); }); it('sends correct POST with Bearer auth', async () => { const mockArrayBuffer = new ArrayBuffer(100); mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: () => Promise.resolve(mockArrayBuffer), }); await snap.capture('https://example.com'); expect(mockFetch).toHaveBeenCalledWith( 'https://snapapi.eu/v1/screenshot', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-api-key', }, body: JSON.stringify({ url: 'https://example.com' }), signal: mockAbortController.signal, } ); }); it('sends all options in body', async () => { const mockArrayBuffer = new ArrayBuffer(100); mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: () => Promise.resolve(mockArrayBuffer), }); await snap.capture('https://example.com', { format: 'jpeg', width: 1920, height: 1080, quality: 90, fullPage: true, }); expect(mockFetch).toHaveBeenCalledWith( 'https://snapapi.eu/v1/screenshot', expect.objectContaining({ body: JSON.stringify({ url: 'https://example.com', format: 'jpeg', width: 1920, height: 1080, quality: 90, fullPage: true, }), }) ); }); it('sends darkMode parameter correctly', async () => { const mockArrayBuffer = new ArrayBuffer(100); mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: () => Promise.resolve(mockArrayBuffer), }); await snap.capture('https://example.com', { darkMode: true, }); expect(mockFetch).toHaveBeenCalledWith( 'https://snapapi.eu/v1/screenshot', expect.objectContaining({ body: JSON.stringify({ url: 'https://example.com', darkMode: true, }), }) ); }); it('sends hideSelectors as string correctly', async () => { const mockArrayBuffer = new ArrayBuffer(100); mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: () => Promise.resolve(mockArrayBuffer), }); await snap.capture('https://example.com', { hideSelectors: '.ads', }); expect(mockFetch).toHaveBeenCalledWith( 'https://snapapi.eu/v1/screenshot', expect.objectContaining({ body: JSON.stringify({ url: 'https://example.com', hideSelectors: '.ads', }), }) ); }); it('sends hideSelectors as array correctly', async () => { const mockArrayBuffer = new ArrayBuffer(100); mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: () => Promise.resolve(mockArrayBuffer), }); await snap.capture('https://example.com', { hideSelectors: ['.ads', '.popup', '.banner'], }); expect(mockFetch).toHaveBeenCalledWith( 'https://snapapi.eu/v1/screenshot', expect.objectContaining({ body: JSON.stringify({ url: 'https://example.com', hideSelectors: ['.ads', '.popup', '.banner'], }), }) ); }); it('sends both darkMode and hideSelectors together', async () => { const mockArrayBuffer = new ArrayBuffer(100); mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: () => Promise.resolve(mockArrayBuffer), }); await snap.capture('https://example.com', { darkMode: true, hideSelectors: ['.ads', '.tracking'], format: 'png', }); expect(mockFetch).toHaveBeenCalledWith( 'https://snapapi.eu/v1/screenshot', expect.objectContaining({ body: JSON.stringify({ url: 'https://example.com', darkMode: true, hideSelectors: ['.ads', '.tracking'], format: 'png', }), }) ); }); it('works with ScreenshotOptions object form', async () => { const mockArrayBuffer = new ArrayBuffer(100); mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: () => Promise.resolve(mockArrayBuffer), }); const options: ScreenshotOptions = { url: 'https://example.com', format: 'png', width: 1280, height: 800, deviceScale: 2, waitForSelector: '.content', }; await snap.capture(options); expect(mockFetch).toHaveBeenCalledWith( 'https://snapapi.eu/v1/screenshot', expect.objectContaining({ body: JSON.stringify(options), }) ); }); it('throws if no url', async () => { await expect(snap.capture('')).rejects.toThrow('SnapAPI: url is required'); const optionsWithoutUrl = {} as ScreenshotOptions; await expect(snap.capture(optionsWithoutUrl)).rejects.toThrow('SnapAPI: url is required'); }); it('throws SnapAPIError on 401/403/429 with error detail', async () => { // Test 401 mockFetch.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({ error: 'Invalid API key' }), }); await expect(snap.capture('https://example.com')).rejects.toMatchObject({ name: 'SnapAPIError', status: 401, detail: 'Invalid API key', message: 'SnapAPI error 401: Invalid API key', }); // Test 403 mockFetch.mockResolvedValueOnce({ ok: false, status: 403, json: () => Promise.resolve({ error: 'Forbidden' }), }); await expect(snap.capture('https://example.com')).rejects.toMatchObject({ name: 'SnapAPIError', status: 403, detail: 'Forbidden', }); // Test 429 mockFetch.mockResolvedValueOnce({ ok: false, status: 429, json: () => Promise.resolve({ error: 'Rate limit exceeded' }), }); await expect(snap.capture('https://example.com')).rejects.toMatchObject({ name: 'SnapAPIError', status: 429, detail: 'Rate limit exceeded', }); }); it('throws SnapAPIError with fallback message when error JSON fails', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 500, json: () => Promise.reject(new Error('Invalid JSON')), }); await expect(snap.capture('https://example.com')).rejects.toMatchObject({ name: 'SnapAPIError', status: 500, detail: 'HTTP 500', }); }); it('returns Buffer on success', async () => { const mockArrayBuffer = new ArrayBuffer(100); // Fill with some test data const view = new Uint8Array(mockArrayBuffer); view.fill(0xFF); mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: () => Promise.resolve(mockArrayBuffer), }); const result = await snap.capture('https://example.com'); expect(result).toBeInstanceOf(Buffer); expect(result.length).toBe(100); expect(result[0]).toBe(0xFF); }); it('sets up AbortController correctly for timeout', async () => { const mockArrayBuffer = new ArrayBuffer(100); mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: () => Promise.resolve(mockArrayBuffer), }); const customSnap = new SnapAPI('test-key', { timeout: 15000 }); await customSnap.capture('https://example.com'); // Verify setTimeout was called with correct timeout expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 15000); // Verify clearTimeout was called (cleanup) expect(clearTimeout).toHaveBeenCalledWith(123); // Verify fetch was called with the abort signal expect(mockFetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ signal: mockAbortController.signal, }) ); }); }); describe('health()', () => { let snap: SnapAPI; beforeEach(() => { snap = new SnapAPI('test-api-key'); }); it('calls GET /health and returns parsed JSON', async () => { const mockHealthResponse = { status: 'ok', version: '1.0.0', uptime: 12345, browser: { browsers: 2, totalPages: 10, availablePages: 8, queueDepth: 0, }, }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockHealthResponse), }); const result = await snap.health(); expect(mockFetch).toHaveBeenCalledWith('https://snapapi.eu/health'); expect(result).toEqual(mockHealthResponse); }); it('throws SnapAPIError on failure', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 503, }); await expect(snap.health()).rejects.toMatchObject({ name: 'SnapAPIError', status: 503, detail: 'Health check failed', }); }); }); describe('SnapAPIError', () => { it('creates proper error with status and detail', () => { const error = new SnapAPIError(400, 'Bad Request'); expect(error.name).toBe('SnapAPIError'); expect(error.status).toBe(400); expect(error.detail).toBe('Bad Request'); expect(error.message).toBe('SnapAPI error 400: Bad Request'); expect(error).toBeInstanceOf(Error); expect(error).toBeInstanceOf(SnapAPIError); }); }); });