/** * WS8: Migrate mode reliability improvements - Comprehensive tests * * Tests cover: * 1. Mixed legacy and modern config detection * 2. Dry-run shows exact rewrites (file path, line number, legacy text, replacement text) * 3. Write performs rewrites correctly * 4. Ambiguous rewrite stops and shows context * 5. Legacy field with no equivalent emits guidance * 6. Partial migration reports completed and remaining * 7. Preserves comments/formatting where feasible * 8. Migrate exits 0 when config is already modern * 9. Migrate exits 2 when ambiguous in write mode * 10. Migrate emits guidance for each legacy field * 11. Config rewriter replaces legacy fields * 12. Route rewriter detects x-validate-runtime annotation * 13. Code rewriter detects legacy patterns * * Architecture: * - Dependency injection: all dependencies passed explicitly * - No optional imports * - Inline comments for documentation * - Property and state model-based testing focused on confidence * - Iterative small steps with rapid feedback loops */ import { test } from 'node:test'; import assert from 'node:assert'; import { writeFileSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { migrateCommand, detectAllLegacyPatterns, discoverMigrationFiles, type MigrateOptions, type MigrationItem, } from '../../cli/commands/migrate/index.js'; import { rewriteConfigFile, detectLegacyConfigFields, detectLegacyFieldsNoEquivalent, detectMixedLegacyModernFields, } from '../../cli/commands/migrate/rewriters/config-rewriter.js'; import { rewriteRouteAnnotations, detectLegacyRouteAnnotations, detectAmbiguousRoutePatterns, } from '../../cli/commands/migrate/rewriters/route-rewriter.js'; import { rewriteCodePatterns, detectLegacyCodePatterns, detectAmbiguousCodePatterns, } from '../../cli/commands/migrate/rewriters/code-rewriter.js'; import { createTempDir, cleanup, makeCtx } from './helpers.js'; test('migrate --check detects broad legacy config field set', async () => { const dir = createTempDir(); try { const legacyConfig = `export default { testMode: "verify", testProfiles: { quick: { usesPreset: "safe-ci", routeFilter: ["GET /legacy"], }, }, testPresets: { "safe-ci": { testDepth: "quick", maxDuration: 5000, }, }, envPolicies: { local: { canVerify: true, }, }, };`; writeFileSync(resolve(dir, 'apophis.config.js'), legacyConfig); const ctx = makeCtx({ cwd: dir }); const result = await migrateCommand({ check: true }, ctx); assert.strictEqual(result.exitCode, 1, 'Should exit 1 when legacy patterns are found'); const legacyNames = result.items.map((item) => item.legacy); assert.ok(legacyNames.includes('testMode'), 'Should detect testMode'); assert.ok(legacyNames.includes('testProfiles'), 'Should detect testProfiles'); assert.ok(legacyNames.includes('usesPreset'), 'Should detect usesPreset'); assert.ok(legacyNames.includes('routeFilter'), 'Should detect routeFilter'); assert.ok(legacyNames.includes('testPresets'), 'Should detect testPresets'); assert.ok(legacyNames.includes('testDepth'), 'Should detect testDepth'); assert.ok(legacyNames.includes('maxDuration'), 'Should detect maxDuration'); assert.ok(legacyNames.includes('envPolicies'), 'Should detect envPolicies'); assert.ok(legacyNames.includes('canVerify'), 'Should detect canVerify'); } finally { cleanup(dir); } }); // --------------------------------------------------------------------------- // Test 1: Mixed legacy and modern config detection // --------------------------------------------------------------------------- test('migrate detects mixed legacy and modern config fields', async () => { const dir = createTempDir(); try { // Config with both legacy and modern fields present const mixedConfig = `export default { // Legacy field testMode: "verify", // Modern field (conflicts with legacy) mode: "observe", profiles: { quick: { preset: "safe-ci", }, }, // Legacy container testProfiles: { old: { usesPreset: "legacy", }, }, };`; writeFileSync(resolve(dir, 'apophis.config.js'), mixedConfig); const ctx = makeCtx({ cwd: dir }); const result = await migrateCommand({ check: true }, ctx); // Should detect legacy patterns assert.strictEqual(result.exitCode, 1, 'Should exit 1 when legacy patterns found'); assert.ok(result.items.length > 0, 'Should detect legacy items'); // Check that mixed fields are reported const legacyNames = result.items.map((item) => item.legacy); assert.ok(legacyNames.includes('testMode'), 'Should detect testMode'); assert.ok(legacyNames.includes('testProfiles'), 'Should detect testProfiles'); assert.ok(legacyNames.includes('usesPreset'), 'Should detect usesPreset'); // Verify guidance mentions the conflict const testModeItem = result.items.find((item) => item.legacy === 'testMode'); assert.ok(testModeItem, 'Should have testMode item'); assert.ok(testModeItem.guidance, 'Should have guidance for testMode'); } finally { cleanup(dir); } }); // --------------------------------------------------------------------------- // Test 2: Dry-run shows exact rewrites // --------------------------------------------------------------------------- test('migrate dry-run shows exact file path, line number, legacy text, replacement text', async () => { const dir = createTempDir(); try { const legacyConfig = `export default { // Line 2 testMode: "verify", profiles: { quick: { // Line 7 usesPreset: "safe-ci", }, }, };`; writeFileSync(resolve(dir, 'apophis.config.js'), legacyConfig); const ctx = makeCtx({ cwd: dir }); const result = await migrateCommand({ dryRun: true }, ctx); assert.strictEqual(result.exitCode, 1, 'Should exit 1 when legacy patterns found'); assert.ok(result.message, 'Should have output message'); // Verify dry-run output contains exact details assert.ok(result.message.includes('Dry run'), 'Should indicate dry run'); assert.ok(result.message.includes('testMode'), 'Should show legacy text'); assert.ok(result.message.includes('mode'), 'Should show replacement text'); assert.ok(result.message.includes('usesPreset'), 'Should show usesPreset'); assert.ok(result.message.includes('preset'), 'Should show preset replacement'); // Verify file path is shown assert.ok(result.message.includes('apophis.config.js'), 'Should show file path'); // Verify line numbers are shown assert.ok(result.message.includes(':2') || result.message.includes(': 2'), 'Should show line number'); // Verify total count assert.ok(result.message.includes('Total:'), 'Should show total count'); assert.ok(result.message.includes('3'), 'Should show correct total (3 items)'); // Verify files would be modified assert.ok(result.filesWouldBeModified, 'Should list files that would be modified'); assert.strictEqual(result.filesWouldBeModified.length, 1, 'Should show 1 file would be modified'); // Verify file was NOT modified const content = readFileSync(resolve(dir, 'apophis.config.js'), 'utf-8'); assert.ok(content.includes('testMode'), 'File should still have testMode'); assert.ok(!content.includes('mode:'), 'File should not have been rewritten'); } finally { cleanup(dir); } }); // --------------------------------------------------------------------------- // Test 3: Write performs rewrites correctly // --------------------------------------------------------------------------- test('migrate write performs rewrites correctly', async () => { const dir = createTempDir(); try { const legacyConfig = `export default { testMode: "verify", testProfiles: { quick: { usesPreset: "safe-ci", }, }, };`; writeFileSync(resolve(dir, 'apophis.config.js'), legacyConfig); const ctx = makeCtx({ cwd: dir }); const result = await migrateCommand({ write: true }, ctx); assert.strictEqual(result.exitCode, 1, 'Should exit 1 when rewrites performed'); assert.ok(result.completed.length > 0, 'Should have completed items'); assert.ok(result.filesModified && result.filesModified.length > 0, 'Should list modified files'); // Verify file WAS modified const content = readFileSync(resolve(dir, 'apophis.config.js'), 'utf-8'); assert.ok(!content.includes('testMode'), 'File should not have testMode'); assert.ok(content.includes('mode:'), 'File should have mode'); assert.ok(!content.includes('testProfiles'), 'File should not have testProfiles'); assert.ok(content.includes('profiles:'), 'File should have profiles'); assert.ok(!content.includes('usesPreset'), 'File should not have usesPreset'); assert.ok(content.includes('preset:'), 'File should have preset'); } finally { cleanup(dir); } }); // --------------------------------------------------------------------------- // Test 4: Ambiguous rewrite stops and shows context // --------------------------------------------------------------------------- test('migrate ambiguous rewrite stops and shows surrounding context', async () => { const dir = createTempDir(); try { // Create a file with an ambiguous code pattern const ambiguousCode = `import Fastify from 'fastify'; const app = Fastify(); // This is ambiguous: what does oldApi() mean here? app.register(oldApi()); export default app;`; writeFileSync(resolve(dir, 'app.js'), ambiguousCode); // Also create a config file so migration has something to work with const config = `export default { mode: "verify", };`; writeFileSync(resolve(dir, 'apophis.config.js'), config); const ctx = makeCtx({ cwd: dir }); const result = await migrateCommand({ write: true }, ctx); // Should stop with exit code 2 (USAGE_ERROR) because ambiguous patterns found assert.strictEqual(result.exitCode, 2, 'Should exit 2 when ambiguous patterns found in write mode'); assert.ok(result.remaining.length > 0, 'Should have remaining items'); assert.ok(result.message, 'Should have output message'); assert.ok(result.message.includes('Ambiguous'), 'Should mention ambiguous patterns'); assert.ok(result.message.includes('oldApi()'), 'Should show the ambiguous pattern'); assert.ok(result.message.includes('manual choice'), 'Should mention manual choice'); // Verify context is shown (surrounding lines) assert.ok(result.message.includes('app.register'), 'Should show surrounding context'); } finally { cleanup(dir); } }); // --------------------------------------------------------------------------- // Test 5: Legacy field with no equivalent emits guidance // --------------------------------------------------------------------------- test('migrate legacy field with no direct equivalent emits human guidance', async () => { const dir = createTempDir(); try { // Config with a legacy field that has no direct equivalent const legacyConfig = `export default { mode: "verify", profiles: { quick: { preset: "safe-ci", }, }, // This field is deprecated with no direct equivalent legacyField: true, };`; writeFileSync(resolve(dir, 'apophis.config.js'), legacyConfig); const ctx = makeCtx({ cwd: dir }); const result = await migrateCommand({ check: true }, ctx); // Should detect the legacy field with no equivalent assert.strictEqual(result.exitCode, 1, 'Should exit 1 when legacy patterns found'); assert.ok(result.items.length > 0, 'Should detect legacy items'); const legacyFieldItem = result.items.find((item) => item.legacy === 'legacyField'); assert.ok(legacyFieldItem, 'Should detect legacyField'); assert.ok(legacyFieldItem.guidance, 'Should have guidance for legacyField'); assert.ok( legacyFieldItem.guidance.includes('no modern equivalent') || legacyFieldItem.guidance.includes('Remove'), 'Guidance should mention removal or no equivalent', ); assert.strictEqual( legacyFieldItem.replacement, '(removed — see guidance)', 'Replacement should indicate removal', ); } finally { cleanup(dir); } }); // --------------------------------------------------------------------------- // Test 6: Partial migration reports completed and remaining // --------------------------------------------------------------------------- test('migrate partial migration reports completed and remaining items', async () => { const dir = createTempDir(); try { const legacyConfig = `export default { testMode: "verify", testProfiles: { quick: { usesPreset: "safe-ci", }, }, };`; writeFileSync(resolve(dir, 'apophis.config.js'), legacyConfig); const ctx = makeCtx({ cwd: dir }); const result = await migrateCommand({ write: true }, ctx); assert.ok(result.completed.length > 0, 'Should have completed items'); assert.ok(result.message, 'Should have output message'); assert.ok(result.message.includes('Completed'), 'Should mention completed'); assert.ok(result.message.includes('Migration complete'), 'Should indicate completion'); } finally { cleanup(dir); } }); // --------------------------------------------------------------------------- // Test 7: Preserves comments/formatting where feasible // --------------------------------------------------------------------------- test('migrate preserves comments and formatting where feasible', async () => { const dir = createTempDir(); try { // Config with specific formatting (comments, indentation) const legacyConfig = `export default { // This is a comment about testMode testMode: "verify", /* * Block comment about testProfiles */ testProfiles: { quick: { // Inline comment usesPreset: "safe-ci", }, }, };`; writeFileSync(resolve(dir, 'apophis.config.js'), legacyConfig); const ctx = makeCtx({ cwd: dir }); const result = await migrateCommand({ write: true }, ctx); const content = readFileSync(resolve(dir, 'apophis.config.js'), 'utf-8'); // Verify comments are preserved assert.ok(content.includes('// This is a comment about testMode'), 'Should preserve line comment'); assert.ok(content.includes('Block comment about testProfiles'), 'Should preserve block comment'); assert.ok(content.includes('// Inline comment'), 'Should preserve inline comment'); // Verify replacements were made assert.ok(content.includes('mode:'), 'Should have mode'); assert.ok(content.includes('profiles:'), 'Should have profiles'); assert.ok(content.includes('preset:'), 'Should have preset'); } finally { cleanup(dir); } }); // --------------------------------------------------------------------------- // Test 8: Migrate exits 0 when config is already modern // --------------------------------------------------------------------------- test('migrate exits 0 when config is already modern', async () => { const dir = createTempDir(); try { const modernConfig = `export default { mode: "verify", profiles: { quick: { preset: "safe-ci", routes: ["GET /users"], }, }, presets: { "safe-ci": { , timeout: 5000, }, }, environments: { local: { allowVerify: true, }, }, };`; writeFileSync(resolve(dir, 'apophis.config.js'), modernConfig); const ctx = makeCtx({ cwd: dir }); const result = await migrateCommand({ check: true }, ctx); assert.strictEqual(result.exitCode, 0, 'Should exit 0 for modern config'); assert.strictEqual(result.items.length, 0, 'Should have no items'); assert.ok(result.message, 'Should have message'); assert.ok(result.message.includes('up to date'), 'Should indicate up to date'); } finally { cleanup(dir); } }); // --------------------------------------------------------------------------- // Test 9: Migrate exits 2 when ambiguous in write mode // --------------------------------------------------------------------------- test('migrate exits 2 when ambiguous patterns found in write mode', async () => { const dir = createTempDir(); try { const config = `export default { mode: "verify", };`; writeFileSync(resolve(dir, 'apophis.config.js'), config); // Create app with an ambiguous pattern const code = `import Fastify from 'fastify'; const app = Fastify(); // Ambiguous pattern app.register(oldApi()); export default app;`; writeFileSync(resolve(dir, 'app.js'), code); const ctx = makeCtx({ cwd: dir }); const result = await migrateCommand({ write: true }, ctx); // Should exit 2 because ambiguous patterns found assert.strictEqual(result.exitCode, 2, 'Should exit 2 when ambiguous patterns found in write mode'); assert.ok(result.remaining.length > 0, 'Should have remaining ambiguous items'); assert.ok((result.manualChoicesRequired ?? 0) > 0, 'Should indicate manual choices required'); } finally { cleanup(dir); } }); // --------------------------------------------------------------------------- // Test 10: Migrate emits guidance for each legacy field // --------------------------------------------------------------------------- test('migrate emits guidance for each legacy field', async () => { const dir = createTempDir(); try { const legacyConfig = `export default { testMode: "verify", testProfiles: { quick: { usesPreset: "safe-ci", }, }, };`; writeFileSync(resolve(dir, 'apophis.config.js'), legacyConfig); const ctx = makeCtx({ cwd: dir }); const result = await migrateCommand({ check: true }, ctx); assert.ok(result.items.length > 0, 'Should have items'); for (const item of result.items) { assert.ok(item.guidance, `Item ${item.legacy} should have guidance`); assert.ok( item.guidance.includes('Replace') || item.guidance.includes('with') || item.guidance.includes('Remove'), `Guidance for ${item.legacy} should mention replacement or removal`, ); } } finally { cleanup(dir); } }); // --------------------------------------------------------------------------- // Test 11: Config rewriter replaces legacy fields // --------------------------------------------------------------------------- test('config rewriter replaces legacy fields', () => { const dir = createTempDir(); try { const content = `export default { testMode: "verify", testProfiles: { quick: { usesPreset: "safe-ci", }, }, };`; writeFileSync(resolve(dir, 'test.config.js'), content); const items = detectLegacyConfigFields(content, 'test.config.js'); assert.strictEqual(items.length, 3, 'Should detect 3 legacy fields'); const result = rewriteConfigFile( resolve(dir, 'test.config.js'), items, ); assert.strictEqual(result.modified, true, 'Should modify content'); assert.ok(result.content.includes('mode:'), 'Should have mode'); assert.ok(result.content.includes('profiles:'), 'Should have profiles'); assert.ok(result.content.includes('preset:'), 'Should have preset'); assert.ok(!result.content.includes('testMode'), 'Should not have testMode'); } finally { cleanup(dir); } }); // --------------------------------------------------------------------------- // Test 12: Route rewriter detects x-validate-runtime annotation // --------------------------------------------------------------------------- test('route rewriter detects x-validate-runtime annotation', () => { const dir = createTempDir(); try { const content = `export default { schema: { 'x-validate-runtime': true, }, };`; writeFileSync(resolve(dir, 'test.routes.js'), content); const items = detectLegacyRouteAnnotations(content, 'test.routes.js'); assert.strictEqual(items.length, 1, 'Should detect 1 legacy annotation'); const firstItem = items[0]; assert.ok(firstItem, 'Expected one migration item'); assert.strictEqual(firstItem.legacy, 'x-validate-runtime'); assert.strictEqual(firstItem.replacement, 'runtime'); const result = rewriteRouteAnnotations( resolve(dir, 'test.routes.js'), items, ); assert.strictEqual(result.modified, true, 'Should modify content'); assert.ok(result.content.includes("'runtime'"), 'Should have runtime'); assert.ok(!result.content.includes('x-validate-runtime'), 'Should not have legacy annotation'); } finally { cleanup(dir); } }); // --------------------------------------------------------------------------- // Test 13: Code rewriter detects legacy patterns // --------------------------------------------------------------------------- test('code rewriter detects legacy patterns', () => { const dir = createTempDir(); try { const content = `import Fastify from 'fastify'; const app = Fastify(); app.register(contract()); app.register(stateful()); app.register(scenario()); export default app;`; writeFileSync(resolve(dir, 'test.app.js'), content); const items = detectLegacyCodePatterns(content, 'test.app.js'); assert.strictEqual(items.length, 3, 'Should detect 3 legacy patterns'); const result = rewriteCodePatterns( resolve(dir, 'test.app.js'), items, ); assert.strictEqual(result.modified, true, 'Should modify content'); assert.ok(result.content.includes("verify({ kind: 'contract' })"), 'Should have verify'); assert.ok(result.content.includes("qualify({ kind: 'stateful' })"), 'Should have qualify stateful'); assert.ok(result.content.includes("qualify({ kind: 'scenario' })"), 'Should have qualify scenario'); } finally { cleanup(dir); } }); // --------------------------------------------------------------------------- // Test 14: Dry-run default mode (safe by default) // --------------------------------------------------------------------------- test('migrate defaults to dry-run mode (safe by default)', async () => { const dir = createTempDir(); try { const legacyConfig = `export default { testMode: "verify", };`; writeFileSync(resolve(dir, 'apophis.config.js'), legacyConfig); const ctx = makeCtx({ cwd: dir }); // No mode specified — should default to dry-run const result = await migrateCommand({}, ctx); assert.strictEqual(result.exitCode, 1, 'Should exit 1 in dry-run mode'); assert.ok(result.message?.includes('Dry run'), 'Should indicate dry run'); // Verify file was NOT modified const content = readFileSync(resolve(dir, 'apophis.config.js'), 'utf-8'); assert.ok(content.includes('testMode'), 'File should still have testMode'); } finally { cleanup(dir); } }); // --------------------------------------------------------------------------- // Test 15: Mixed legacy/modern field detection at rewriter level // --------------------------------------------------------------------------- test('config rewriter detects mixed legacy and modern fields', () => { const dir = createTempDir(); try { const content = `export default { // Both legacy and modern present testMode: "verify", mode: "observe", testProfiles: { quick: { usesPreset: "safe-ci", }, }, profiles: { modern: { preset: "safe-ci", }, }, };`; writeFileSync(resolve(dir, 'test.config.js'), content); const mixedReports = detectMixedLegacyModernFields(content, 'test.config.js'); assert.ok(mixedReports.length > 0, 'Should detect mixed fields'); const testModeReport = mixedReports.find((r) => r.legacy === 'testMode'); assert.ok(testModeReport, 'Should report testMode as mixed'); assert.ok(testModeReport.guidance.includes('testMode'), 'Guidance should mention testMode'); assert.ok(testModeReport.guidance.includes('mode'), 'Guidance should mention mode'); } finally { cleanup(dir); } }); // --------------------------------------------------------------------------- // Test 16: Ambiguous route pattern detection // --------------------------------------------------------------------------- test('route rewriter detects ambiguous route patterns with context', () => { const dir = createTempDir(); try { const content = `export default { schema: { // This is ambiguous 'x-validate': true, }, };`; writeFileSync(resolve(dir, 'test.routes.js'), content); const items = detectAmbiguousRoutePatterns(content, 'test.routes.js'); assert.strictEqual(items.length, 1, 'Should detect 1 ambiguous pattern'); const firstItem = items[0]; assert.ok(firstItem, 'Expected one migration item'); assert.strictEqual(firstItem.legacy, 'x-validate'); assert.ok(firstItem.ambiguous, 'Should be marked as ambiguous'); assert.ok(firstItem.guidance, 'Should have guidance'); assert.ok(firstItem.guidance.includes('Possible resolutions'), 'Should list possible resolutions'); } finally { cleanup(dir); } }); // --------------------------------------------------------------------------- // Test 17: Ambiguous code pattern detection with context // --------------------------------------------------------------------------- test('code rewriter detects ambiguous code patterns with surrounding context', () => { const dir = createTempDir(); try { const content = `import Fastify from 'fastify'; const app = Fastify(); // Ambiguous pattern app.register(oldApi()); export default app;`; writeFileSync(resolve(dir, 'test.app.js'), content); const items = detectAmbiguousCodePatterns(content, 'test.app.js'); assert.strictEqual(items.length, 1, 'Should detect 1 ambiguous pattern'); const firstItem = items[0]; assert.ok(firstItem, 'Expected one migration item'); assert.strictEqual(firstItem.legacy, 'oldApi()'); assert.ok(firstItem.ambiguous, 'Should be marked as ambiguous'); assert.ok(firstItem.guidance, 'Should have guidance'); assert.ok(firstItem.guidance.includes('Possible resolutions'), 'Should list possible resolutions'); assert.ok(firstItem.guidance.includes('Context:'), 'Should show surrounding context'); } finally { cleanup(dir); } }); // --------------------------------------------------------------------------- // Test 18: Legacy fixture detection // --------------------------------------------------------------------------- test('migrate detects legacy patterns in fixture config', async () => { const ctx = makeCtx({ cwd: 'src/cli/__fixtures__/legacy-config' }); const result = await migrateCommand({ check: true }, ctx); assert.strictEqual(result.exitCode, 1, 'Should detect legacy patterns in fixture'); assert.ok(result.items.length > 0, 'Should find legacy items'); const legacyNames = result.items.map((item) => item.legacy); assert.ok(legacyNames.includes('testMode'), 'Should detect testMode in fixture'); assert.ok(legacyNames.includes('testProfiles'), 'Should detect testProfiles in fixture'); assert.ok(legacyNames.includes('testPresets'), 'Should detect testPresets in fixture'); assert.ok(legacyNames.includes('envPolicies'), 'Should detect envPolicies in fixture'); }); // --------------------------------------------------------------------------- // Test 19: JSON output format // --------------------------------------------------------------------------- test('migrate outputs JSON format with all fields', async () => { const dir = createTempDir(); try { const legacyConfig = `export default { testMode: "verify", };`; writeFileSync(resolve(dir, 'apophis.config.js'), legacyConfig); const ctx = makeCtx({ cwd: dir, options: { ...makeCtx().options, format: 'json' } }); const result = await migrateCommand({ check: true }, ctx); assert.strictEqual(result.exitCode, 1, 'Should exit 1'); assert.ok(result.items.length > 0, 'Should have items'); assert.ok(result.totalRewrites, 'Should have totalRewrites'); assert.ok(result.filesWouldBeModified, 'Should have filesWouldBeModified'); } finally { cleanup(dir); } }); // --------------------------------------------------------------------------- // Test 20: No files found returns usage error // --------------------------------------------------------------------------- test('migrate returns usage error when no files found', async () => { const dir = createTempDir(); try { const ctx = makeCtx({ cwd: dir }); const result = await migrateCommand({ check: true }, ctx); assert.strictEqual(result.exitCode, 2, 'Should exit 2 when no files found'); assert.ok(result.message?.includes('No config or app files found'), 'Should mention no files found'); } finally { cleanup(dir); } });