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);
});