import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { chromium, type Browser } from 'playwright'; import type { PostNode, ThreadSnapshot } from '../src/model.ts'; import { captureView, renderHtml, renderText } from '../src/render.ts'; import { ReadingStore } from '../src/store.ts'; const actor = 'did:plc:renderfixture'; const uri = `at://${actor}/app.bsky.feed.post/focus`; const baseNode: PostNode = { uri, availability: 'available', author: { did: actor, handle: 'reader.example', displayName: 'Reader' }, text: 'A small conversation.', createdAt: '2026-09-05T11:00:00.000Z', indexedAt: '2026-09-05T11:00:02.000Z', replies: [], repliesFetched: true, facets: [], media: [], quotes: [], cards: [], labels: [], unsupported: [], counts: {}, }; const baseSnapshot: ThreadSnapshot = { kind: 'thread', requested: uri, focusUri: uri, fetchedAt: '2026-09-05T12:00:00.000Z', source: { mode: 'public', service: 'https://public.api.bsky.app' }, nodes: [baseNode], ancestors: [], replyOrder: 'source', limits: { depth: 40, parentHeight: 40, maxNodes: 60 }, locallyTruncated: false, warnings: [], }; function storedView(snapshot: ThreadSnapshot) { const store = new ReadingStore(':memory:'); try { const position = store.position('render'); return store.commit('render', snapshot, position.revision); } finally { store.close(); } } let browser: Browser; beforeAll(async () => { browser = await chromium.launch(); }); afterAll(async () => { await browser?.close(); }); describe('the readable view preserves source without executing it', () => { test('prose, author, alt, and card markup remain text, never active elements', async () => { const injected = ''; const snapshot = structuredClone(baseSnapshot); snapshot.nodes[0].text = `🙂 héj 日本語\n${injected}\nSecond paragraph.`; snapshot.nodes[0].author!.displayName = '\">'; snapshot.nodes[0].media = [{ kind: 'image', alt: injected }]; snapshot.nodes[0].cards = [{ uri: 'javascript:globalThis.sourceRan=true', title: injected, description: injected }]; const view = storedView(snapshot); const original = JSON.stringify(view); expect(renderText(view)).toContain(snapshot.nodes[0].text!); const page = await browser.newPage(); try { await page.setContent(renderHtml(view)); expect(await page.locator('script, iframe, [onerror], [onload]').count()).toBe(0); expect(await page.locator('body').textContent()).toContain(snapshot.nodes[0].text!); expect(await page.evaluate(() => 'sourceRan' in globalThis)).toBe(false); expect(await page.locator('a[href^="javascript:"]').count()).toBe(0); expect(JSON.stringify(view)).toBe(original); } finally { await page.close(); } }); test('deep replies remain readable rather than losing width at each level', async () => { const snapshot = structuredClone(baseSnapshot); snapshot.nodes = Array.from({ length: 35 }, (_, index) => ({ ...structuredClone(baseNode), uri: `${uri}-${index}`, text: `Reply ${index}: readable original speech.`, parentUri: index > 0 ? `${uri}-${index - 1}` : undefined, rootUri: `${uri}-0`, replies: index < 34 ? [`${uri}-${index + 1}`] : [], })); snapshot.focusUri = snapshot.nodes[0].uri; const page = await browser.newPage({ viewport: { width: 1280, height: 960 } }); try { await page.setContent(renderHtml(storedView(snapshot))); const widths = await page.locator('article[data-panel-block]').evaluateAll(elements => elements.map(e => e.getBoundingClientRect().width)); expect(widths.length).toBe(35); expect(Math.min(...widths)).toBeGreaterThan(700); expect(Math.max(...widths) - Math.min(...widths)).toBeLessThan(10); for (const node of snapshot.nodes) expect(await page.locator('body').textContent()).toContain(node.text!); } finally { await page.close(); } }); test('unknown, empty, invalid and future source values remain distinct', () => { const snapshot = structuredClone(baseSnapshot); snapshot.nodes[0].text = ''; snapshot.nodes[0].createdAt = 'not-a-time'; snapshot.nodes[0].indexedAt = '2026-09-06T12:00:00Z'; snapshot.nodes[0].media = [{ kind: 'image' }, { kind: 'image', alt: '' }]; const text = renderText(storedView(snapshot)); expect(text).toContain('not-a-time'); expect(text.toLowerCase()).toContain('invalid'); expect(text.toLowerCase()).toMatch(/future by|after snapshot reference/); expect(text).toContain('Alt: ""'); expect(text).toContain(''); expect(text.toLowerCase()).not.toContain('just now'); }); test('quoted images keep ownership distinct from the surrounding post', async () => { const snapshot = structuredClone(baseSnapshot); const quotedUri = `${uri}-quote`; snapshot.nodes[0].quotes = [quotedUri]; snapshot.nodes[0].media = [{ kind: 'image', alt: 'The outer image' }]; snapshot.nodes.push({ ...structuredClone(baseNode), uri: quotedUri, text: 'The quoted voice', media: [{ kind: 'image', alt: 'The inner image' }] }); const view = storedView(snapshot); const mediaTargets = Object.values(view.refs).filter(target => target.kind === 'media'); expect(mediaTargets.map(target => target.ownerUri)).toEqual([uri, quotedUri]); const page = await browser.newPage(); try { await page.setContent(renderHtml(view)); const body = await page.locator('body').textContent(); expect(body).toContain('The quoted voice'); expect(body).toContain('The outer image'); expect(body).toContain('The inner image'); } finally { await page.close(); } }); test('actual capture blocks a non-CDN image and writes paired immutable outputs', async () => { let outsideRequests = 0; const server = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch() { outsideRequests += 1; return new Response('outside image origin'); } }); const directory = await mkdtemp(join(tmpdir(), 'perch-render-')); const snapshot = structuredClone(baseSnapshot); snapshot.nodes[0].media = [{ kind: 'image', url: `http://127.0.0.1:${server.port}/image`, alt: 'Untrusted remote image' }]; const view = storedView(snapshot); const original = JSON.stringify(view); try { const files = await captureView(view, { outputDir: directory }); expect(outsideRequests).toBe(0); expect(JSON.parse(await readFile(files.json, 'utf8'))).toEqual(view); expect(await readFile(files.text, 'utf8')).toBe(renderText(view)); const html = await readFile(files.html, 'utf8'); const page = await browser.newPage(); try { await page.setContent(html); expect(await page.getByText('Preview unavailable during capture', { exact: true }).isVisible()).toBe(true); } finally { await page.close(); } expect(outsideRequests).toBe(0); expect(html).toContain('Untrusted remote image'); expect(files.images.length).toBeGreaterThanOrEqual(2); for (const file of files.images) { const png = await readFile(file); expect(png.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); expect(png.readUInt32BE(16)).toBeGreaterThan(600); expect(png.readUInt32BE(20)).toBeGreaterThan(100); } expect(JSON.stringify(view)).toBe(original); } finally { server.stop(true); await rm(directory, { recursive: true, force: true }); } }, 30_000); test('an oversized overview cannot displace or crop the unchanged reading card', async () => { const directory = await mkdtemp(join(tmpdir(), 'perch-overview-')); const snapshot = structuredClone(baseSnapshot); try { const ordinary = await captureView(storedView(snapshot), { outputDir: join(directory, 'ordinary') }); snapshot.warnings = Array.from({ length: 50 }, (_, i) => `Warning ${i}: source metadata was unavailable; the reading card is unchanged.`); const expanded = await captureView(storedView(snapshot), { outputDir: join(directory, 'expanded') }); const ordinaryPanels = ordinary.images.filter(path => path.includes('-panel-')); const expandedPanels = expanded.images.filter(path => path.includes('-panel-')); expect(ordinaryPanels.length).toBe(1); expect(expandedPanels.length).toBe(1); // Compare the actual card pixels, allowing browser subpixel scroll rounding. const page = await browser.newPage(); try { const difference = await page.evaluate(async ({ before, after }) => { const pixels = await Promise.all([before, after].map(async data => { const image = new Image(); image.src = `data:image/png;base64,${data}`; await image.decode(); const canvas = document.createElement('canvas'); canvas.width = image.width; canvas.height = image.height; const context = canvas.getContext('2d')!; context.drawImage(image, 0, 0); return { width: image.width, height: image.height, data: context.getImageData(0, 0, image.width, image.height).data }; })); const [a, b] = pixels; if (a.width !== b.width || Math.abs(a.height - b.height) > 2) return 1; let best = 1; for (let shift = -2; shift <= 2; shift++) { let total = 0; let samples = 0; for (let y = 2; y < Math.min(a.height, b.height) - 2; y++) { for (let x = 0; x < a.width; x++) { for (let channel = 0; channel < 3; channel++) { total += Math.abs(a.data[(y * a.width + x) * 4 + channel] - b.data[((y + shift) * b.width + x) * 4 + channel]); samples++; } } } best = Math.min(best, total / samples / 255); } return best; }, { before: (await readFile(ordinaryPanels[0])).toString('base64'), after: (await readFile(expandedPanels[0])).toString('base64') }); expect(difference).toBeLessThan(0.005); } finally { await page.close(); } } finally { await rm(directory, { recursive: true, force: true }); } }, 30_000); });