Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
54e85dd7fThis file is pinned to the selected release. Download the original to open it in your design tool.
import { describe, it, expect, beforeEach } from 'vitest';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execa } from 'execa';
import {
budgetExtraTurns,
budgetPromptText,
parseMaxTurns,
repoOf,
} from '../src/util/cli-args.js';
import { setColorEnabled, styleOutcome, styleHeaderLines } from '../src/agent/theme.js';
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
describe('--max-turns parsing', () => {
it('accepts positive integers, including exponent notation', () => {
expect(parseMaxTurns('1')).toBe(1);
expect(parseMaxTurns('40')).toBe(40);
expect(parseMaxTurns('1e3')).toBe(1000);
});
it('refuses anything that is not a positive integer', () => {
for (const bad of ['0', '-1', '2.5', 'NaN', '5oops', 'Infinity', ' ']) {
expect(() => parseMaxTurns(bad), bad).toThrow(/positive integer/);
}
});
it('names the offending value so the refusal is actionable', () => {
expect(() => parseMaxTurns('5oops')).toThrow('"5oops"');
});
});
describe('--repo resolution', () => {
it('resolves a relative path against the working directory', () => {
expect(repoOf({ repo: '.' })).toBe(path.resolve('.'));
});
it('passes an absolute path through unchanged', () => {
expect(repoOf({ repo: '/tmp/some-repo' })).toBe('/tmp/some-repo');
});
it('defaults to the working directory', () => {
expect(repoOf({})).toBe(path.resolve(process.cwd()));
});
});
describe('budget-exhausted prompt (design D1)', () => {
const stats = {
maxTurns: 40,
turnsUsed: 40,
tokensIn: 12_345,
tokensOut: 6_789,
filesTouched: ['a.kicad_sch', 'b.md'],
openObligations: 1,
};
it('offers half the ORIGINAL budget, so repeat extensions do not escalate', () => {
expect(budgetExtraTurns({ maxTurns: 40 })).toBe(20);
expect(budgetExtraTurns({ maxTurns: 41 })).toBe(21); // ceil, never zero
expect(budgetExtraTurns({ maxTurns: 1 })).toBe(1);
});
it('spells out the cost of the run so far', () => {
const q = budgetPromptText(stats);
expect(q).toContain('40 turns');
expect(q).toContain('12.3k in / 6.8k out');
expect(q).toContain('2 file(s) touched');
expect(q).toContain('1 open obligation(s)');
expect(q).toContain('Continue with 20 more turns?');
});
});
describe('theme styling (color on)', () => {
// These branches early-return when color is off, which is how every other
// suite runs them, so they need their own explicit color-on coverage.
beforeEach(() => setColorEnabled(true));
it('colors a successful outcome differently from a refusal', () => {
const done = styleOutcome('done · 3 turns');
const refused = styleOutcome('refused · over budget');
expect(done).toContain('done');
expect(refused).toContain('refused');
expect(done).not.toBe(refused);
// The head is painted and the tail dimmed, so both carry SGR.
expect(done).toMatch(/\x1b\[/);
expect(refused).toMatch(/\x1b\[/);
});
it('treats every failure token as a failure', () => {
for (const head of ['refused', 'failed', 'error', 'exhausted', 'stalled']) {
expect(styleOutcome(`${head} · x`), head).toBe(styleOutcome('refused · x').replace('refused', head));
}
});
it('paints the brand in the header and dims the rest', () => {
const [first, second] = styleHeaderLines(['copperhead v0.7.0 · gpt-5 · repo', 'second line']);
expect(first).toContain('copperhead v0.7.0');
expect(first).toMatch(/\x1b\[/);
expect(second).toContain('second line');
});
it('dims a first line that does not match the brand pattern', () => {
const [only] = styleHeaderLines(['not the banner']);
expect(only).toContain('not the banner');
expect(only).toMatch(/\x1b\[/);
});
it('is a pass-through when color is off', () => {
setColorEnabled(false);
expect(styleOutcome('done · 3 turns')).toBe('done · 3 turns');
expect(styleHeaderLines(['a', 'b'])).toEqual(['a', 'b']);
});
});
describe('demo --tour honours --json', () => {
// The only end-to-end check of the CLI wiring: --tour short-circuits before
// the try block, so it used to print prose even under --json. Runs the real
// entry point through tsx; --tour touches no network and no kicad-cli.
it('emits structured JSON, not prose, under --json', async () => {
const res = await execa('npx', ['tsx', 'src/cli.ts', '--json', 'demo', '--tour'], {
cwd: ROOT,
reject: false,
env: { NO_COLOR: '1' },
});
expect(res.exitCode).toBe(0);
const parsed = JSON.parse(res.stdout) as { tour: string[] };
expect(Array.isArray(parsed.tour)).toBe(true);
expect(parsed.tour.join('\n')).toContain('copperhead');
// Machine-readable means no leftover SGR in the payload.
expect(res.stdout).not.toMatch(/\x1b\[/);
}, 60_000);
it('still prints prose without --json', async () => {
const res = await execa('npx', ['tsx', 'src/cli.ts', 'demo', '--tour'], {
cwd: ROOT,
reject: false,
env: { NO_COLOR: '1' },
});
expect(res.exitCode).toBe(0);
expect(() => JSON.parse(res.stdout)).toThrow();
expect(res.stdout).toContain('copperhead');
}, 60_000);
});