842 lines
28 KiB
TypeScript
842 lines
28 KiB
TypeScript
|
|
/**
|
||
|
|
* 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": {
|
||
|
|
depth: "quick",
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
});
|