|
1 | 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; |
2 | 2 | import { execSync } from 'child_process'; |
3 | | -import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; |
| 3 | +import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs'; |
4 | 4 | import { join, resolve } from 'path'; |
5 | 5 | import { fileURLToPath } from 'url'; |
6 | 6 | import { dirname } from 'path'; |
| 7 | +import { computeContentFingerprint, computeFingerprint } from './lib/fingerprint.js'; |
| 8 | +import { loadConfig } from './lib/config.js'; |
7 | 9 |
|
8 | 10 | const __filename = fileURLToPath(import.meta.url); |
9 | 11 | const __dirname = dirname(__filename); |
@@ -51,9 +53,9 @@ describe('CLI', () => { |
51 | 53 | }); |
52 | 54 |
|
53 | 55 | describe('run command', () => { |
54 | | - it('shows error when config file does not exist', () => { |
55 | | - const result = runCli(['run', '/non/existent/config.ts']); |
56 | | - expect(result.stderr).toContain('not found'); |
| 56 | + it('shows error when the named experiment does not exist', () => { |
| 57 | + const result = runCli(['run', 'no-such-experiment']); |
| 58 | + expect(result.stderr.toLowerCase()).toMatch(/no experiments matched|required/); |
57 | 59 | expect(result.exitCode).toBe(1); |
58 | 60 | }); |
59 | 61 |
|
@@ -170,4 +172,164 @@ describe('CLI', () => { |
170 | 172 | expect(result.exitCode).toBe(1); |
171 | 173 | }); |
172 | 174 | }); |
| 175 | + |
| 176 | + describe('refingerprint command', () => { |
| 177 | + function setupProject(): { projectDir: string; evalPath: string; summaryPath: string } { |
| 178 | + const projectDir = join(TEST_DIR, 'refp'); |
| 179 | + const experimentsDir = join(projectDir, 'experiments'); |
| 180 | + mkdirSync(experimentsDir, { recursive: true }); |
| 181 | + writeFileSync(join(experimentsDir, 'cc.ts'), `export default { agent: 'claude-code', model: 'opus' };`); |
| 182 | + const evalDir = join(projectDir, 'evals', 'eval-1'); |
| 183 | + mkdirSync(evalDir, { recursive: true }); |
| 184 | + writeFileSync(join(evalDir, 'PROMPT.md'), 'do it'); |
| 185 | + writeFileSync(join(evalDir, 'EVAL.ts'), 'test code'); |
| 186 | + writeFileSync(join(evalDir, 'package.json'), '{"type":"module"}'); |
| 187 | + const summaryDir = join(projectDir, 'results', 'cc', '2026-01-01T00-00-00.000Z', 'eval-1'); |
| 188 | + mkdirSync(summaryDir, { recursive: true }); |
| 189 | + return { projectDir, evalPath: evalDir, summaryPath: join(summaryDir, 'summary.json') }; |
| 190 | + } |
| 191 | + |
| 192 | + it('carries forward a config-only change but never masks a content change', () => { |
| 193 | + const { projectDir, evalPath, summaryPath } = setupProject(); |
| 194 | + const currentContent = computeContentFingerprint(evalPath); |
| 195 | + |
| 196 | + // Config-only change: stored content fp matches current, combined is stale. |
| 197 | + writeFileSync( |
| 198 | + summaryPath, |
| 199 | + JSON.stringify({ |
| 200 | + totalRuns: 1, passedRuns: 1, passRate: '100%', meanDuration: 1, |
| 201 | + fingerprint: 'STALE_COMBINED', contentFingerprint: currentContent, |
| 202 | + }) |
| 203 | + ); |
| 204 | + let r = runCli(['refingerprint'], projectDir); |
| 205 | + expect(r.exitCode).toBe(0); |
| 206 | + let summary = JSON.parse(readFileSync(summaryPath, 'utf-8')); |
| 207 | + expect(summary.fingerprint).not.toBe('STALE_COMBINED'); // carried forward |
| 208 | + expect(summary.fingerprint).toMatch(/^[a-f0-9]{64}$/); |
| 209 | + expect(summary.contentFingerprint).toBe(currentContent); // content untouched |
| 210 | + |
| 211 | + // Content change: stored content fp differs → must be left stale, NOT masked. |
| 212 | + writeFileSync( |
| 213 | + summaryPath, |
| 214 | + JSON.stringify({ |
| 215 | + totalRuns: 1, passedRuns: 1, passRate: '100%', meanDuration: 1, |
| 216 | + fingerprint: 'OLD_COMBINED', contentFingerprint: 'OLD_CONTENT', |
| 217 | + }) |
| 218 | + ); |
| 219 | + r = runCli(['refingerprint'], projectDir); |
| 220 | + expect(r.exitCode).toBe(0); |
| 221 | + summary = JSON.parse(readFileSync(summaryPath, 'utf-8')); |
| 222 | + expect(summary.fingerprint).toBe('OLD_COMBINED'); // NOT re-stamped |
| 223 | + expect(summary.contentFingerprint).toBe('OLD_CONTENT'); |
| 224 | + }); |
| 225 | + |
| 226 | + it('--dry does not write', () => { |
| 227 | + const { projectDir, evalPath, summaryPath } = setupProject(); |
| 228 | + const currentContent = computeContentFingerprint(evalPath); |
| 229 | + writeFileSync( |
| 230 | + summaryPath, |
| 231 | + JSON.stringify({ |
| 232 | + totalRuns: 1, passedRuns: 1, passRate: '100%', meanDuration: 1, |
| 233 | + fingerprint: 'STALE_COMBINED', contentFingerprint: currentContent, |
| 234 | + }) |
| 235 | + ); |
| 236 | + const r = runCli(['refingerprint', '--dry'], projectDir); |
| 237 | + expect(r.exitCode).toBe(0); |
| 238 | + const summary = JSON.parse(readFileSync(summaryPath, 'utf-8')); |
| 239 | + expect(summary.fingerprint).toBe('STALE_COMBINED'); // unchanged under --dry |
| 240 | + }); |
| 241 | + }); |
| 242 | + |
| 243 | + describe('staleness flow (status / refingerprint / --check / --json)', () => { |
| 244 | + it('fresh → change → status + --check + --json flag it; refingerprint stays honest; a rerun clears it', async () => { |
| 245 | + const projectDir = join(TEST_DIR, 'flow'); |
| 246 | + const experimentsDir = join(projectDir, 'experiments'); |
| 247 | + mkdirSync(experimentsDir, { recursive: true }); |
| 248 | + writeFileSync(join(experimentsDir, 'cc.ts'), `export default { agent: 'claude-code', model: 'opus' };`); |
| 249 | + const evalDir = join(projectDir, 'evals', 'eval-1'); |
| 250 | + mkdirSync(evalDir, { recursive: true }); |
| 251 | + writeFileSync(join(evalDir, 'PROMPT.md'), 'do it'); |
| 252 | + writeFileSync(join(evalDir, 'EVAL.ts'), 'v1'); |
| 253 | + writeFileSync(join(evalDir, 'package.json'), '{"type":"module"}'); |
| 254 | + const summaryPath = join(projectDir, 'results', 'cc', '2026-01-01T00-00-00.000Z', 'eval-1', 'summary.json'); |
| 255 | + mkdirSync(dirname(summaryPath), { recursive: true }); |
| 256 | + |
| 257 | + const config = await loadConfig(join(experimentsDir, 'cc.ts')); |
| 258 | + const modelConfig = { ...config, model: Array.isArray(config.model) ? config.model[0] : config.model }; |
| 259 | + const seedFresh = () => |
| 260 | + writeFileSync( |
| 261 | + summaryPath, |
| 262 | + JSON.stringify({ |
| 263 | + totalRuns: 1, passedRuns: 1, passRate: '100%', meanDuration: 1, |
| 264 | + fingerprint: computeFingerprint(evalDir, modelConfig as never), |
| 265 | + contentFingerprint: computeContentFingerprint(evalDir), |
| 266 | + }) |
| 267 | + ); |
| 268 | + |
| 269 | + // 1. Fresh → status clean, --check passes. |
| 270 | + seedFresh(); |
| 271 | + expect(runCli(['status'], projectDir).stdout).toContain('up to date'); |
| 272 | + expect(runCli(['status', '--check'], projectDir).exitCode).toBe(0); |
| 273 | + |
| 274 | + // 2. Eval content changes → status flags it, --check fails, --json reports it. |
| 275 | + writeFileSync(join(evalDir, 'EVAL.ts'), 'v2'); |
| 276 | + const s = runCli(['status'], projectDir).stdout; |
| 277 | + expect(s).toContain('changed'); |
| 278 | + expect(s).toContain('eval-1'); |
| 279 | + expect(runCli(['status', '--check'], projectDir).exitCode).toBe(1); |
| 280 | + const json = JSON.parse(runCli(['status', '--json'], projectDir).stdout); |
| 281 | + expect(json.work).toEqual([{ experiment: 'cc', new: [], changed: ['eval-1'] }]); |
| 282 | + |
| 283 | + // 3. refingerprint must NOT mask a content change — still failing. |
| 284 | + runCli(['refingerprint'], projectDir); |
| 285 | + expect(runCli(['status', '--check'], projectDir).exitCode).toBe(1); |
| 286 | + |
| 287 | + // 4. A rerun (simulated by re-seeding fresh for the new content) clears it. |
| 288 | + seedFresh(); |
| 289 | + expect(runCli(['status', '--check'], projectDir).exitCode).toBe(0); |
| 290 | + }); |
| 291 | + |
| 292 | + it('status reports new and changed evals as the work to do', async () => { |
| 293 | + const projectDir = join(TEST_DIR, 'status'); |
| 294 | + const experimentsDir = join(projectDir, 'experiments'); |
| 295 | + mkdirSync(experimentsDir, { recursive: true }); |
| 296 | + writeFileSync(join(experimentsDir, 'cc.ts'), `export default { agent: 'claude-code', model: 'opus' };`); |
| 297 | + const evalDir = join(projectDir, 'evals', 'eval-1'); |
| 298 | + mkdirSync(evalDir, { recursive: true }); |
| 299 | + writeFileSync(join(evalDir, 'PROMPT.md'), 'do it'); |
| 300 | + writeFileSync(join(evalDir, 'EVAL.ts'), 'v1'); |
| 301 | + writeFileSync(join(evalDir, 'package.json'), '{"type":"module"}'); |
| 302 | + |
| 303 | + const config = await loadConfig(join(experimentsDir, 'cc.ts')); |
| 304 | + const modelConfig = { ...config, model: Array.isArray(config.model) ? config.model[0] : config.model }; |
| 305 | + const sp = join(projectDir, 'results', 'cc', '2026-01-01T00-00-00.000Z', 'eval-1', 'summary.json'); |
| 306 | + mkdirSync(dirname(sp), { recursive: true }); |
| 307 | + writeFileSync( |
| 308 | + sp, |
| 309 | + JSON.stringify({ |
| 310 | + totalRuns: 1, passedRuns: 1, passRate: '100%', meanDuration: 1, |
| 311 | + fingerprint: computeFingerprint(evalDir, modelConfig as never), |
| 312 | + contentFingerprint: computeContentFingerprint(evalDir), |
| 313 | + }) |
| 314 | + ); |
| 315 | + |
| 316 | + // Up to date. |
| 317 | + expect(runCli(['status'], projectDir).stdout).toContain('up to date'); |
| 318 | + |
| 319 | + // Add a NEW eval (no result) + CHANGE the existing one. |
| 320 | + const evalDir2 = join(projectDir, 'evals', 'eval-2'); |
| 321 | + mkdirSync(evalDir2, { recursive: true }); |
| 322 | + writeFileSync(join(evalDir2, 'PROMPT.md'), 'do it'); |
| 323 | + writeFileSync(join(evalDir2, 'EVAL.ts'), 'x'); |
| 324 | + writeFileSync(join(evalDir2, 'package.json'), '{"type":"module"}'); |
| 325 | + writeFileSync(join(evalDir, 'EVAL.ts'), 'v2'); |
| 326 | + |
| 327 | + const out = runCli(['status'], projectDir).stdout; |
| 328 | + expect(out).toContain('new'); // eval-2 |
| 329 | + expect(out).toContain('eval-2'); |
| 330 | + expect(out).toContain('changed'); // eval-1 |
| 331 | + expect(out).toContain('eval-1'); |
| 332 | + expect(out).toContain('to run'); |
| 333 | + }); |
| 334 | + }); |
173 | 335 | }); |
0 commit comments