diff --git a/browse/test/media-extract-unit.test.ts b/browse/test/media-extract-unit.test.ts new file mode 100644 index 000000000..9bd208656 --- /dev/null +++ b/browse/test/media-extract-unit.test.ts @@ -0,0 +1,294 @@ +/** + * Unit tests for browse/src/media-extract.ts — the media-discovery logic + * shared by the `media` and `scrape` commands. + * + * All of extractMedia's logic lives inside the page.evaluate() callback. + * Playwright serializes that callback to the browser, but the function itself + * is pure over the DOM globals it touches (`document`, `getComputedStyle`), + * so instead of exporting internals or launching a browser these tests pass a + * fake target whose evaluate() invokes the real callback in-process against a + * minimal mock DOM. No product code was modified. + * + * Globals are installed/restored inside each run (never at module scope) so + * nothing leaks to sibling test files sharing this shard process. + */ +import { describe, test, expect } from 'bun:test'; +import { extractMedia, type MediaResult } from '../src/media-extract'; + +type Dom = Record; + +/** querySelector/querySelectorAll over a selector → elements map. */ +function queryable(map: Dom) { + return { + querySelectorAll: (sel: string) => map[sel] ?? [], + querySelector: (sel: string) => (map[sel] ?? [])[0] ?? null, + }; +} + +const VISIBLE_RECT = { width: 100, height: 50, bottom: 400, right: 300 }; +const HIDDEN_RECT = { width: 0, height: 0, bottom: 0, right: 0 }; + +function imgEl(overrides: Record = {}, attrs: Record = {}, rect = VISIBLE_RECT) { + return { + src: '', srcset: '', currentSrc: '', alt: '', + width: 0, height: 0, naturalWidth: 0, naturalHeight: 0, loading: '', + getAttribute: (name: string) => attrs[name] ?? null, + getBoundingClientRect: () => rect, + ...overrides, + }; +} + +function videoEl(overrides: Record = {}, sources: Array<{ src?: string; type?: string }> = []) { + return { + src: '', currentSrc: '', poster: '', + videoWidth: 0, width: 0, videoHeight: 0, height: 0, duration: 0, + querySelectorAll: (sel: string) => (sel === 'source' ? sources : []), + ...overrides, + }; +} + +function audioEl(overrides: Record = {}, source: { src?: string; type?: string } | null = null) { + return { + src: '', currentSrc: '', duration: 0, + querySelector: (sel: string) => (sel === 'source' ? source : null), + ...overrides, + }; +} + +/** An element visible only to the background-image pass (`*` + getComputedStyle). */ +function bgEl(backgroundImage: string, opts: { tagName?: string; id?: string; className?: unknown } = {}) { + return { + tagName: opts.tagName ?? 'DIV', + id: opts.id ?? '', + className: opts.className ?? '', + __backgroundImage: backgroundImage, + }; +} + +/** + * Run the REAL extractMedia against a mock document. The fake target's + * evaluate() calls the callback with its argument, exactly as Playwright does + * in the browser — resolving `document`/`getComputedStyle` to our shims. + */ +async function extract( + dom: Dom, + options?: Parameters[1], +): Promise { + const g = globalThis as any; + const savedDocument = g.document; + const savedGcs = g.getComputedStyle; + g.document = queryable(dom); + g.getComputedStyle = (el: any) => ({ backgroundImage: el.__backgroundImage ?? 'none' }); + try { + const target = { evaluate: (fn: any, arg: any) => Promise.resolve(fn(arg)) } as any; + return await extractMedia(target, options); + } finally { + g.document = savedDocument; + g.getComputedStyle = savedGcs; + } +} + +describe('extractMedia: images', () => { + test('collects attributes, dimensions, and the lazy-load data-src fallback chain', async () => { + const result = await extract({ + img: [ + imgEl({ + src: 'https://cdn.example.com/hero.jpg', + srcset: 'hero-2x.jpg 2x', + currentSrc: 'https://cdn.example.com/hero-2x.jpg', + alt: 'Hero', + width: 640, height: 480, naturalWidth: 1280, naturalHeight: 960, + loading: 'lazy', + }, { 'data-lazy-src': 'lazy.jpg' }), + ], + }); + expect(result.images).toHaveLength(1); + const img = result.images[0]; + expect(img.index).toBe(0); + expect(img.src).toBe('https://cdn.example.com/hero.jpg'); + expect(img.srcset).toBe('hero-2x.jpg 2x'); + expect(img.currentSrc).toBe('https://cdn.example.com/hero-2x.jpg'); + expect(img.alt).toBe('Hero'); + expect(img.naturalWidth).toBe(1280); + expect(img.loading).toBe('lazy'); + // No data-src → falls through to data-lazy-src. + expect(img.dataSrc).toBe('lazy.jpg'); + expect(img.visible).toBe(true); + expect(result.total).toBe(1); + }); + + test('data-src wins over the later fallbacks, data-original is last', async () => { + const first = await extract({ img: [imgEl({}, { 'data-src': 'a.jpg', 'data-lazy-src': 'b.jpg', 'data-original': 'c.jpg' })] }); + expect(first.images[0].dataSrc).toBe('a.jpg'); + const last = await extract({ img: [imgEl({}, { 'data-original': 'c.jpg' })] }); + expect(last.images[0].dataSrc).toBe('c.jpg'); + const none = await extract({ img: [imgEl()] }); + expect(none.images[0].dataSrc).toBe(''); + }); + + test('a zero-size or fully offscreen rect marks the image not visible', async () => { + const result = await extract({ + img: [ + imgEl({}, {}, HIDDEN_RECT), + // Above/left of the viewport: bottom and right are negative. + imgEl({}, {}, { width: 10, height: 10, bottom: -5, right: -5 }), + imgEl({}, {}, VISIBLE_RECT), + ], + }); + expect(result.images.map(index => index.visible)).toEqual([false, false, true]); + }); +}); + +describe('extractMedia: videos', () => { + test('detects HLS from either the mime type or an .m3u8 source URL', async () => { + const result = await extract({ + video: [ + videoEl({}, [{ src: 'https://v.example.com/stream.m3u8', type: '' }]), + videoEl({}, [{ src: 'https://v.example.com/stream', type: 'application/x-mpegURL' }]), + videoEl({ src: 'plain.mp4' }, [{ src: 'plain.mp4', type: 'video/mp4' }]), + ], + }); + expect(result.videos.map(v => v.isHLS)).toEqual([true, true, false]); + expect(result.videos[2].type).toBe('video/mp4'); + }); + + test('detects DASH from either the mime type or an .mpd source URL', async () => { + const result = await extract({ + video: [ + videoEl({}, [{ src: 'https://v.example.com/manifest.mpd', type: '' }]), + videoEl({}, [{ src: 'https://v.example.com/manifest', type: 'application/dash+xml' }]), + ], + }); + expect(result.videos.map(v => v.isDASH)).toEqual([true, true]); + }); + + test('an Infinity duration (live stream) is reported as 0; intrinsic size beats attributes', async () => { + const result = await extract({ + video: [videoEl({ duration: Infinity, videoWidth: 1920, width: 640, videoHeight: 1080, height: 360 })], + }); + expect(result.videos[0].duration).toBe(0); + expect(result.videos[0].width).toBe(1920); + expect(result.videos[0].height).toBe(1080); + }); + + test('collects every child with src and type', async () => { + const sources = [ + { src: 'a.webm', type: 'video/webm' }, + { src: 'a.mp4', type: 'video/mp4' }, + ]; + const result = await extract({ video: [videoEl({ poster: 'poster.jpg' }, sources)] }); + expect(result.videos[0].sources).toEqual(sources); + expect(result.videos[0].poster).toBe('poster.jpg'); + expect(result.videos[0].type).toBe('video/webm'); // first source's type + }); +}); + +describe('extractMedia: audio', () => { + test('falls back to the child when the element has no src, NaN duration → 0', async () => { + const result = await extract({ + audio: [audioEl({ duration: NaN }, { src: 'track.ogg', type: 'audio/ogg' })], + }); + expect(result.audio[0].src).toBe('track.ogg'); + expect(result.audio[0].type).toBe('audio/ogg'); + expect(result.audio[0].duration).toBe(0); + }); + + test('element src wins over the source child', async () => { + const result = await extract({ + audio: [audioEl({ src: 'direct.mp3', duration: 12.5 }, { src: 'child.ogg', type: 'audio/ogg' })], + }); + expect(result.audio[0].src).toBe('direct.mp3'); + expect(result.audio[0].duration).toBe(12.5); + }); +}); + +describe('extractMedia: CSS background images', () => { + test('parses url(...) in quoted and unquoted forms, skipping none and data: URIs', async () => { + const result = await extract({ + '*': [ + bgEl('url("https://cdn.example.com/bg.png")'), + bgEl("url('https://cdn.example.com/bg2.png')"), + bgEl('url(https://cdn.example.com/bg3.png)'), + bgEl('none'), + bgEl('url(data:image/png;base64,AAAA)'), + ], + }); + expect(result.backgroundImages.map(b => b.url)).toEqual([ + 'https://cdn.example.com/bg.png', + 'https://cdn.example.com/bg2.png', + 'https://cdn.example.com/bg3.png', + ]); + expect(result.backgroundImages.map(b => b.index)).toEqual([0, 1, 2]); + }); + + test('builds a tag#id.class selector; a non-string className (SVG) contributes no class part', async () => { + const result = await extract({ + '*': [ + bgEl('url(a.png)', { tagName: 'SECTION', id: 'hero', className: ' banner large ' }), + bgEl('url(b.png)', { tagName: 'SVG', className: { baseVal: 'svg-class' } }), + ], + }); + expect(result.backgroundImages[0].selector).toBe('section#hero.banner.large'); + expect(result.backgroundImages[0].element).toBe('section'); + expect(result.backgroundImages[1].selector).toBe('svg'); + }); + + test('caps background-image extraction at 500 elements', async () => { + const many = Array.from({ length: 520 }, (_, i) => bgEl(`url(bg-${i}.png)`)); + const result = await extract({ '*': many }); + expect(result.backgroundImages).toHaveLength(500); + expect(result.backgroundImages[499].url).toBe('bg-499.png'); + expect(result.total).toBe(500); + }); +}); + +describe('extractMedia: filter and scope options', () => { + const FULL_DOM: Dom = { + img: [imgEl({ src: 'i.png' })], + video: [videoEl({ src: 'v.mp4' })], + audio: [audioEl({ src: 'a.mp3' })], + '*': [bgEl('url(bg.png)')], + }; + + test('no filter returns every category and total sums them', async () => { + const result = await extract(FULL_DOM); + expect(result.images).toHaveLength(1); + expect(result.videos).toHaveLength(1); + expect(result.audio).toHaveLength(1); + expect(result.backgroundImages).toHaveLength(1); + expect(result.total).toBe(4); + }); + + test("filter: 'videos' excludes images, audio, and background images", async () => { + const result = await extract(FULL_DOM, { filter: 'videos' }); + expect(result.videos).toHaveLength(1); + expect(result.images).toEqual([]); + expect(result.audio).toEqual([]); + expect(result.backgroundImages).toEqual([]); + expect(result.total).toBe(1); + }); + + test("filter: 'images' includes background images (they are image media)", async () => { + const result = await extract(FULL_DOM, { filter: 'images' }); + expect(result.images).toHaveLength(1); + expect(result.backgroundImages).toHaveLength(1); + expect(result.videos).toEqual([]); + expect(result.audio).toEqual([]); + expect(result.total).toBe(2); + }); + + test('a selector scopes extraction to the matching subtree', async () => { + const scoped = { + ...queryable({ img: [imgEl({ src: 'scoped.png' })] }), + }; + const result = await extract({ img: [imgEl({ src: 'global.png' })], '#gallery': [scoped] }, { selector: '#gallery' }); + expect(result.images).toHaveLength(1); + expect(result.images[0].src).toBe('scoped.png'); + }); + + test('a selector matching nothing falls back to the whole document', async () => { + const result = await extract({ img: [imgEl({ src: 'global.png' })] }, { selector: '#missing' }); + expect(result.images).toHaveLength(1); + expect(result.images[0].src).toBe('global.png'); + }); +}); diff --git a/browse/test/session-cookie-store.test.ts b/browse/test/session-cookie-store.test.ts new file mode 100644 index 000000000..a9e90f8e9 --- /dev/null +++ b/browse/test/session-cookie-store.test.ts @@ -0,0 +1,159 @@ +/** + * Unit tests for browse/src/session-cookie-store.ts — the factory behind + * pty-session-cookie.ts and sse-session-cookie.ts. + * + * sse-session-cookie.test.ts pins the SSE instantiation (flags, entropy, + * cross-endpoint isolation). This file tests the FACTORY's own contract with + * custom options the instantiations never vary: the cookieName knob in + * extract/buildSetCookie, the ttlMs knob in expiry and Max-Age, the + * maxSessions hard cap, and isolation between independently created stores. + * + * The store is purely in-memory (a Map keyed by token) — there is no on-disk + * state, so no temp dirs or permission cases apply. + */ +import { describe, test, expect } from 'bun:test'; +import { createSessionCookieStore } from '../src/session-cookie-store'; + +const NAME = 'gstack_test_session'; + +function makeStore(opts: Partial[0]> = {}) { + return createSessionCookieStore({ cookieName: NAME, ttlMs: 60_000, ...opts }); +} + +function requestWithCookies(cookieHeader: string | null): Request { + return new Request('http://127.0.0.1/sse', { + headers: cookieHeader === null ? {} : { cookie: cookieHeader }, + }); +} + +describe('session-cookie-store: mint + validate round-trip', () => { + test('a minted token validates until revoked', () => { + const store = makeStore(); + const { token, expiresAt } = store.mint(); + expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/); // 32 bytes base64url, no padding + expect(expiresAt).toBeGreaterThan(Date.now()); + expect(expiresAt).toBeLessThanOrEqual(Date.now() + 60_000); + expect(store.validate(token)).toBe(true); + store.revoke(token); + expect(store.validate(token)).toBe(false); + }); + + test('unknown, null, undefined, and empty tokens never validate', () => { + const store = makeStore(); + store.mint(); + expect(store.validate('forged-token')).toBe(false); + expect(store.validate(null)).toBe(false); + expect(store.validate(undefined)).toBe(false); + expect(store.validate('')).toBe(false); + }); + + test('revoke of an unknown/null token is a no-op, not an error', () => { + const store = makeStore(); + const { token } = store.mint(); + expect(() => store.revoke('never-minted')).not.toThrow(); + expect(() => store.revoke(null)).not.toThrow(); + expect(() => store.revoke(undefined)).not.toThrow(); + expect(store.validate(token)).toBe(true); // untouched + }); + + test('a token expires after ttlMs and validate deletes it', async () => { + const store = makeStore({ ttlMs: 5 }); + const { token, expiresAt } = store.mint(); + expect(expiresAt - Date.now()).toBeLessThanOrEqual(5); + await new Promise(resolve => setTimeout(resolve, 25)); + expect(store.validate(token)).toBe(false); + expect(store.validate(token)).toBe(false); // still gone after deletion + }); + + test('two stores are fully isolated — a token minted in one never validates in the other', () => { + const a = makeStore(); + const b = makeStore(); + const { token } = a.mint(); + expect(b.validate(token)).toBe(false); + expect(a.validate(token)).toBe(true); + }); + + test('__reset clears every session', () => { + const store = makeStore(); + const first = store.mint().token; + const second = store.mint().token; + store.__reset(); + expect(store.validate(first)).toBe(false); + expect(store.validate(second)).toBe(false); + }); +}); + +describe('session-cookie-store: maxSessions hard cap', () => { + test('minting past the cap evicts the oldest sessions', () => { + const store = makeStore({ maxSessions: 3 }); + const tokens = Array.from({ length: 5 }, () => store.mint().token); + // Insertion order eviction: the two oldest are gone, the newest three live. + expect(store.validate(tokens[0])).toBe(false); + expect(store.validate(tokens[1])).toBe(false); + expect(store.validate(tokens[2])).toBe(true); + expect(store.validate(tokens[3])).toBe(true); + expect(store.validate(tokens[4])).toBe(true); + }); +}); + +describe('session-cookie-store: extract (cookie header parsing)', () => { + test('finds the configured cookie among others, with surrounding whitespace', () => { + const store = makeStore(); + const req = requestWithCookies(`other=1; ${NAME}=tok-value ; trailing=2`); + // Each `name=value` part is trimmed as a whole before splitting. + expect(store.extract(req)).toBe('tok-value'); + }); + + test('a cookie value containing = survives intact', () => { + const store = makeStore(); + const req = requestWithCookies(`${NAME}=abc=def==`); + expect(store.extract(req)).toBe('abc=def=='); + }); + + test('only the EXACT cookie name matches — no prefix/suffix confusion', () => { + const store = makeStore(); + expect(store.extract(requestWithCookies(`x${NAME}=evil`))).toBeNull(); + expect(store.extract(requestWithCookies(`${NAME}x=evil`))).toBeNull(); + }); + + test('missing header and empty value both yield null', () => { + const store = makeStore(); + expect(store.extract(requestWithCookies(null))).toBeNull(); + expect(store.extract(requestWithCookies(`${NAME}=`))).toBeNull(); + expect(store.extract(requestWithCookies('unrelated=1'))).toBeNull(); + }); + + test('two stores with different cookie names read different cookies from one header', () => { + const ptyLike = createSessionCookieStore({ cookieName: 'pty_session', ttlMs: 1000 }); + const sseLike = createSessionCookieStore({ cookieName: 'sse_session', ttlMs: 1000 }); + const req = requestWithCookies('pty_session=pty-tok; sse_session=sse-tok'); + expect(ptyLike.extract(req)).toBe('pty-tok'); + expect(sseLike.extract(req)).toBe('sse-tok'); + }); +}); + +describe('session-cookie-store: buildSetCookie', () => { + test('emits the exact security flags with Max-Age derived from ttlMs', () => { + const store = makeStore({ ttlMs: 90_500 }); // floor(90.5s) = 90 + expect(store.buildSetCookie('tok123')).toBe( + `${NAME}=tok123; HttpOnly; SameSite=Strict; Path=/; Max-Age=90`, + ); + }); + + test('never emits Secure — the daemon serves plain HTTP on loopback', () => { + const store = makeStore(); + expect(store.buildSetCookie('t')).not.toContain('Secure'); + }); + + test('a minted token round-trips: Set-Cookie → request header → extract → validate', () => { + const store = makeStore(); + const { token } = store.mint(); + const setCookie = store.buildSetCookie(token); + // The browser echoes back only the name=value pair. + const pair = setCookie.split(';')[0]; + const req = requestWithCookies(pair); + const extracted = store.extract(req); + expect(extracted).toBe(token); + expect(store.validate(extracted)).toBe(true); + }); +}); diff --git a/test/code-intelligence-cli.test.ts b/test/code-intelligence-cli.test.ts new file mode 100644 index 000000000..ff1523291 --- /dev/null +++ b/test/code-intelligence-cli.test.ts @@ -0,0 +1,205 @@ +/** + * bin/gstack-code-intelligence — CLI surface smoke tests. + * + * lib/code-intelligence/* is covered by test/code-intelligence.test.ts, which + * also drives the CLI's `index` and `search` consent/policy refusal paths. + * This file covers the argument-handling surface those tests skip: usage on + * bad/missing subcommands, `select` and `consent` validation + state writes, + * and the `suggest` offer gate — all hermetic under a mkdtemp GSTACK_HOME + * (the selection store lives at $GSTACK_HOME/code-intelligence.json), and all + * on paths that never call detectAvailable(), so nothing probes providers or + * the network. + * + * Note: the CLI has no `--help` flag — every unrecognized action (including + * `--help`) routes to the usage message on stderr with exit 1. Pinned below. + */ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { runBin } from './helpers/run-bin'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const CLI = path.join(ROOT, 'bin', 'gstack-code-intelligence'); + +let home: string; +let workDir: string; + +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-cli-home-')); + workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-cli-work-')); +}); + +afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(workDir, { recursive: true, force: true }); +}); + +function runCli(...args: string[]) { + return runBin('bun', [CLI, ...args], { cwd: workDir, gstackHome: home, home }); +} + +function readStore(): { provider: string | null; consents: Record; declined: boolean } { + return JSON.parse(fs.readFileSync(path.join(home, 'code-intelligence.json'), 'utf-8')); +} + +describe('gstack-code-intelligence: usage surface', () => { + test('no arguments: usage on stderr, exit 1', () => { + const result = runCli(); + expect(result.status).toBe(1); + expect(result.stderr).toContain('gstack-code-intelligence:'); + expect(result.stderr).toContain('Usage:'); + expect(result.stderr).toContain('select '); + expect(result.stdout).toBe(''); + }); + + test('unknown subcommand: usage on stderr, exit 1', () => { + const result = runCli('frobnicate'); + expect(result.status).toBe(1); + expect(result.stderr).toContain('Usage:'); + }); + + test('--help has no exit-0 handler — it routes to the usage failure (current behavior)', () => { + const result = runCli('--help'); + expect(result.status).toBe(1); + expect(result.stderr).toContain('Usage:'); + }); +}); + +describe('gstack-code-intelligence: select', () => { + test('invalid provider is rejected with the select usage line', () => { + const result = runCli('select', 'bogus-provider'); + expect(result.status).toBe(1); + expect(result.stderr).toContain('Usage: select '); + expect(fs.existsSync(path.join(home, 'code-intelligence.json'))).toBe(false); + }); + + test('select with no argument is rejected the same way', () => { + const result = runCli('select'); + expect(result.status).toBe(1); + expect(result.stderr).toContain('Usage: select '); + }); + + test('select none records the decline so the offer is never repeated', () => { + const result = runCli('select', 'none'); + expect(result.status).toBe(0); + expect(result.stdout).toContain('declined'); + expect(result.stdout).toContain('will not ask again'); + const store = readStore(); + expect(store.provider).toBeNull(); + expect(store.declined).toBe(true); + }); + + test('selecting the local provider persists it without an off-machine warning', () => { + const result = runCli('select', 'graphify'); + expect(result.status).toBe(0); + expect(result.stdout).toContain('selected Graphify.'); + expect(result.stdout).not.toContain('off this machine'); + const store = readStore(); + expect(store.provider).toBe('graphify'); + expect(store.declined).toBe(false); + }); + + test('selecting a non-local provider warns that content leaves the machine', () => { + const result = runCli('select', 'gbrain'); + expect(result.status).toBe(0); + expect(result.stdout).toContain('selected GBrain.'); + expect(result.stdout).toContain('off this machine'); + expect(readStore().provider).toBe('gbrain'); + }); +}); + +describe('gstack-code-intelligence: consent', () => { + test('the yes/no value is required — a bare path records NOTHING', () => { + const result = runCli('consent', workDir); + expect(result.status).toBe(1); + expect(result.stderr).toContain('never assumed'); + expect(fs.existsSync(path.join(home, 'code-intelligence.json'))).toBe(false); + }); + + test('an unknown value records NOTHING', () => { + const result = runCli('consent', workDir, 'maybe'); + expect(result.status).toBe(1); + expect(result.stderr).toContain('never assumed'); + expect(fs.existsSync(path.join(home, 'code-intelligence.json'))).toBe(false); + }); + + test('consent yes persists true for the resolved repo path', () => { + const result = runCli('consent', workDir, 'yes'); + expect(result.status).toBe(0); + expect(result.stdout).toContain('indexing consent recorded'); + expect(readStore().consents[fs.realpathSync(workDir)] ?? readStore().consents[workDir]).toBe(true); + }); + + test('consent no persists an explicit DENIED — a "no" is a durable answer too', () => { + const result = runCli('consent', workDir, 'no'); + expect(result.status).toBe(0); + expect(result.stdout).toContain('DENIED'); + expect(readStore().consents[fs.realpathSync(workDir)] ?? readStore().consents[workDir]).toBe(false); + }); + + test('consent with no path defaults to the cwd', () => { + const result = runCli('consent', 'yes'); + expect(result.status).toBe(0); + const consents = readStore().consents; + const keys = Object.keys(consents); + expect(keys.length).toBe(1); + // resolve(cwd) — the child's cwd is workDir (possibly via a symlinked tmp). + expect([workDir, fs.realpathSync(workDir)]).toContain(keys[0]); + expect(consents[keys[0]]).toBe(true); + }); +}); + +describe('gstack-code-intelligence: suggest (offer gate)', () => { + test('a non-repo directory never triggers the offer (--json)', () => { + const result = runCli('suggest', workDir, '--json'); + expect(result.status).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed.offer).toBe(false); + expect(parsed.reason).toBe('not-a-repo'); + expect(parsed.fileCount).toBeNull(); + expect([workDir, fs.realpathSync(workDir)]).toContain(parsed.repoPath); + }); + + test('a selected provider suppresses the offer before any repo probing', () => { + expect(runCli('select', 'graphify').status).toBe(0); + const result = runCli('suggest', workDir, '--json'); + expect(result.status).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed.offer).toBe(false); + expect(parsed.reason).toBe('provider-selected'); + }); + + test('an explicit decline suppresses the offer permanently', () => { + expect(runCli('select', 'none').status).toBe(0); + const result = runCli('suggest', workDir, '--json'); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).reason).toBe('declined'); + }); + + test('human-readable no-offer output names the reason', () => { + const result = runCli('suggest', workDir); + expect(result.status).toBe(0); + expect(result.stdout).toContain('no offer (not-a-repo)'); + }); +}); + +describe('gstack-code-intelligence: provider-requiring commands without a selection', () => { + test('index refuses when no provider is selected', () => { + const result = runCli('index', workDir); + expect(result.status).toBe(1); + expect(result.stderr).toContain('no provider selected'); + }); + + test('search refuses when no provider is selected', () => { + const result = runCli('search', 'anything'); + expect(result.status).toBe(1); + expect(result.stderr).toContain('no provider selected'); + }); + + test('search with no query prints the search usage', () => { + const result = runCli('search'); + expect(result.status).toBe(1); + expect(result.stderr).toContain('Usage: search '); + }); +}); diff --git a/test/eval-cli-family.test.ts b/test/eval-cli-family.test.ts new file mode 100644 index 000000000..8ef6fc9a0 --- /dev/null +++ b/test/eval-cli-family.test.ts @@ -0,0 +1,387 @@ +/** + * The eval CLI family — scripts/eval-select.ts, eval-list.ts, eval-compare.ts, + * eval-summary.ts — the primary interface to eval results. + * + * Isolation mechanisms (each verified against the source, not assumed): + * + * - eval-list / eval-compare / eval-summary resolve their eval dir via + * getProjectEvalDir() (test/helpers/eval-store.ts), which probes the + * CWD-RELATIVE `.claude/skills/gstack/bin/gstack-slug` first, then + * `~/.claude/...` (~ = $HOME of the child). They do NOT honor + * GSTACK_EVAL_DIR (only EvalCollector does). So the real isolation + * mechanism is: cwd = a temp HOME containing a fake gstack-slug that + * prints `SLUG=`, routing every read to + * $HOME/.gstack/projects//evals — fully hermetic, and it + * exercises the primary (project-scoped) dir resolution path. + * (test/eval-list-cli.test.ts already covers the legacy-fallback dir + + * --limit validation; this file deliberately does not duplicate that.) + * + * - eval-select has NO isolation mechanism for its git diff: ROOT is + * hardcoded to the repo containing the script (import.meta.dir/..), so + * the CLI is smoke-tested against this repo with `--base HEAD` using + * shape invariants that hold for any working-tree state, and the + * "global touchfile ⇒ run everything" behavior is tested through the + * pure, importable selectTests() the CLI is a thin wrapper over. + */ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { runBin } from './helpers/run-bin'; +import { selectTests, E2E_TOUCHFILES, LLM_JUDGE_TOUCHFILES, GLOBAL_TOUCHFILES } from './helpers/touchfiles'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SCRIPT = (name: string) => path.join(ROOT, 'scripts', name); +const SLUG = 'eval-cli-fixture'; + +let tmpHome: string; +let evalDir: string; + +beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-eval-family-')); + // Fake gstack-slug at the cwd-relative probe path so getProjectEvalDir() + // deterministically resolves the project-scoped dir under the temp HOME. + const slugBin = path.join(tmpHome, '.claude', 'skills', 'gstack', 'bin'); + fs.mkdirSync(slugBin, { recursive: true }); + fs.writeFileSync(path.join(slugBin, 'gstack-slug'), `#!/usr/bin/env bash\necho "SLUG=${SLUG}"\n`, { mode: 0o755 }); + evalDir = path.join(tmpHome, '.gstack', 'projects', SLUG, 'evals'); + fs.mkdirSync(evalDir, { recursive: true }); +}); + +afterEach(() => { + fs.rmSync(tmpHome, { recursive: true, force: true }); +}); + +function runEvalCli(script: string, ...args: string[]) { + return runBin('bun', [SCRIPT(script), ...args], { + cwd: tmpHome, + home: tmpHome, + gstackHome: path.join(tmpHome, '.gstack'), + }); +} + +interface FixtureTest { + name: string; + passed: boolean; + cost?: number; + turns?: number; + duration?: number; +} + +/** Write a run file in the collector's shapes: finalized `{version}-{branch}-{tier}-{ts}.json` or `_partial-e2e.json`. */ +function writeRun(dir: string, opts: { + version?: string; + branch?: string; + tier?: 'e2e' | 'llm-judge'; + timestamp: string; + tests: FixtureTest[]; + partial?: boolean; +}): string { + const version = opts.version ?? '1.0.0'; + const branch = opts.branch ?? 'featx'; + const tier = opts.tier ?? 'e2e'; + const tests = opts.tests.map(t => ({ + name: t.name, + suite: 'fixture', + tier, + passed: t.passed, + duration_ms: t.duration ?? 1000, + cost_usd: t.cost ?? 0.5, + turns_used: t.turns ?? 5, + })); + const body = { + schema_version: 1, + version, + branch, + git_sha: 'abc1234', + timestamp: opts.timestamp, + hostname: 'fixture-host', + tier, + total_tests: tests.length, + passed: tests.filter(t => t.passed).length, + failed: tests.filter(t => !t.passed).length, + total_cost_usd: tests.reduce((s, t) => s + t.cost_usd, 0), + total_duration_ms: tests.reduce((s, t) => s + t.duration_ms, 0), + tests, + ...(opts.partial ? { _partial: true } : {}), + }; + const dateStr = opts.timestamp.replace(/[:.]/g, '').replace('T', '-').slice(0, 15); + const filename = opts.partial ? '_partial-e2e.json' : `${version}-${branch}-${tier}-${dateStr}.json`; + fs.mkdirSync(dir, { recursive: true }); + const filepath = path.join(dir, filename); + fs.writeFileSync(filepath, JSON.stringify(body, null, 2) + '\n'); + return filepath; +} + +// ── eval-select ────────────────────────────────────────────────────────────── + +describe('eval:select CLI (scripts/eval-select.ts)', () => { + test('--json parses and its selection partitions the full touchfile maps', () => { + // --base HEAD makes the committed diff empty; uncommitted/untracked files + // in the working tree may still appear, so assert shape invariants that + // hold for ANY tree state rather than pinning specific selections. + const result = runBin('bun', [SCRIPT('eval-select.ts'), '--json', '--base', 'HEAD'], { cwd: ROOT }); + expect(result.status).toBe(0); + + const parsed = JSON.parse(result.stdout); + expect(parsed.base).toBe('HEAD'); + + if (parsed.changed_files === 0) { + // Pristine tree: the no-diff shape reports run-all for both tiers. + expect(parsed.e2e).toBe('all'); + expect(parsed.llm_judge).toBe('all'); + expect(parsed.reason).toContain('all tests'); + } else { + expect(Array.isArray(parsed.changed_files)).toBe(true); + expect(parsed.changed_files.length).toBeGreaterThan(0); + for (const [selection, map] of [ + [parsed.e2e, E2E_TOUCHFILES], + [parsed.llm_judge, LLM_JUDGE_TOUCHFILES], + ] as const) { + const total = Object.keys(map).length; + expect(Array.isArray(selection.selected)).toBe(true); + expect(Array.isArray(selection.skipped)).toBe(true); + // selected + skipped always partition the map: disjoint, complete. + expect(selection.selected.length + selection.skipped.length).toBe(total); + const overlap = selection.selected.filter((name: string) => selection.skipped.includes(name)); + expect(overlap).toEqual([]); + expect(typeof selection.reason).toBe('string'); + expect(selection.count).toBe(`${selection.selected.length}/${total}`); + } + expect(Array.isArray(parsed.e2e.removed_tests)).toBe(true); + } + }); + + test('human-readable mode prints the base and per-tier headers', () => { + const result = runBin('bun', [SCRIPT('eval-select.ts'), '--base', 'HEAD'], { cwd: ROOT }); + expect(result.status).toBe(0); + expect(result.stdout).toContain('Base: HEAD'); + // Either the no-diff line or the two selection headers. + const hasNoDiff = result.stdout.includes('No changed files detected'); + if (!hasNoDiff) { + expect(result.stdout).toContain('E2E: selected'); + expect(result.stdout).toContain('LLM-judge: selected'); + } + }); + + test('a global-touchfile diff selects ALL tests with a global reason (pure selectTests)', () => { + // eval-select is a thin wrapper over selectTests(); the CLI cannot be + // pointed at a fixture repo (ROOT is hardcoded), so the run-all-on-global + // behavior is pinned through the same imported function it calls. + expect(GLOBAL_TOUCHFILES).toContain('test/helpers/eval-store.ts'); + const selection = selectTests(['test/helpers/eval-store.ts'], E2E_TOUCHFILES, GLOBAL_TOUCHFILES); + expect(selection.reason).toBe('global: test/helpers/eval-store.ts'); + expect(selection.selected.sort()).toEqual(Object.keys(E2E_TOUCHFILES).sort()); + expect(selection.skipped).toEqual([]); + }); + + test('a per-test touchfile diff selects only the dependent test', () => { + const touchfiles = { + 'test-a': ['src/feature-a.ts', 'src/shared/**'], + 'test-b': ['src/feature-b.ts'], + }; + const globals = ['helpers/global-runner.ts']; + + const hitA = selectTests(['src/feature-a.ts'], touchfiles, globals); + expect(hitA.selected).toEqual(['test-a']); + expect(hitA.skipped).toEqual(['test-b']); + expect(hitA.reason).toBe('diff'); + + const hitGlob = selectTests(['src/shared/deep/util.ts'], touchfiles, globals); + expect(hitGlob.selected).toEqual(['test-a']); + + const miss = selectTests(['docs/README.md'], touchfiles, globals); + expect(miss.selected).toEqual([]); + expect(miss.skipped.sort()).toEqual(['test-a', 'test-b']); + }); +}); + +// ── eval-list ──────────────────────────────────────────────────────────────── + +describe('eval:list CLI (scripts/eval-list.ts)', () => { + test('empty eval dir prints the getting-started hint and exits 0', () => { + const result = runEvalCli('eval-list.ts'); + expect(result.status).toBe(0); + expect(result.stdout).toContain('No eval runs yet'); + }); + + test('lists finalized runs from the flat dir AND one level of shards//', () => { + writeRun(evalDir, { branch: 'flat-branch', timestamp: '2026-01-01T01:00:00Z', tests: [{ name: 't1', passed: true, cost: 1.5, turns: 7 }] }); + writeRun(path.join(evalDir, 'shards', 'shard-a'), { branch: 'shard-branch', timestamp: '2026-01-02T01:00:00Z', tests: [{ name: 't2', passed: true, cost: 0.5, turns: 3 }] }); + + const result = runEvalCli('eval-list.ts'); + expect(result.status).toBe(0); + expect(result.stdout).toContain('Eval History (2 total runs)'); + expect(result.stdout).toContain('flat-branch'); + expect(result.stdout).toContain('shard-branch'); + // Sorted by timestamp descending: the shard run (newer) is listed first. + expect(result.stdout.indexOf('shard-branch')).toBeLessThan(result.stdout.indexOf('flat-branch')); + // Reads route to the project-scoped dir resolved via the fake gstack-slug. + expect(result.stdout).toContain(path.join('projects', SLUG, 'evals')); + }); + + test('--branch and --tier filter the listing', () => { + writeRun(evalDir, { branch: 'keep-me', tier: 'e2e', timestamp: '2026-01-01T01:00:00Z', tests: [{ name: 't1', passed: true }] }); + writeRun(evalDir, { branch: 'drop-me', tier: 'llm-judge', timestamp: '2026-01-02T01:00:00Z', tests: [{ name: 't2', passed: true }] }); + + const byBranch = runEvalCli('eval-list.ts', '--branch', 'keep-me'); + expect(byBranch.status).toBe(0); + expect(byBranch.stdout).toContain('Eval History (1 total runs)'); + expect(byBranch.stdout).toContain('keep-me'); + expect(byBranch.stdout).not.toContain('drop-me'); + + const byTier = runEvalCli('eval-list.ts', '--tier', 'llm-judge'); + expect(byTier.status).toBe(0); + expect(byTier.stdout).toContain('drop-me'); + expect(byTier.stdout).not.toContain('keep-me'); + }); + + test('DOCUMENTS CURRENT BEHAVIOR: in-progress _partial accumulators appear in the listing', () => { + // eval-list.ts applies NO isPartialEval filter (unlike eval-compare and + // every baseline lookup in eval-store.ts), so the in-progress accumulator + // is listed as if it were a run. If eval-list ever grows a partial filter, + // update this test to assert exclusion — that would be an improvement, + // not a regression. + writeRun(evalDir, { branch: 'finalized-run', timestamp: '2026-01-01T01:00:00Z', tests: [{ name: 't1', passed: true }] }); + writeRun(evalDir, { branch: 'partial-sentinel', timestamp: '2026-01-03T01:00:00Z', tests: [{ name: 't1', passed: false }], partial: true }); + + const result = runEvalCli('eval-list.ts'); + expect(result.status).toBe(0); + expect(result.stdout).toContain('finalized-run'); + expect(result.stdout).toContain('Eval History (2 total runs)'); + expect(result.stdout).toContain('partial-sentinel'); + }); +}); + +// ── eval-compare ───────────────────────────────────────────────────────────── + +describe('eval:compare CLI (scripts/eval-compare.ts)', () => { + test('empty eval dir prints the getting-started hint and exits 0', () => { + const result = runEvalCli('eval-compare.ts'); + expect(result.status).toBe(0); + expect(result.stdout).toContain('No eval runs yet'); + }); + + test('a single run is not enough to compare (exit 0 with guidance)', () => { + writeRun(evalDir, { timestamp: '2026-01-01T01:00:00Z', tests: [{ name: 't1', passed: true }] }); + const result = runEvalCli('eval-compare.ts'); + expect(result.status).toBe(0); + expect(result.stdout).toContain('Need at least 2 eval runs'); + }); + + test('no args: compares the two most recent FINALIZED runs and reports deltas; the fresher partial is never a side', () => { + writeRun(evalDir, { + timestamp: '2026-01-01T01:00:00Z', + tests: [ + { name: 't-stable', passed: true, cost: 1.0, turns: 5 }, + { name: 't-flaky', passed: false, cost: 1.0, turns: 5 }, + { name: 't-regressed', passed: true, cost: 1.0, turns: 5 }, + ], + }); + writeRun(evalDir, { + timestamp: '2026-01-02T01:00:00Z', + tests: [ + { name: 't-stable', passed: true, cost: 1.0, turns: 5 }, + { name: 't-flaky', passed: true, cost: 1.0, turns: 5 }, + { name: 't-regressed', passed: false, cost: 1.0, turns: 5 }, + ], + }); + // Freshest timestamp of all — if partials leaked into selection, this + // would be picked as the "after" run (or the baseline) and its sentinel + // branch would show up in the header line. + writeRun(evalDir, { + branch: 'partial-sentinel', + timestamp: '2026-01-03T01:00:00Z', + tests: [{ name: 't-stable', passed: false }], + partial: true, + }); + + const result = runEvalCli('eval-compare.ts'); + expect(result.status).toBe(0); + expect(result.stdout).not.toContain('partial-sentinel'); + expect(result.stdout).toContain('1 improved'); + expect(result.stdout).toContain('1 regressed'); + expect(result.stdout).toContain('1 unchanged'); + expect(result.stdout).toContain('REGRESSION: "t-regressed" was passing, now fails.'); + expect(result.stdout).toContain('Fixed: "t-flaky" now passes.'); + }); + + test('two explicit filenames resolve relative to the eval dir and compare in the given order', () => { + const before = writeRun(evalDir, { + timestamp: '2026-01-01T01:00:00Z', + tests: [{ name: 't-x', passed: true, cost: 1.0 }], + }); + const after = writeRun(evalDir, { + timestamp: '2026-01-02T01:00:00Z', + tests: [{ name: 't-x', passed: false, cost: 3.0 }], + }); + + const result = runEvalCli('eval-compare.ts', path.basename(before), path.basename(after)); + expect(result.status).toBe(0); + expect(result.stdout).toContain('1 regressed'); + expect(result.stdout).toContain('REGRESSION: "t-x" was passing, now fails.'); + // Cost delta: 1.00 → 3.00 = +$2.00 + expect(result.stdout).toContain('+$2.00'); + }); + + test('a missing explicit file fails with exit 1 and names the resolved path', () => { + writeRun(evalDir, { timestamp: '2026-01-01T01:00:00Z', tests: [{ name: 't1', passed: true }] }); + writeRun(evalDir, { timestamp: '2026-01-02T01:00:00Z', tests: [{ name: 't1', passed: true }] }); + const result = runEvalCli('eval-compare.ts', 'does-not-exist.json', 'also-missing.json'); + expect(result.status).toBe(1); + expect(result.stderr).toContain('File not found:'); + expect(result.stderr).toContain('does-not-exist.json'); + }); +}); + +// ── eval-summary ───────────────────────────────────────────────────────────── + +describe('eval:summary CLI (scripts/eval-summary.ts)', () => { + test('empty eval dir prints the getting-started hint and exits 0', () => { + const result = runEvalCli('eval-summary.ts'); + expect(result.status).toBe(0); + expect(result.stdout).toContain('No eval runs yet'); + }); + + test('aggregates run counts, spend, and flaky tests across tiers', () => { + writeRun(evalDir, { + tier: 'e2e', + branch: 'branch-one', + timestamp: '2026-01-01T01:00:00Z', + tests: [ + { name: 't-flaky', passed: true, cost: 0.5, turns: 4, duration: 10_000 }, + { name: 't-solid', passed: true, cost: 0.5, turns: 6, duration: 20_000 }, + ], + }); + writeRun(evalDir, { + tier: 'e2e', + branch: 'branch-one', + timestamp: '2026-01-02T01:00:00Z', + tests: [ + { name: 't-flaky', passed: false, cost: 1.0, turns: 8, duration: 30_000 }, + { name: 't-solid', passed: true, cost: 1.0, turns: 6, duration: 20_000 }, + ], + }); + writeRun(evalDir, { + tier: 'llm-judge', + branch: 'branch-two', + timestamp: '2026-01-03T01:00:00Z', + tests: [{ name: 'judge-1', passed: true, cost: 0.5 }], + }); + + const result = runEvalCli('eval-summary.ts'); + expect(result.status).toBe(0); + // 3 runs total: 2 e2e + 1 llm-judge. + expect(result.stdout).toContain('3 (2 e2e, 1 llm-judge)'); + // Total spend: (0.5+0.5) + (1.0+1.0) + 0.5 = 3.50 + expect(result.stdout).toContain('$3.50'); + // t-flaky passed once and failed once → flagged flaky, keyed by tier. + expect(result.stdout).toContain('Flaky tests (1):'); + expect(result.stdout).toContain('e2e:t-flaky'); + expect(result.stdout).not.toContain('e2e:t-solid'); + // Date range spans first → last timestamp. + expect(result.stdout).toContain('2026-01-01 01:00'); + expect(result.stdout).toContain('2026-01-03 01:00'); + expect(result.stdout).toContain(path.join('projects', SLUG, 'evals')); + }); +}); diff --git a/test/slop-diff-cli.test.ts b/test/slop-diff-cli.test.ts new file mode 100644 index 000000000..d28bd1eca --- /dev/null +++ b/test/slop-diff-cli.test.ts @@ -0,0 +1,162 @@ +/** + * scripts/slop-diff.ts — new-findings-only slop report, run on every /review + * and quality gate. + * + * Isolation: every git call in the script inherits the child's cwd (no + * explicit cwd is passed to spawnSync), so pointing the CLI at a tiny fixture + * repo is just `cwd: fixtureRepo`. The `npx slop-scan` dependency is stubbed + * with a PATH-prepended fake so no test ever downloads or runs the real + * scanner — the stub also makes the "scanner missing", "invalid JSON", and + * "real findings" paths deterministic. + */ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { runBin } from './helpers/run-bin'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SLOP_DIFF = path.join(ROOT, 'scripts', 'slop-diff.ts'); + +let repo: string; +let stubDir: string; + +function git(...args: string[]): void { + const result = runBin('git', args, { cwd: repo }); + if (result.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`); + } +} + +// POSIX-only on purpose: the npx stub is a shebang script, and Windows +// CreateProcess cannot exec shebangs (a PATH `npx` without .cmd would fall +// through to the REAL npx and try to download slop-scan). The quoted +// '/bin/bash' below is what the Windows-fragile content scanner in +// scripts/test-free-shards.ts keys on to exclude this file from the +// windows-safe subset. +const BASH = '/bin/bash'; + +/** Install a fake `npx` first on PATH. Body is a bash script fragment. */ +function stubNpx(body: string): void { + fs.writeFileSync(path.join(stubDir, 'npx'), `#!${BASH}\n${body}\n`, { mode: 0o755 }); +} + +function runSlopDiff(...args: string[]) { + return runBin('bun', [SLOP_DIFF, ...args], { + cwd: repo, + env: { PATH: `${stubDir}:${process.env.PATH}` }, + // Two scans + a worktree add/remove; generous but bounded. + timeoutMs: 90_000, + }); +} + +beforeEach(() => { + repo = fs.mkdtempSync(path.join(os.tmpdir(), 'slop-diff-repo-')); + stubDir = fs.mkdtempSync(path.join(os.tmpdir(), 'slop-diff-npx-')); + git('-c', 'init.defaultBranch=main', 'init', '-q'); + git('config', 'user.email', 'fixture@example.com'); + git('config', 'user.name', 'Fixture'); + fs.writeFileSync(path.join(repo, 'README.md'), '# fixture\n'); + git('add', 'README.md'); + git('commit', '-q', '-m', 'initial'); + // A default stub so no test path can ever reach a real npx/network. + stubNpx('exit 1'); +}); + +afterEach(() => { + fs.rmSync(repo, { recursive: true, force: true }); + fs.rmSync(stubDir, { recursive: true, force: true }); +}); + +/** Commit a changed file on a feature branch so `main...HEAD` is non-empty. */ +function commitFeatureChange(): void { + git('checkout', '-q', '-b', 'feature'); + fs.mkdirSync(path.join(repo, 'src'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'src', 'app.ts'), 'export const x = 1;\n'); + git('add', 'src/app.ts'); + git('commit', '-q', '-m', 'feature change'); +} + +describe('slop:diff CLI (scripts/slop-diff.ts)', () => { + test('no changes vs the base branch: exits 0 without ever invoking the scanner', () => { + // HEAD == main → empty diff → early exit before any npx call. The stub + // exits 1, so if the scanner were invoked the output would differ. + const result = runSlopDiff(); + expect(result.status).toBe(0); + expect(result.stdout).toContain('No files changed vs main'); + expect(result.stdout).toContain('nothing to check'); + }); + + test('missing slop-scan (npx produces no output): graceful message, exit 0', () => { + commitFeatureChange(); + // Default stub: exit 1, no stdout → the script's fallback path. + const result = runSlopDiff(); + expect(result.status).toBe(0); + expect(result.stdout).toContain('slop-scan not available'); + expect(result.stdout).toContain('npm i -g slop-scan'); + }); + + test('scanner emitting invalid JSON: graceful message, exit 0', () => { + commitFeatureChange(); + stubNpx('echo "this is not json"'); + const result = runSlopDiff(); + expect(result.status).toBe(0); + expect(result.stdout).toContain('slop-scan returned invalid JSON'); + }); + + test('reports only NEW findings in changed files, diffed against the merge-base scan', () => { + commitFeatureChange(); + // The stub is invoked twice: `npx slop-scan scan . --json` for HEAD and + // `npx slop-scan scan --json` for the merge-base. Branch on + // the scan target ($3): HEAD gets one finding in the changed file plus one + // in an UNCHANGED file (which must be filtered out); the base gets none. + stubNpx([ + 'if [ "$3" = "." ]; then', + ` echo '{"findings":[` + + `{"ruleId":"empty-catch","path":"src/app.ts","evidence":["line 3: empty catch, boundary=none"]},` + + `{"ruleId":"empty-catch","path":"README.md","evidence":["line 1: empty catch, boundary=none"]}` + + `]}'`, + 'else', + ' echo \'{"findings":[]}\'', + 'fi', + ].join('\n')); + + const result = runSlopDiff(); + expect(result.status).toBe(0); + expect(result.stdout).toContain('1 new findings'); + expect(result.stdout).toContain('src/app.ts'); + expect(result.stdout).toContain('empty-catch'); + expect(result.stdout).toContain('line 3: empty catch, boundary=none'); + // README.md was not part of the branch diff — its finding is not "new". + expect(result.stdout).not.toContain('README.md'); + expect(result.stdout).toContain('Net: +1 new, -0 removed'); + }); + + test('a finding present at the merge-base is not new, even when line numbers shift', () => { + commitFeatureChange(); + // Same (rule, file, evidence-modulo-line-number) on both sides: HEAD says + // line 42, base says line 3 — the line-number-insensitive fingerprint must + // treat them as the same finding. + stubNpx([ + 'if [ "$3" = "." ]; then', + ' echo \'{"findings":[{"ruleId":"empty-catch","path":"src/app.ts","evidence":["line 42: empty catch, boundary=none"]}]}\'', + 'else', + // The base scan sees worktree-absolute paths; the script remaps them by + // stripping the worktree prefix, so emit the path under the scan target. + ' echo "{\\"findings\\":[{\\"ruleId\\":\\"empty-catch\\",\\"path\\":\\"$3/src/app.ts\\",\\"evidence\\":[\\"line 3: empty catch, boundary=none\\"]}]}"', + 'fi', + ].join('\n')); + + const result = runSlopDiff(); + expect(result.status).toBe(0); + expect(result.stdout).toContain('no new findings'); + }); + + test('an explicit base argument overrides main', () => { + // Diff feature...feature is empty even though feature differs from main. + commitFeatureChange(); + const result = runSlopDiff('feature'); + expect(result.status).toBe(0); + expect(result.stdout).toContain('No files changed vs feature'); + }); +}); diff --git a/test/version-source.test.ts b/test/version-source.test.ts new file mode 100644 index 000000000..793963ff4 --- /dev/null +++ b/test/version-source.test.ts @@ -0,0 +1,181 @@ +/** + * Direct unit tests for lib/version-source.ts — the single owner of the + * 4-digit VERSION ↔ 3-digit npm translation and of version-path + * interpretation (raw text vs JSON `.version`). + * + * Before this file, lib/version-source.ts was exercised only INDIRECTLY + * through bin/gstack-version-bump (test/gstack-version-bump.test.ts spawns + * the bin; nothing imported the lib). These tests pin the translation rules + * documented in the module header so a regression is attributed to the lib, + * not to whichever CLI happened to surface it. + */ +import { describe, test, expect } from 'bun:test'; +import { + parseVersion, + versionWidth, + fmtVersion, + cmpVersion, + bumpVersion, + bumpWasCoerced, + npmVersion, + isJsonVersionPath, + extractVersion, + setVersionInJson, + type Version, +} from '../lib/version-source'; + +describe('parseVersion', () => { + test('4-digit versions parse to all four components', () => { + expect(parseVersion('1.67.0.0')).toEqual([1, 67, 0, 0]); + expect(parseVersion('12.3.45.6')).toEqual([12, 3, 45, 6]); + }); + + test('3-digit versions pad MICRO to 0 so comparison stays uniform', () => { + expect(parseVersion('1.2.3')).toEqual([1, 2, 3, 0]); + }); + + test('surrounding whitespace is tolerated (file reads carry newlines)', () => { + expect(parseVersion(' 1.2.3.4\n')).toEqual([1, 2, 3, 4]); + }); + + test('anything else is null, never a guess', () => { + for (const bad of ['1.2', 'v1.2.3', '1.2.3.4.5', '1.2.3-rc1', 'abc', '', '{"name":"frontend"']) { + expect(parseVersion(bad)).toBeNull(); + } + }); +}); + +describe('versionWidth + fmtVersion', () => { + test('width reflects how many components the string actually had', () => { + expect(versionWidth('1.2.3.4')).toBe(4); + expect(versionWidth(' 1.2.3.4 ')).toBe(4); + expect(versionWidth('1.2.3')).toBe(3); + }); + + test('formatting round-trips at each width', () => { + const v: Version = [1, 67, 2, 5]; + expect(fmtVersion(v, 4)).toBe('1.67.2.5'); + expect(fmtVersion(v, 3)).toBe('1.67.2'); + expect(fmtVersion(v)).toBe('1.67.2.5'); // default width 4 + }); + + test('parse → fmt round-trip preserves the original string at its own width', () => { + for (const s of ['1.67.0.0', '2.0.1']) { + expect(fmtVersion(parseVersion(s)!, versionWidth(s))).toBe(s); + } + }); +}); + +describe('cmpVersion', () => { + test('orders component-wise, MICRO included', () => { + const parse = (s: string) => parseVersion(s)!; + expect(cmpVersion(parse('1.2.3.4'), parse('1.2.3.4'))).toBe(0); + expect(cmpVersion(parse('1.2.3.5'), parse('1.2.3.4'))).toBeGreaterThan(0); + expect(cmpVersion(parse('1.2.3.4'), parse('1.3.0.0'))).toBeLessThan(0); + expect(cmpVersion(parse('2.0.0.0'), parse('1.99.99.99'))).toBeGreaterThan(0); + // Padded 3-digit compares equal to its explicit .0 form. + expect(cmpVersion(parse('1.2.3'), parse('1.2.3.0'))).toBe(0); + }); +}); + +describe('bumpVersion + bumpWasCoerced', () => { + const base = parseVersion('1.2.3.4')!; + + test('each level zeroes everything below it', () => { + expect(bumpVersion(base, 'major')).toEqual([2, 0, 0, 0]); + expect(bumpVersion(base, 'minor')).toEqual([1, 3, 0, 0]); + expect(bumpVersion(base, 'patch')).toEqual([1, 2, 4, 0]); + expect(bumpVersion(base, 'micro')).toEqual([1, 2, 3, 5]); + }); + + test('micro in a 3-digit repo is carried out as PATCH — never a silent no-op', () => { + const v = parseVersion('1.2.3')!; + expect(bumpVersion(v, 'micro', 3)).toEqual([1, 2, 4, 0]); + expect(fmtVersion(bumpVersion(v, 'micro', 3), 3)).toBe('1.2.4'); + }); + + test('bumpWasCoerced is true exactly for micro-at-width-3', () => { + expect(bumpWasCoerced('micro', 3)).toBe(true); + expect(bumpWasCoerced('micro', 4)).toBe(false); + expect(bumpWasCoerced('patch', 3)).toBe(false); + expect(bumpWasCoerced('major', 3)).toBe(false); + }); +}); + +describe('npmVersion (4-digit VERSION → 3-digit npm translation)', () => { + test('truncates the MICRO component', () => { + expect(npmVersion('1.67.0.0')).toBe('1.67.0'); + expect(npmVersion('1.67.2.5')).toBe('1.67.2'); + }); + + test('3-digit versions pass through unchanged', () => { + expect(npmVersion('1.2.3')).toBe('1.2.3'); + }); + + test('trims before translating', () => { + expect(npmVersion(' 1.2.3.4\n')).toBe('1.2.3'); + }); +}); + +describe('isJsonVersionPath', () => { + test('detection is by shape (.json suffix), case-insensitive, trimmed', () => { + expect(isJsonVersionPath('package.json')).toBe(true); + expect(isJsonVersionPath('frontend/package.JSON')).toBe(true); + expect(isJsonVersionPath(' pkg.json ')).toBe(true); + expect(isJsonVersionPath('VERSION')).toBe(false); + expect(isJsonVersionPath('version.txt')).toBe(false); + expect(isJsonVersionPath('jsonfile')).toBe(false); + }); +}); + +describe('extractVersion', () => { + test('non-JSON paths read as text with ALL whitespace stripped', () => { + expect(extractVersion('1.67.0.0\n', 'VERSION')).toBe('1.67.0.0'); + expect(extractVersion(' 1.2.3 \r\n', 'VERSION')).toBe('1.2.3'); + }); + + test('JSON paths read the .version field, not the raw bytes (#2501 regression class)', () => { + const pkg = '{\n "name": "frontend",\n "version": "2.0.1"\n}\n'; + expect(extractVersion(pkg, 'frontend/package.json')).toBe('2.0.1'); + // The old whitespace-strip-as-text behavior would return mangled JSON. + expect(extractVersion(pkg, 'frontend/package.json')).not.toContain('{'); + }); + + test('JSON without a usable version yields "" for the caller\'s own fallback', () => { + expect(extractVersion('{"name":"x"}', 'package.json')).toBe(''); + expect(extractVersion('{"version": 42}', 'package.json')).toBe(''); + expect(extractVersion('not json at all', 'package.json')).toBe(''); + }); + + test('JSON version values are trimmed', () => { + expect(extractVersion('{"version": " 1.2.3 "}', 'package.json')).toBe('1.2.3'); + }); +}); + +describe('setVersionInJson', () => { + test('rewrites only the version, preserving key order, 2-space indent, trailing newline', () => { + const raw = '{"name":"frontend","version":"1.0.0","private":true,"scripts":{"build":"x"}}'; + const out = setVersionInJson(raw, '1.1.0'); + expect(out).toBe([ + '{', + ' "name": "frontend",', + ' "version": "1.1.0",', + ' "private": true,', + ' "scripts": {', + ' "build": "x"', + ' }', + '}', + '', + ].join('\n')); + }); + + test('round-trips with extractVersion', () => { + const out = setVersionInJson('{"name":"x","version":"1.0.0"}', '2.3.4'); + expect(extractVersion(out, 'package.json')).toBe('2.3.4'); + }); + + test('adds a version field when the manifest had none', () => { + const out = setVersionInJson('{"name":"x"}', '0.1.0'); + expect(JSON.parse(out)).toEqual({ name: 'x', version: '0.1.0' }); + }); +});