Files
gstack/test/timeline.test.ts
T
garrytanandcapy-ai[bot] f9796eef8e fix(timeline): reject a missing or malformed --limit with exit 2 (#1723)
gstack-timeline-read parsed --limit with parseInt and fell back to 20, so
'--limit 1abc' became 1, 'nope', 0 and -5 silently became the default, and a
bare --limit crashed on an unbound $2. Validate the argument as a positive
integer before the no-timeline early exit and exit 2 with an example. No flag
still means 20.

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

226 lines
7.9 KiB
TypeScript

import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { execFileSync, execSync, spawnSync, ExecSyncOptionsWithStringEncoding } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin');
let tmpDir: string;
let slugDir: string;
function runLog(input: string, opts: { expectFail?: boolean } = {}): { stdout: string; exitCode: number } {
const execOpts: ExecSyncOptionsWithStringEncoding = {
cwd: ROOT,
env: { ...process.env, GSTACK_HOME: tmpDir },
encoding: 'utf-8',
timeout: 15000,
};
try {
const stdout = execSync(`${BIN}/gstack-timeline-log '${input.replace(/'/g, "'\\''")}'`, execOpts).trim();
return { stdout, exitCode: 0 };
} catch (e: any) {
if (opts.expectFail) {
return { stdout: e.stderr?.toString() || '', exitCode: e.status || 1 };
}
throw e;
}
}
function runRead(args: string = ''): string {
const execOpts: ExecSyncOptionsWithStringEncoding = {
cwd: ROOT,
env: { ...process.env, GSTACK_HOME: tmpDir },
encoding: 'utf-8',
timeout: 15000,
};
try {
return execSync(`${BIN}/gstack-timeline-read ${args}`, execOpts).trim();
} catch {
return '';
}
}
function runReadArgs(args: string[] = []): string {
const execOpts: ExecSyncOptionsWithStringEncoding = {
cwd: ROOT,
env: { ...process.env, GSTACK_HOME: tmpDir },
encoding: 'utf-8',
timeout: 15000,
};
try {
return execFileSync(path.join(BIN, 'gstack-timeline-read'), args, execOpts).trim(); // timeout via execOpts
} catch {
return '';
}
}
function runReadStatus(args: string[]): { status: number | null; stdout: string; stderr: string } {
const result = spawnSync(path.join(BIN, 'gstack-timeline-read'), args, {
cwd: ROOT,
env: { ...process.env, GSTACK_HOME: tmpDir },
encoding: 'utf-8',
timeout: 15000,
});
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-timeline-'));
slugDir = path.join(tmpDir, 'projects');
fs.mkdirSync(slugDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function findTimelineFile(): string | null {
const projectDirs = fs.readdirSync(slugDir);
if (projectDirs.length === 0) return null;
const f = path.join(slugDir, projectDirs[0], 'timeline.jsonl');
return fs.existsSync(f) ? f : null;
}
describe('gstack-timeline-log', () => {
test('accepts valid JSON and appends to timeline.jsonl', () => {
const input = '{"skill":"review","event":"started","branch":"main"}';
const result = runLog(input);
expect(result.exitCode).toBe(0);
const f = findTimelineFile();
expect(f).not.toBeNull();
const content = fs.readFileSync(f!, 'utf-8').trim();
const parsed = JSON.parse(content);
expect(parsed.skill).toBe('review');
expect(parsed.event).toBe('started');
expect(parsed.branch).toBe('main');
});
test('rejects invalid JSON with exit 0 (non-blocking)', () => {
const result = runLog('not json at all');
expect(result.exitCode).toBe(0);
// No file should be created
const f = findTimelineFile();
expect(f).toBeNull();
});
test('injects timestamp when ts field is missing', () => {
const input = '{"skill":"review","event":"started","branch":"main"}';
runLog(input);
const f = findTimelineFile();
expect(f).not.toBeNull();
const parsed = JSON.parse(fs.readFileSync(f!, 'utf-8').trim());
expect(parsed.ts).toBeDefined();
expect(new Date(parsed.ts).getTime()).toBeGreaterThan(0);
});
test('preserves timestamp when ts field is present', () => {
const input = '{"skill":"review","event":"completed","branch":"main","ts":"2025-06-15T10:00:00Z"}';
runLog(input);
const f = findTimelineFile();
expect(f).not.toBeNull();
const parsed = JSON.parse(fs.readFileSync(f!, 'utf-8').trim());
expect(parsed.ts).toBe('2025-06-15T10:00:00Z');
});
test('validates required fields (skill, event) - exits 0 if missing skill', () => {
const result = runLog('{"event":"started","branch":"main"}');
expect(result.exitCode).toBe(0);
const f = findTimelineFile();
expect(f).toBeNull();
});
test('validates required fields (skill, event) - exits 0 if missing event', () => {
const result = runLog('{"skill":"review","branch":"main"}');
expect(result.exitCode).toBe(0);
const f = findTimelineFile();
expect(f).toBeNull();
});
});
describe('gstack-timeline-read', () => {
test('returns empty output for missing file (exit 0)', () => {
const output = runRead();
expect(output).toBe('');
});
test('filters by --branch', () => {
runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: 'feature-a', outcome: 'approved', ts: '2026-03-28T10:00:00Z' }));
runLog(JSON.stringify({ skill: 'ship', event: 'completed', branch: 'feature-b', outcome: 'merged', ts: '2026-03-28T11:00:00Z' }));
const output = runRead('--branch feature-a');
expect(output).toContain('review');
expect(output).not.toContain('feature-b');
});
test('filters branch names containing single quotes', () => {
runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: "feature/o'hare", outcome: 'approved', ts: '2026-03-28T10:00:00Z' }));
runLog(JSON.stringify({ skill: 'ship', event: 'completed', branch: 'feature-other', outcome: 'merged', ts: '2026-03-28T11:00:00Z' }));
const output = runReadArgs(['--branch', "feature/o'hare"]);
expect(output).toContain('review');
expect(output).toContain("feature/o'hare");
expect(output).not.toContain('feature-other');
});
test('limits output with --limit', () => {
for (let i = 0; i < 5; i++) {
runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: 'main', outcome: 'approved', ts: `2026-03-2${i}T10:00:00Z` }));
}
const unlimited = runRead('--limit 20');
const limited = runRead('--limit 2');
// Count event lines (lines starting with "- ")
const unlimitedEvents = unlimited.split('\n').filter(l => l.startsWith('- ')).length;
const limitedEvents = limited.split('\n').filter(l => l.startsWith('- ')).length;
expect(unlimitedEvents).toBe(5);
expect(limitedEvents).toBe(2);
});
test('no --limit still shows the 20 most recent events', () => {
for (let i = 0; i < 25; i++) {
runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: 'main', ts: `2026-03-01T10:${String(i).padStart(2, '0')}:00Z` }));
}
const result = runReadStatus([]);
expect(result.status).toBe(0);
const events = result.stdout.split('\n').filter(l => l.startsWith('- '));
expect(events.length).toBe(20);
expect(events[0]).toContain('10:05:00Z');
});
// #1723: a malformed --limit used to be parsed leniently (1abc -> 1) or
// silently replaced by the default; a bare --limit crashed on unbound $2.
const badLimits: Array<[string, string[], string]> = [
['missing value', ['--limit'], 'got no value'],
['zero', ['--limit', '0'], "got '0'"],
['negative', ['--limit', '-5'], "got '-5'"],
['non-numeric', ['--limit', 'abc'], "got 'abc'"],
['trailing garbage', ['--limit', '1abc'], "got '1abc'"],
];
for (const history of ['empty', 'populated'] as const) {
for (const [label, args, got] of badLimits) {
test(`--limit ${label} exits 2 with an example on ${history} history`, () => {
if (history === 'populated') {
runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: 'main', ts: '2026-03-28T10:00:00Z' }));
}
const result = runReadStatus(args);
expect(result.status).toBe(2);
expect(result.stdout).toBe('');
expect(result.stderr).toContain('--limit must be a positive integer');
expect(result.stderr).toContain(got);
expect(result.stderr).toContain('example: --limit 20');
});
}
}
});