mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-31 18:30:39 +02:00
test: coverage fill — 95 tests for six zero-coverage surfaces
- eval CLI family (eval-list/compare/summary + eval-select smoke): the primary interface to eval results had no tests; isolation via a fake gstack-slug under a mkdtemp HOME (the scripts' real resolution path — they do NOT honor GSTACK_EVAL_DIR; only EvalCollector does). Pinned current behavior: eval-list does NOT exclude _partial runs (documented improvement candidate) - slop-diff (runs on every /review + quality-gate): fixture git repo + first-on-PATH npx stub (never downloads real slop-scan); no-diff early exit, missing-scanner fallback, fingerprint line-insensitivity, merge-base worktree scan - bin/gstack-code-intelligence CLI arg surface (lib was covered, the 284-line CLI wasn't): select/consent/suggest/index/search gating; pinned: --help routes to usage failure exit 1 (no handler) - browse media-extract: the page.evaluate callback exercised in-process against a mock DOM (no exports added) — lazy-src fallback chain, HLS/DASH detection, bg-image url() parsing, 500-element cap - browse session-cookie-store: factory contract (cookieName/ttlMs/ maxSessions eviction, cross-store isolation, mint→validate round-trip); store is in-memory — no fs cases exist - lib/version-source direct unit tests (gstack-version-bump.test.ts spawns the bin, never imports the lib): parse/format/cmp/bump coercion, npm 4→3 translation, #2501 mangled-JSON regression class All hermetic (mkdtemp homes, runBin child isolation); windows curation correctly partitions the six. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6841183c35
commit
1055561cae
@@ -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<string, any[]>;
|
||||
|
||||
/** 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<string, unknown> = {}, attrs: Record<string, string> = {}, 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<string, unknown> = {}, 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<string, unknown> = {}, 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<typeof extractMedia>[1],
|
||||
): Promise<MediaResult> {
|
||||
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 <source> 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 <source> 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');
|
||||
});
|
||||
});
|
||||
@@ -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<Parameters<typeof createSessionCookieStore>[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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, boolean>; 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 <provider>');
|
||||
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 <gbrain|sourcebot|graphify|none>');
|
||||
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 <gbrain|sourcebot|graphify|none>');
|
||||
});
|
||||
|
||||
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 <query...>');
|
||||
});
|
||||
});
|
||||
@@ -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=<fixture>`, routing every read to
|
||||
* $HOME/.gstack/projects/<fixture>/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/<slug>/', () => {
|
||||
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'));
|
||||
});
|
||||
});
|
||||
@@ -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 <tmp-worktree> --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');
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user