Files
gstack/test/pr-shots.test.ts
Garry Tanandcapy-ai[bot] 128dd06f09 v1.91.303.0 fix(browse): pr-shots no longer edits the tracked .gitignore mid-/ship (#3560) (#3562)
* fix(browse): record .gstack/ in info/exclude, never the tracked .gitignore (#3560)

The browse daemon's state-dir setup appended .gstack/ to the worktree's .gitignore when the repository tracks files under .gstack/, so gstack-pr-shots changed the tree after pregate and /ship's receipt refused with PREGATE_STALE. The ignore line now goes to the repository's info/exclude (git rev-parse --git-path), which is outside the tree.

Fixes #3560

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): pr-shots no longer edits .gitignore (#3560)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(pr-shots): clear BROWSE_STATE_FILE so the #3560 case runs under the shard runner

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-29 02:20:13 +00:00

167 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, string>): { 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('<span class="m">+</span><span class="t">line 240 CHANGED</span>');
expect(page.html).toContain('<span class="m">−</span><span class="t">line 240</span>');
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: '<script>x</script>', label: 'A' }, title: '<t>', caption: '"c"', kind: 'terminal' });
if ('code' in page) throw new Error(page.message);
expect(page.html).not.toContain('<script>x');
expect(page.html).toContain('&lt;script&gt;x&lt;/script&gt;');
expect(page.html).toContain("default-src 'none'");
const same = buildPage({ before: { text: 'x', label: 'B' }, after: { text: 'x', label: 'A' }, title: 't', caption: 'c', kind: 'prompt' });
expect('code' in same && same.code).toBe('PR_SHOTS_IDENTICAL');
});
test('a whole rewrite is marked once per pane instead of highlighting every line', () => {
const page = buildPage({ before: { text: 'old one\nold two', label: 'B' }, after: { text: 'new one\nnew two\nnew three', label: 'A' }, title: 't', caption: 'c', kind: 'prompt' });
if ('code' in page) throw new Error(page.message);
expect(page.html).toContain('rewritten entirely: every line is removed (−)');
expect(page.html).toContain('rewritten entirely: every line is new (+)');
expect(page.html).not.toContain('class="row add"');
expect(page.html).toContain('<span class="m">+</span><span class="t">new three</span>');
});
test('the text version uses a fence longer than any tilde run inside', () => {
const page = buildPage({ before: { text: '~~~\nold', label: 'B' }, after: { text: '~~~\nnew', label: 'A' }, title: 't', caption: 'c', kind: 'prompt' });
if ('code' in page) throw new Error(page.message);
expect(page.text).toContain('~~~~diff');
});
test('ANSI is stripped; binary and oversize input are refused', () => {
expect(cleanText('\x1b[31mred\x1b[0m\r\n', 'x')).toBe('red\n');
expect(cleanText(Buffer.from([65, 0, 66]), 'x')).toMatchObject({ code: 'PR_SHOTS_INPUT_REFUSED' });
expect(cleanText('x'.repeat(600 * 1024), 'x')).toMatchObject({ code: 'PR_SHOTS_INPUT_REFUSED' });
});
});
describe('path mode', () => {
test('one heading section at the base vs the working tree; a new section has an absent before', () => {
const { dir, base } = repo({ 'SKILL.md': '# S\n## Step 1\nold text\n## Step 2\nsame\n' });
fs.writeFileSync(path.join(dir, 'SKILL.md'), '# S\n## Step 1\nnew text\n## Step 2\nsame\n## Step 3\nadded\n');
const s = pathSides(dir, 'SKILL.md', base, 'Step 1');
if ('code' in s) throw new Error(s.message);
expect(s.before.text).toBe('## Step 1\nold text');
expect(s.after.text).toBe('## Step 1\nnew text');
expect(s.before.label).toContain(`at ${base.slice(0, 7)} · source text`);
const added = pathSides(dir, 'SKILL.md', base, 'Step 3');
if ('code' in added) throw new Error(added.message);
expect(added.before.absent).toBe('section not present before');
expect(pathSides(dir, 'SKILL.md', base, 'Nope')).toMatchObject({ code: 'PR_SHOTS_INPUT_NOT_FOUND' });
expect(pathSides(dir, '../etc/passwd', base)).toMatchObject({ code: 'PR_SHOTS_INPUT_REFUSED' });
});
test('refs are resolved without option parsing', () => {
const { dir } = repo({ 'a.txt': 'a' });
expect(resolveCommit(dir, '--output=/tmp/x')).toMatchObject({ code: 'PR_SHOTS_INPUT_REFUSED' });
expect(resolveCommit(dir, 'no-such-ref')).toMatchObject({ code: 'PR_SHOTS_INPUT_NOT_FOUND' });
});
});
describe('gstack-pr-shots CLI', () => {
const cli = (args: string[], cwd: string, env: Record<string, string> = {}) => {
const r = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-pr-shots'), ...args], { cwd, encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_PROJECT_SLUG: 'acme', GSTACK_SKIP_ASIDE: '1', GSTACK_BROWSE_BIN: '/nonexistent/browse', ...env } });
return { status: r.status ?? -1, out: r.stdout ?? '', err: r.stderr ?? '' };
};
test('captures inside the repo render to HTML and text; no browser is reported, not failed', () => {
const { dir } = repo({ 'x.txt': 'x' });
fs.writeFileSync(path.join(dir, 'before.txt'), '$ gstack-approvals render\nNo approvals\n');
fs.writeFileSync(path.join(dir, 'after.txt'), '$ gstack-approvals render\n| Date | Asked |\n');
const state = tmp('shots-state-');
const r = cli(['--before', 'before.txt', '--after', 'after.txt', '--kind', 'terminal', '--caption', 'The record renders as a table'], dir, { GSTACK_STATE_ROOT: state });
expect(r.status).toBe(0);
expect(r.out).toMatch(/^SHOT_HTML: .*\/projects\/acme\/pr-shots\/Before-and-after\/shot\.html$/m);
expect(r.out).toMatch(/^SHOT_PNG: (unavailable \(|.*pair\.png engine=)/m);
const text = fs.readFileSync(r.out.match(/^SHOT_TEXT: (.+)$/m)![1]!, 'utf8');
expect(text).toStartWith('<details><summary>Before/after as text: The record renders as a table</summary>');
});
// #3560: pr-shots starts the browse daemon from the worktree; the daemon's state-dir setup
// appended .gstack/ to the tracked .gitignore in a repository that tracks .gstack/ config, so the
// tree changed after pregate and the /ship receipt refused with PREGATE_STALE. The stub browse
// runs the daemon's real state-dir setup (browse/src/config.ts), then fails like a browser-less host.
test.skipIf(process.platform === 'win32')('the working-tree fingerprint is unchanged after a run that starts browse (#3560)', () => {
const { dir } = repo({ '.gitignore': 'node_modules/\n.gstack/tmp/\n', '.gstack/pregate.json': '{}\n', 'doc.md': '# Doc\n\nold\n' });
fs.writeFileSync(path.join(dir, 'doc.md'), '# Doc\n\nnew\n');
const stubDir = tmp('shots-browse-');
fs.writeFileSync(path.join(stubDir, 'state-dir.ts'), `import { ensureStateDir, resolveConfig } from ${JSON.stringify(path.join(ROOT, 'browse/src/config.ts'))};\nensureStateDir(resolveConfig());\nprocess.exit(1);\n`);
const browse = path.join(stubDir, 'browse');
fs.writeFileSync(browse, `#!/usr/bin/env bash\nexec ${JSON.stringify(process.execPath)} --no-env-file --config=/dev/null ${JSON.stringify(path.join(stubDir, 'state-dir.ts'))}\n`, { mode: 0o755 });
const before = wtreeFingerprint({ cwd: dir });
expect('tree' in before).toBe(true);
const r = cli(['--path', 'doc.md', '--caption', 'c'], dir, { GSTACK_STATE_ROOT: tmp('shots-state-'), GSTACK_BROWSE_BIN: browse, BROWSE_STATE_FILE: '' });
expect(r.out).toMatch(/^SHOT_PNG: failed /m);
expect(fs.existsSync(path.join(dir, '.gstack', '.gitignore'))).toBe(true);
expect(fs.readFileSync(path.join(dir, '.gitignore'), 'utf8')).toBe('node_modules/\n.gstack/tmp/\n');
expect(wtreeFingerprint({ cwd: dir })).toEqual(before);
});
test('a secret in a capture is refused before anything is written, without echoing it', () => {
const { dir } = repo({ 'x.txt': 'x' });
const secret = 'ghp_' + 'b'.repeat(36);
fs.writeFileSync(path.join(dir, 'before.txt'), 'ok\n');
fs.writeFileSync(path.join(dir, 'after.txt'), `token ${secret}\n`);
const state = tmp('shots-state-');
const r = cli(['--before', 'before.txt', '--after', 'after.txt', '--caption', 'c'], dir, { GSTACK_STATE_ROOT: state });
expect(r.status).toBe(3);
expect(r.err).toContain('(PR_SHOTS_INPUT_REFUSED)');
expect(r.err).toMatch(/after matched the secret scanner: line 1 /);
expect(r.err + r.out).not.toContain(secret);
expect(fs.existsSync(path.join(state, 'projects'))).toBe(false);
});
test('a capture outside the repository and state root is refused; mixed modes are usage errors', () => {
const { dir } = repo({ 'x.txt': 'x' });
const outside = path.join(tmp('shots-out-'), 'secret.txt');
fs.writeFileSync(outside, 'x');
expect(cli(['--before', outside, '--after', 'x.txt', '--caption', 'c'], dir, { GSTACK_STATE_ROOT: tmp('shots-state-') }).err).toContain('outside the repository');
expect(cli(['--path', 'x.txt', '--before', 'x.txt', '--after', 'x.txt', '--caption', 'c'], dir).status).toBe(2);
expect(cli(['--path', 'x.txt'], dir).status).toBe(2);
});
});