diff --git a/package.json b/package.json index f98025e..a3f951e 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "prepublishOnly": "npm run build", "version:bump": "npx tsx scripts/bump-version.ts", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:integration": "vitest run --config vitest.integration.config.ts" }, "keywords": [ "claude", diff --git a/tests/integration/ambient-activation.test.ts b/tests/integration/ambient-activation.test.ts new file mode 100644 index 0000000..58b74a5 --- /dev/null +++ b/tests/integration/ambient-activation.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { + isClaudeAvailable, + runClaude, + hasClassification, + isQuietResponse, + extractIntent, + extractDepth, +} from './helpers.js'; + +/** + * Integration tests for ambient mode skill activation. + * + * These tests require: + * - `claude` CLI installed and authenticated + * - Ambient mode enabled (`devflow ambient --enable`) + * - DevFlow skills installed (`devflow init`) + * + * Run manually: npm run test:integration + * Not part of `npm test` — each test is an API call. + */ +describe.skipIf(!isClaudeAvailable())('ambient classification', () => { + // QUICK tier — no skills loaded, no classification output + it('classifies "thanks" as QUICK (silent)', () => { + const output = runClaude('thanks'); + expect(isQuietResponse(output)).toBe(true); + }); + + it('classifies "commit this" as QUICK (git op)', () => { + const output = runClaude('commit the current changes'); + // Git operations should not trigger STANDARD classification + expect(isQuietResponse(output) || extractDepth(output) === 'QUICK').toBe(true); + }); + + // STANDARD tier — skills referenced in output + it('classifies "add a login form" as BUILD/STANDARD', () => { + const output = runClaude('add a login form with email and password fields'); + if (hasClassification(output)) { + expect(extractIntent(output)).toBe('BUILD'); + expect(extractDepth(output)).toBe('STANDARD'); + } + // Even without explicit classification, BUILD prompts should reference TDD + expect( + output.toLowerCase().includes('test') || + output.toLowerCase().includes('tdd') || + hasClassification(output) + ).toBe(true); + }); + + it('classifies "fix the auth error" as DEBUG/STANDARD', () => { + const output = runClaude('fix the authentication error in the login handler'); + if (hasClassification(output)) { + expect(extractIntent(output)).toBe('DEBUG'); + expect(['STANDARD', 'ESCALATE']).toContain(extractDepth(output)); + } + }); +}); diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts new file mode 100644 index 0000000..067abbf --- /dev/null +++ b/tests/integration/helpers.ts @@ -0,0 +1,64 @@ +import { execSync } from 'child_process'; + +/** + * Check if the `claude` CLI is available on this machine. + */ +export function isClaudeAvailable(): boolean { + try { + execSync('claude --version', { stdio: 'pipe', timeout: 5000 }); + return true; + } catch { + return false; + } +} + +/** + * Run a prompt through claude CLI in non-interactive mode. + * Returns the text output. + */ +export function runClaude(prompt: string, options?: { timeout?: number }): string { + const timeout = options?.timeout ?? 30000; + + const result = execSync( + `claude -p --output-format text --model haiku "${prompt.replace(/"/g, '\\"')}"`, + { + stdio: 'pipe', + timeout, + encoding: 'utf-8', + }, + ); + + return result.trim(); +} + +/** + * Assert that output contains a classification marker (case-insensitive). + * Classification markers look like: "Ambient: BUILD/STANDARD" + */ +export function hasClassification(output: string): boolean { + return /ambient:\s*(BUILD|DEBUG|REVIEW|PLAN|EXPLORE|CHAT)\s*\/\s*(QUICK|STANDARD|ESCALATE)/i.test(output); +} + +/** + * Assert that output does NOT contain a classification marker. + * QUICK responses should be silent — no classification output. + */ +export function isQuietResponse(output: string): boolean { + return !hasClassification(output); +} + +/** + * Extract the intent from a classification marker. + */ +export function extractIntent(output: string): string | null { + const match = output.match(/ambient:\s*(BUILD|DEBUG|REVIEW|PLAN|EXPLORE|CHAT)/i); + return match ? match[1].toUpperCase() : null; +} + +/** + * Extract the depth from a classification marker. + */ +export function extractDepth(output: string): string | null { + const match = output.match(/ambient:\s*\w+\s*\/\s*(QUICK|STANDARD|ESCALATE)/i); + return match ? match[1].toUpperCase() : null; +} diff --git a/vitest.config.ts b/vitest.config.ts index 976169b..d3b56a2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { root: '.', include: ['tests/**/*.test.ts'], + exclude: ['tests/integration/**'], globals: false, environment: 'node', restoreMocks: true, diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts new file mode 100644 index 0000000..3b78284 --- /dev/null +++ b/vitest.integration.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + root: '.', + include: ['tests/integration/**/*.test.ts'], + globals: false, + environment: 'node', + restoreMocks: true, + testTimeout: 60000, + retry: 2, + }, + resolve: { + alias: { + '#cli': new URL('./src/cli/', import.meta.url).pathname, + }, + }, +});