import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; import { mkdtempSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { Cache } from '../src/services/cache.ts'; import { fetchToc, fetchChapters, type DriverFactory } from '../src/services/scraper.ts'; import type { NovelDefinition, AppConfig } from '../src/config/types.ts'; const DEF: NovelDefinition = { slug: 'test-novel', type: 'web-scraped', source: { url: 'https://example.com/novel' }, selectors: { toc: { scope: '#chapters', linkPattern: 'https://example\\.com/chapter/.*' }, content: { scope: '#content' }, }, stealth: { enabled: true, waitMs: 0, addons: [] }, download: { parallelism: 2, minContentBytes: 5 }, }; const APP_CONFIG: AppConfig = { server: { port: 8080 }, selenium: { remoteUrl: 'http://selenium:4444/wd/hub', browser: 'firefox' }, cache: { basePath: '/tmp' }, addons: { directory: './addons' }, }; let tmpDir: string; let cache: Cache; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'qa-scraper-test-')); cache = new Cache(tmpDir); }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); function buildMockPage(links: string[]): string { const anchors = links.map(href => `link`).join(''); return `
${anchors}
`; } function makeMockDriver(html: string): { driver: ReturnType extends Promise ? T : never; driverFactory: DriverFactory; quitSpy: ReturnType } { const quitSpy = mock(() => Promise.resolve()); const driver = { get: mock((_url: string) => Promise.resolve()), sleep: mock((_ms: number) => Promise.resolve()), executeScript: mock((_script: string) => Promise.resolve(html)), quit: quitSpy, } as unknown as Awaited>; const driverFactory: DriverFactory = () => Promise.resolve(driver); return { driver, driverFactory, quitSpy }; } describe('fetchToc', () => { test('returns cached TOC without opening a browser', async () => { const urls = ['https://example.com/chapter/1', 'https://example.com/chapter/2']; cache.saveToc(0, urls); const driverFactory = mock(() => Promise.reject(new Error('should not be called'))); const result = await fetchToc(DEF, 0, cache, APP_CONFIG, driverFactory as unknown as DriverFactory); expect(result).toEqual(urls); expect(driverFactory).not.toHaveBeenCalled(); }); test('fetches TOC via browser when cache is cold', async () => { const links = ['https://example.com/chapter/1', 'https://example.com/chapter/2']; const { driverFactory, quitSpy } = makeMockDriver(buildMockPage(links)); const result = await fetchToc(DEF, 1, cache, APP_CONFIG, driverFactory); expect(result).toEqual(links); expect(quitSpy).toHaveBeenCalledTimes(1); }); test('filters links by linkPattern', async () => { const html = ``; const { driverFactory } = makeMockDriver(html); const result = await fetchToc(DEF, 2, cache, APP_CONFIG, driverFactory); expect(result).toEqual(['https://example.com/chapter/1', 'https://example.com/chapter/3']); }); // Extended timeout: pRetry's default exponential backoff across 4 attempts can take ~7s test('quits driver even on failure', async () => { const { driverFactory, quitSpy } = makeMockDriver(''); await expect(fetchToc(DEF, 3, cache, APP_CONFIG, driverFactory)).rejects.toThrow(); expect(quitSpy).toHaveBeenCalledTimes(1); }, 15000); test('finds anchors nested at arbitrary depth within the scope element', async () => { // Simulates a TOC where links are buried inside ul > li > span > a // rather than being direct children of the scope container. const html = ``; const { driverFactory } = makeMockDriver(html); const result = await fetchToc(DEF, 5, cache, APP_CONFIG, driverFactory); expect(result).toEqual([ 'https://example.com/chapter/1', 'https://example.com/chapter/2', ]); }); test('saves fetched TOC to cache', async () => { const links = ['https://example.com/chapter/1']; const { driverFactory } = makeMockDriver(buildMockPage(links)); await fetchToc(DEF, 4, cache, APP_CONFIG, driverFactory); expect(cache.tryGetToc(4)).toEqual(links); }); }); describe('fetchChapters', () => { test('returns cached view without fetching', async () => { const content = JSON.stringify([{ body: 'Chapter content here.' }]); cache.saveChapters(10, [{ index: 0, content }]); const fetchSpy = spyOn(globalThis, 'fetch'); const view = await fetchChapters(DEF, 10, 0, ['https://example.com/chapter/1'], cache); expect(view.chapters).toHaveLength(1); expect(fetchSpy).not.toHaveBeenCalled(); fetchSpy.mockRestore(); }); test('downloads chapters and saves them', async () => { // mockImplementation (not mockResolvedValue) so each call gets a fresh Response — // reusing the same instance causes "Body already used" on retry reads. const spy = spyOn(globalThis, 'fetch').mockImplementation(() => Promise.resolve(new Response('
Chapter body text here.
', { status: 200 })), ); const view = await fetchChapters( DEF, 11, 0, ['https://example.com/chapter/1', 'https://example.com/chapter/2'], cache, ); expect(view.min).toBe(0); expect(view.max).toBe(1); expect(view.chapters).toHaveLength(2); expect(spy).toHaveBeenCalledTimes(2); spy.mockRestore(); }); test('stores error fragment when chapter content is too short', async () => { const spy = spyOn(globalThis, 'fetch').mockImplementation(() => Promise.resolve(new Response('
', { status: 200 })), ); const view = await fetchChapters(DEF, 12, 0, ['https://example.com/chapter/1'], cache); expect(view.chapters).toHaveLength(1); // The saved content should be the error fragment JSON const saved = cache.tryGetChapters(12, 0, 0); expect(saved).not.toBeNull(); spy.mockRestore(); }); });