feat: add diagnostics tool for live command output monitoring
- Implemented diagnostics functionality to start, stop, and monitor background check commands. - Created a DiagnosticsPanel for real-time output display in the UI. - Added support for auto-detecting default diagnostics commands based on project configuration. - Introduced run_checks tool to execute project verification commands and report results. - Enhanced tools-extra with functions to parse AGENTS.md and package.json for check commands. - Added diff review functionality to visualize changes made in the last turn. - Implemented tests for diagnostics and run_checks functionalities to ensure reliability.
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { diagStart, diagStop, diagStatus, bootDiagnostics, defaultDiagnosticsCommand, shutdownDiagnostics } from '../src/diagnostics';
|
||||
|
||||
// Diagnostics spawns real processes, so each test gets a fresh state (the module
|
||||
// is a singleton — one diagnostics command at a time).
|
||||
describe('diagnostics module', () => {
|
||||
afterEach(() => {
|
||||
shutdownDiagnostics();
|
||||
});
|
||||
|
||||
it('starts, shows running status, and stops a command', async () => {
|
||||
const started = diagStart('node -e "setInterval(()=>{}, 1000)"');
|
||||
expect(started.started).toBe(true);
|
||||
|
||||
const status = diagStatus();
|
||||
expect(status.running).toBe(true);
|
||||
expect(status.command).toContain('setInterval');
|
||||
|
||||
const stopped = diagStop();
|
||||
expect(stopped.stopped).toBe(true);
|
||||
expect(diagStatus().running).toBe(false);
|
||||
});
|
||||
|
||||
it('reports exit code once the command finishes', async () => {
|
||||
diagStart('node -e "process.exit(3)"');
|
||||
// Wait for the process to actually exit.
|
||||
await Bun.sleep(300);
|
||||
const status = diagStatus();
|
||||
expect(status.running).toBe(false);
|
||||
expect(status.exit).toBe(3);
|
||||
});
|
||||
|
||||
it('captures output into the tail', async () => {
|
||||
diagStart('node -e "console.log(\'hello-diag\')"');
|
||||
await Bun.sleep(300);
|
||||
const status = diagStatus();
|
||||
expect(status.tail).toContain('hello-diag');
|
||||
expect(status.exit).toBe(0);
|
||||
});
|
||||
|
||||
it('diagStop with nothing running is a no-op', () => {
|
||||
expect(diagStop().stopped).toBe(false);
|
||||
expect(diagStatus().running).toBe(false);
|
||||
});
|
||||
|
||||
it('starting replaces a running command and kills the old one', async () => {
|
||||
const oldHandle = diagStart('node -e "setInterval(()=>{}, 1000)"');
|
||||
expect(oldHandle.started).toBe(true);
|
||||
const second = diagStart('node -e "console.log(\'second\')"');
|
||||
expect(second.started).toBe(true);
|
||||
const status = diagStatus();
|
||||
expect(status.command).toContain('second');
|
||||
// old process is gone; status.command reflects the newest start
|
||||
shutdownDiagnostics();
|
||||
});
|
||||
|
||||
it('bootDiagnostics ignores empty config and starts a real one', async () => {
|
||||
bootDiagnostics(undefined);
|
||||
expect(diagStatus().running).toBe(false);
|
||||
bootDiagnostics('node -e "setInterval(()=>{}, 1000)"');
|
||||
expect(diagStatus().running).toBe(true);
|
||||
});
|
||||
|
||||
it('bootDiagnostics never throws on a bad command — keeps boot clean', async () => {
|
||||
expect(() => bootDiagnostics('')).not.toThrow();
|
||||
// A command that cannot spawn (bad binary) still must not throw synchronously.
|
||||
expect(() => bootDiagnostics('/nonexistent/binary')).not.toThrow();
|
||||
await Bun.sleep(50);
|
||||
shutdownDiagnostics();
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultDiagnosticsCommand — auto-detection', () => {
|
||||
const tmp = `${Bun.env['TMPDIR'] ?? '/tmp'}/diag-default-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
||||
let savedCwd: string;
|
||||
const { mkdirSync, rmSync, writeFileSync } = require('node:fs') as typeof import('node:fs');
|
||||
|
||||
beforeEach(() => {
|
||||
savedCwd = process.cwd();
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
mkdirSync(tmp, { recursive: true });
|
||||
process.chdir(tmp);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(savedCwd);
|
||||
diagStop();
|
||||
});
|
||||
|
||||
it('picks tsc --watch for a bun+ts project', async () => {
|
||||
writeFileSync('bun.lock', '');
|
||||
writeFileSync('package.json', '{}');
|
||||
writeFileSync('tsconfig.json', '{}');
|
||||
expect(await defaultDiagnosticsCommand(process.cwd())).toBe('bun run typecheck --watch');
|
||||
});
|
||||
|
||||
it('falls back to bun test --watch without tsconfig', async () => {
|
||||
writeFileSync('bun.lock', '');
|
||||
writeFileSync('package.json', '{}');
|
||||
expect(await defaultDiagnosticsCommand(process.cwd())).toBe('bun test --watch');
|
||||
});
|
||||
|
||||
it('returns undefined for an unknown project', async () => {
|
||||
expect(await defaultDiagnosticsCommand(process.cwd())).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { parseDiffHunks, renderDiffReview } from '../src/diff-review';
|
||||
|
||||
const SAMPLE_DIFF = `diff --git a/src/foo.ts b/src/foo.ts
|
||||
@@ -1,5 +1,6 @@
|
||||
import { a } from './a';
|
||||
+import { b } from './b';
|
||||
const x = 1;
|
||||
const y = 2;
|
||||
const z = 3;
|
||||
@@ -10,3 +11,2 @@
|
||||
function foo() {
|
||||
- return 0;
|
||||
}
|
||||
diff --git a/src/bar.ts b/src/bar.ts
|
||||
@@ -1,4 +1,1 @@
|
||||
-a
|
||||
-b
|
||||
-c
|
||||
+d
|
||||
`;
|
||||
|
||||
describe('parseDiffHunks', () => {
|
||||
it('finds hunk headers across files', () => {
|
||||
const hunks = parseDiffHunks(SAMPLE_DIFF);
|
||||
expect(hunks.length).toBe(3);
|
||||
expect(hunks[0]!.file).toBe('src/foo.ts');
|
||||
expect(hunks[0]!.oldStart).toBe(1);
|
||||
expect(hunks[0]!.newStart).toBe(1);
|
||||
expect(hunks[1]!.file).toBe('src/foo.ts');
|
||||
expect(hunks[1]!.oldStart).toBe(10);
|
||||
expect(hunks[1]!.newStart).toBe(11);
|
||||
expect(hunks[2]!.file).toBe('src/bar.ts');
|
||||
expect(hunks[2]!.oldStart).toBe(1);
|
||||
expect(hunks[2]!.newStart).toBe(1);
|
||||
});
|
||||
|
||||
it('captures hunk body including +/- lines', () => {
|
||||
const hunks = parseDiffHunks(SAMPLE_DIFF);
|
||||
expect(hunks[0]!.body).toContain('+import { b } from \'./b\';');
|
||||
expect(hunks[0]!.body).toContain(' const x = 1;');
|
||||
expect(hunks[1]!.body).toContain('- return 0;');
|
||||
});
|
||||
|
||||
it('returns empty array on empty diff', () => {
|
||||
expect(parseDiffHunks('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when no hunk headers', () => {
|
||||
expect(parseDiffHunks('diff --git a/x b/x\njust some text\n')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderDiffReview', () => {
|
||||
it('renders with file:line anchors', () => {
|
||||
const out = renderDiffReview(SAMPLE_DIFF);
|
||||
expect(out).toContain('src/foo.ts:1');
|
||||
expect(out).toContain('src/foo.ts:11');
|
||||
expect(out).toContain('src/bar.ts:1');
|
||||
expect(out).toContain('diff review:');
|
||||
});
|
||||
|
||||
it('returns "no hunks to review" on empty diff', () => {
|
||||
expect(renderDiffReview('')).toBe('no hunks to review');
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -66,7 +66,7 @@ test('a read-only tool set changes the workflow rules', () => {
|
||||
test('a full tool set explains approval and verification', () => {
|
||||
const full = systemPrompt({ cwd: '/repo', availableTools: ALL });
|
||||
expect(full).toContain('need the user to approve');
|
||||
expect(full).toContain("run the project's build or tests");
|
||||
expect(full).toContain('run_checks');
|
||||
expect(full).not.toContain('no tools that change anything');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { parseCommand } from '../src/commands';
|
||||
import {
|
||||
docsCheckCommands,
|
||||
manifestScripts,
|
||||
runCheck,
|
||||
type CheckSuggestion,
|
||||
} from '../src/tools-extra';
|
||||
|
||||
// Each test chdirs into a fresh temp dir so discovery sees only what the test wrote.
|
||||
const tmp = (name: string) => `${Bun.env['TMPDIR'] ?? '/tmp'}/run-checks-${name}-${process.pid}`;
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = process.cwd();
|
||||
const dir = tmp(`${Math.random().toString(36).slice(2)}`);
|
||||
// mkdtemp-style: create and chdir
|
||||
const { mkdirSync } = require('node:fs') as typeof import('node:fs');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
process.chdir(dir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(cwd);
|
||||
});
|
||||
|
||||
describe('docsCheckCommands — AGENTS.md parsing', () => {
|
||||
it('extracts backticked commands', async () => {
|
||||
const { writeFileSync } = require('node:fs') as typeof import('node:fs');
|
||||
writeFileSync(
|
||||
'AGENTS.md',
|
||||
'## Commands\n- `bun run typecheck` — typecheck\n- `bun test` — test suite\n',
|
||||
);
|
||||
const out = await docsCheckCommands(process.cwd());
|
||||
expect(out).toEqual([
|
||||
{ name: 'typecheck', command: 'bun run typecheck', source: expect.stringContaining('AGENTS.md') },
|
||||
{ name: 'test', command: 'bun test', source: expect.stringContaining('AGENTS.md') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores prose with no command and non-check commands', async () => {
|
||||
const { writeFileSync } = require('node:fs') as typeof import('node:fs');
|
||||
writeFileSync('AGENTS.md', 'Run the thing.\n- `bun run dev` — dev server\n- `bun run build`\n');
|
||||
const out = await docsCheckCommands(process.cwd());
|
||||
// build matches the check filter (`build` is one of the check words)
|
||||
expect(out.map((s) => s.command)).toContain('bun run build');
|
||||
expect(out.map((s) => s.command)).not.toContain('bun run dev');
|
||||
expect(out.length).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('only takes the first backticked span, not prose after it', async () => {
|
||||
const { writeFileSync } = require('node:fs') as typeof import('node:fs');
|
||||
writeFileSync('AGENTS.md', '- `bun test` — this is a description with a semicolon; run it\n');
|
||||
const out = await docsCheckCommands(process.cwd());
|
||||
expect(out[0]!.command).toBe('bun test'); // not "bun test — this is…"
|
||||
});
|
||||
|
||||
it('handles $ -prefixed commands', async () => {
|
||||
const { writeFileSync } = require('node:fs') as typeof import('node:fs');
|
||||
writeFileSync('AGENTS.md', '```\n$ bun run typecheck\n```\n');
|
||||
const out = await docsCheckCommands(process.cwd());
|
||||
expect(out.map((s) => s.command)).toContain('bun run typecheck');
|
||||
});
|
||||
});
|
||||
|
||||
describe('manifestScripts — package.json discovery', () => {
|
||||
it('finds scripts and uses bun when the lockfile is bun', async () => {
|
||||
const { writeFileSync } = require('node:fs') as typeof import('node:fs');
|
||||
writeFileSync('package.json', JSON.stringify({ scripts: { test: 'vitest run', lint: 'eslint .' } }));
|
||||
writeFileSync('bun.lock', '');
|
||||
const out = await manifestScripts(process.cwd());
|
||||
expect(out).toEqual([
|
||||
{ name: 'test', command: 'bun run test', source: expect.stringContaining('package.json') },
|
||||
{ name: 'lint', command: 'bun run lint', source: expect.stringContaining('package.json') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses npm when there is no bun lockfile', async () => {
|
||||
const { writeFileSync } = require('node:fs') as typeof import('node:fs');
|
||||
writeFileSync('package.json', JSON.stringify({ scripts: { test: 'jest' } }));
|
||||
const out = await manifestScripts(process.cwd());
|
||||
expect(out[0]!.command).toBe('npm run test');
|
||||
});
|
||||
|
||||
it('returns [] for a malformed manifest', async () => {
|
||||
const { writeFileSync } = require('node:fs') as typeof import('node:fs');
|
||||
writeFileSync('package.json', 'not json {');
|
||||
expect(await manifestScripts(process.cwd())).toEqual([]);
|
||||
});
|
||||
|
||||
it('orders test, typecheck, check, lint, build first, then alphabetically', async () => {
|
||||
const { writeFileSync } = require('node:fs') as typeof import('node:fs');
|
||||
writeFileSync('package.json', JSON.stringify({ scripts: { zeta: '', lint: '', test: '' } }));
|
||||
const names = (await manifestScripts(process.cwd())).map((s) => s.name);
|
||||
expect(names[0]).toBe('test');
|
||||
expect(names[1]).toBe('lint');
|
||||
expect(names[2]).toBe('zeta');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runCheck — subprocess execution', () => {
|
||||
it('returns ok for exit 0', async () => {
|
||||
const r = await runCheck('node -e "process.exit(0)"', process.cwd(), 10_000);
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('returns fail for exit 1 with stderr surfaced', async () => {
|
||||
const r = await runCheck('node -e "console.error(\'boom\'); process.exit(1)"', process.cwd(), 10_000);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.output).toContain('boom');
|
||||
});
|
||||
|
||||
it('times out a hanging command', async () => {
|
||||
const r = await runCheck('node -e "setTimeout(()=>{}, 60_000)"', process.cwd(), 500);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.output).toContain('timed out');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user