/** * gstack-pr-shots (lib/pr-shots.ts): change-centred hunks, the −/+ gutter, * escaping, section extraction at a base commit, and the refusals that keep * secrets, binaries, option-like refs and outside files off a public PR image. * Rendering itself is covered by the no-browser path (SHOT_PNG: unavailable). */ import { afterEach, describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { buildPage, cleanText, diffLines, hunks, pathSides, resolveCommit } from '../lib/pr-shots'; import { wtreeFingerprint } from '../lib/script-invocation'; const ROOT = path.resolve(import.meta.dir, '..'); const dirs: string[] = []; const tmp = (prefix: string) => { const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); dirs.push(d); return d; }; afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); }); const GIT_ENV = { ...process.env, GIT_AUTHOR_NAME: 'Fixture', GIT_AUTHOR_EMAIL: 'fixture@example.test', GIT_COMMITTER_NAME: 'Fixture', GIT_COMMITTER_EMAIL: 'fixture@example.test' }; function repo(files: Record): { dir: string; base: string } { const dir = tmp('shots-repo-'); const git = (...a: string[]) => spawnSync('git', a, { cwd: dir, encoding: 'utf8', env: GIT_ENV, timeout: 30_000 }); git('init', '-q', '-b', 'main'); for (const [f, c] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(dir, f)), { recursive: true }); fs.writeFileSync(path.join(dir, f), c); } git('add', '-A'); git('commit', '-q', '-m', 'base'); return { dir, base: git('rev-parse', 'HEAD').stdout.trim() }; } describe('diff and hunks', () => { test('a change at line 240 of 300 stays visible; gaps say how much they hide', () => { const a = Array.from({ length: 300 }, (_, i) => `line ${i + 1}`); const b = [...a]; b[239] = 'line 240 CHANGED'; const ops = diffLines(a, b); if (!Array.isArray(ops)) throw new Error(ops.message); const hs = hunks(ops); expect(hs.length).toBe(1); expect(hs[0]!.gapBefore).toBe(236); expect(hs[0]!.ops.map(o => o.t + o.line)).toContain('+line 240 CHANGED'); const page = buildPage({ before: { text: a.join('\n'), label: 'Before' }, after: { text: b.join('\n'), label: 'After' }, title: 't', caption: 'c', kind: 'prompt' }); if ('code' in page) throw new Error(page.message); expect(page.html).toContain('⋯ 236 unchanged lines'); expect(page.html).toContain('⋯ 57 unchanged lines'); expect(page.html).toContain('+line 240 CHANGED'); expect(page.html).toContain('−line 240'); expect(page.text).toContain('+line 240 CHANGED'); expect(page.changed).toBe(2); }); test('HTML is escaped, the page has a strict CSP, identical sides are refused', () => { const page = buildPage({ before: { text: 'a', label: 'B' }, after: { text: '', label: 'A' }, title: '', caption: '"c"', kind: 'terminal' }); if ('code' in page) throw new Error(page.message); expect(page.html).not.toContain('