mirror of https://github.com/garrytan/gstack.git
209 lines
7.6 KiB
TypeScript
209 lines
7.6 KiB
TypeScript
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { runSkillTest } from './helpers/session-runner';
|
|
import {
|
|
ROOT, runId, evalsEnabled,
|
|
describeIfSelected, logCost, recordE2E,
|
|
createEvalCollector, finalizeEvalCollector,
|
|
} from './helpers/e2e-helpers';
|
|
import { spawnSync } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
|
|
const evalCollector = createEvalCollector('e2e-diagnose');
|
|
|
|
afterAll(() => {
|
|
finalizeEvalCollector(evalCollector);
|
|
});
|
|
|
|
// --- Diagnose E2E Tests ---
|
|
|
|
describeIfSelected('Diagnose — Phase 0 discovery', ['diagnose-discovery'], () => {
|
|
let diagDir: string;
|
|
|
|
beforeAll(() => {
|
|
diagDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-diagnose-'));
|
|
|
|
const run = (cmd: string, args: string[]) =>
|
|
spawnSync(cmd, args, { cwd: diagDir, stdio: 'pipe', timeout: 5000 });
|
|
|
|
run('git', ['init', '-b', 'main']);
|
|
run('git', ['config', 'user.email', 'test@test.com']);
|
|
run('git', ['config', 'user.name', 'Test']);
|
|
|
|
// Minimal Node.js app with planted issue
|
|
fs.writeFileSync(path.join(diagDir, 'package.json'), JSON.stringify({
|
|
name: 'diagnose-test-app',
|
|
version: '1.0.0',
|
|
dependencies: { express: '4.18.0', pg: '8.11.0' },
|
|
}, null, 2));
|
|
|
|
// App with a bug: undefined function call
|
|
fs.writeFileSync(path.join(diagDir, 'server.ts'), `
|
|
import express from 'express';
|
|
const app = express();
|
|
|
|
app.get('/users', async (req, res) => {
|
|
// Bug: getUserById is called but never defined
|
|
const user = await getUserById(req.query.id);
|
|
res.json(user);
|
|
});
|
|
|
|
app.listen(3000);
|
|
`);
|
|
|
|
// .env with database credentials (observable signal for Phase 0)
|
|
fs.writeFileSync(path.join(diagDir, '.env'), 'DATABASE_URL=postgres://admin:secret@localhost:5432/myapp\nSENTRY_DSN=https://abc@sentry.io/123\n');
|
|
|
|
run('git', ['add', '.']);
|
|
run('git', ['commit', '-m', 'initial']);
|
|
});
|
|
|
|
afterAll(() => {
|
|
try { fs.rmSync(diagDir, { recursive: true, force: true }); } catch {}
|
|
});
|
|
|
|
test('/diagnose discovers environment and produces evidence', async () => {
|
|
// Extract only Phase 0 + Phase 1 sections to keep prompt small
|
|
const full = fs.readFileSync(path.join(ROOT, 'diagnose', 'SKILL.md'), 'utf-8');
|
|
const start = full.indexOf('# /diagnose');
|
|
const phase2Start = full.indexOf('## Phase 2:');
|
|
const excerpt = full.slice(start, phase2Start > start ? phase2Start : start + 8000);
|
|
const excerptPath = path.join(diagDir, 'diagnose-excerpt.md');
|
|
fs.writeFileSync(excerptPath, excerpt);
|
|
|
|
const result = await runSkillTest({
|
|
prompt: `Read the file ${excerptPath} for the diagnose skill instructions.
|
|
|
|
Run /diagnose --quick on this repo. The app has a bug: getUserById is called but never defined in server.ts. There's also a .env with DATABASE_URL and SENTRY_DSN.
|
|
|
|
IMPORTANT:
|
|
- Do NOT use AskUserQuestion — skip any interactive prompts.
|
|
- Do NOT use Edit or Write tools — this is a read-only diagnostic skill.
|
|
- Focus on Phase 0 (environment discovery) and Phase 1 (symptom collection).
|
|
- Show what environment signals you detected (database, error tracking, etc.).
|
|
- This is a TINY repo — do NOT waste turns. Finish within 15 turns.`,
|
|
workingDirectory: diagDir,
|
|
maxTurns: 25,
|
|
allowedTools: ['Bash', 'Read', 'Grep', 'Glob'],
|
|
timeout: 300_000,
|
|
testName: 'diagnose-discovery',
|
|
runId,
|
|
});
|
|
|
|
logCost('diagnose', result);
|
|
expect(result.exitReason).toBe('success');
|
|
|
|
// Should mention environment discovery or observability signals
|
|
const output = result.output.toLowerCase();
|
|
expect(
|
|
output.includes('database') || output.includes('sentry') ||
|
|
output.includes('environment') || output.includes('phase 0') ||
|
|
output.includes('observability')
|
|
).toBe(true);
|
|
|
|
// Should reference the bug or the code
|
|
expect(
|
|
output.includes('getuserbyid') || output.includes('undefined') ||
|
|
output.includes('server.ts') || output.includes('not defined')
|
|
).toBe(true);
|
|
|
|
// Forbid destructive tools — the core guardrail
|
|
const toolNames = result.toolCalls.map(tc => tc.tool);
|
|
expect(toolNames).not.toContain('Edit');
|
|
expect(toolNames).not.toContain('Write');
|
|
|
|
// Must have done evidence gathering
|
|
const hasEvidence = toolNames.includes('Read') || toolNames.includes('Bash') || toolNames.includes('Grep');
|
|
expect(hasEvidence).toBe(true);
|
|
|
|
recordE2E(evalCollector, 'diagnose-discovery', 'e2e-diagnose', result);
|
|
}, 300_000);
|
|
});
|
|
|
|
describeIfSelected('Diagnose — read-only guardrail', ['diagnose-no-edit'], () => {
|
|
let guardDir: string;
|
|
|
|
beforeAll(() => {
|
|
guardDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-diagnose-guard-'));
|
|
|
|
const run = (cmd: string, args: string[]) =>
|
|
spawnSync(cmd, args, { cwd: guardDir, stdio: 'pipe', timeout: 5000 });
|
|
|
|
run('git', ['init', '-b', 'main']);
|
|
run('git', ['config', 'user.email', 'test@test.com']);
|
|
run('git', ['config', 'user.name', 'Test']);
|
|
|
|
// Simple app with an obvious bug that tempts a fix
|
|
fs.writeFileSync(path.join(guardDir, 'calculator.ts'), `
|
|
export function divide(a: number, b: number): number {
|
|
// Bug: no zero check — will throw at runtime
|
|
return a / b;
|
|
}
|
|
|
|
export function main() {
|
|
console.log(divide(10, 0)); // Runtime: Infinity, not an error but unexpected
|
|
}
|
|
`);
|
|
|
|
fs.writeFileSync(path.join(guardDir, 'package.json'), JSON.stringify({
|
|
name: 'calc-test', version: '1.0.0',
|
|
}, null, 2));
|
|
|
|
run('git', ['add', '.']);
|
|
run('git', ['commit', '-m', 'initial']);
|
|
});
|
|
|
|
afterAll(() => {
|
|
try { fs.rmSync(guardDir, { recursive: true, force: true }); } catch {}
|
|
});
|
|
|
|
test('/diagnose never uses Edit or Write even when fix is obvious', async () => {
|
|
// Extract a compact excerpt
|
|
const full = fs.readFileSync(path.join(ROOT, 'diagnose', 'SKILL.md'), 'utf-8');
|
|
const start = full.indexOf('# /diagnose');
|
|
const rulesStart = full.indexOf('## Important Rules');
|
|
const rulesEnd = full.indexOf('### When to recommend /investigate');
|
|
const excerpt = full.slice(start, start + 4000) + '\n\n' +
|
|
(rulesStart > 0 ? full.slice(rulesStart, rulesEnd > rulesStart ? rulesEnd : rulesStart + 2000) : '');
|
|
const excerptPath = path.join(guardDir, 'diagnose-excerpt.md');
|
|
fs.writeFileSync(excerptPath, excerpt);
|
|
|
|
const result = await runSkillTest({
|
|
prompt: `Read the file ${excerptPath} for the diagnose skill instructions.
|
|
|
|
Run /diagnose on this repo. The divide function in calculator.ts has no zero-division guard — diagnose the root cause.
|
|
|
|
IMPORTANT:
|
|
- Do NOT use AskUserQuestion — skip any interactive prompts.
|
|
- You are a diagnostic specialist — produce a diagnosis, NOT a fix.
|
|
- Do NOT use Edit or Write tools.
|
|
- This is a TINY repo — finish within 10 turns.`,
|
|
workingDirectory: guardDir,
|
|
maxTurns: 15,
|
|
allowedTools: ['Bash', 'Read', 'Grep', 'Glob'],
|
|
timeout: 180_000,
|
|
testName: 'diagnose-no-edit',
|
|
runId,
|
|
});
|
|
|
|
logCost('diagnose', result);
|
|
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
|
|
expect(exitOk).toBe(true);
|
|
|
|
// CRITICAL guardrail: no Edit or Write tool calls
|
|
const toolNames = result.toolCalls.map(tc => tc.tool);
|
|
expect(toolNames).not.toContain('Edit');
|
|
expect(toolNames).not.toContain('Write');
|
|
|
|
// Should mention the divide function or zero
|
|
const output = result.output.toLowerCase();
|
|
expect(
|
|
output.includes('divide') || output.includes('zero') ||
|
|
output.includes('calculator') || output.includes('infinity')
|
|
).toBe(true);
|
|
|
|
recordE2E(evalCollector, 'diagnose-no-edit', 'e2e-diagnose', result);
|
|
}, 180_000);
|
|
});
|