import assert from 'node:assert/strict'; type HttpMethod = 'GET'; interface CompareCase { method: HttpMethod; path: string; } interface CompareResult { path: string; nodeStatus: number; rustStatus: number; matched: boolean; reason?: string; } const DEFAULT_NODE_BASE_URL = 'http://127.0.0.1:8081'; const DEFAULT_RUST_BASE_URL = 'http://127.0.0.1:3000'; function readEnv(name: string, fallback: string): string { const value = process.env[name]?.trim(); return value ? value : fallback; } function buildCases(): CompareCase[] { const rawPaths = process.env.M7_COMPARE_PATHS?.trim(); const paths = rawPaths ? rawPaths.split(',').map((value) => value.trim()).filter(Boolean) : ['/healthz', '/api/auth/login-options']; return paths.map((path) => ({ method: 'GET', path: path.startsWith('/') ? path : `/${path}`, })); } async function fetchJson(baseUrl: string, testCase: CompareCase, requestId: string) { const url = new URL(testCase.path, baseUrl); const response = await fetch(url, { method: testCase.method, headers: { 'x-request-id': requestId, 'x-genarrative-response-envelope': '1', }, }); const text = await response.text(); const json = text ? JSON.parse(text) : null; return { status: response.status, json: normalizeVolatileJson(json), }; } function normalizeVolatileJson(value: unknown): unknown { if (Array.isArray(value)) { return value.map(normalizeVolatileJson); } if (!value || typeof value !== 'object') { return value; } const record = value as Record; const normalized: Record = {}; for (const [key, child] of Object.entries(record)) { if (['requestId', 'timestamp', 'latencyMs'].includes(key)) { continue; } normalized[key] = normalizeVolatileJson(child); } return normalized; } function stableStringify(value: unknown): string { if (Array.isArray(value)) { return `[${value.map(stableStringify).join(',')}]`; } if (!value || typeof value !== 'object') { return JSON.stringify(value); } const entries = Object.entries(value as Record) .sort(([left], [right]) => left.localeCompare(right)) .map(([key, child]) => `${JSON.stringify(key)}:${stableStringify(child)}`); return `{${entries.join(',')}}`; } async function compareCase( nodeBaseUrl: string, rustBaseUrl: string, testCase: CompareCase, ): Promise { const requestId = `m7-api-compare-${testCase.path.replaceAll('/', '-')}`; const [nodeResponse, rustResponse] = await Promise.all([ fetchJson(nodeBaseUrl, testCase, requestId), fetchJson(rustBaseUrl, testCase, requestId), ]); if (nodeResponse.status !== rustResponse.status) { return { path: testCase.path, nodeStatus: nodeResponse.status, rustStatus: rustResponse.status, matched: false, reason: 'status 不一致', }; } const nodeBody = stableStringify(nodeResponse.json); const rustBody = stableStringify(rustResponse.json); if (nodeBody !== rustBody) { return { path: testCase.path, nodeStatus: nodeResponse.status, rustStatus: rustResponse.status, matched: false, reason: `body 不一致\nnode=${nodeBody}\nrust=${rustBody}`, }; } return { path: testCase.path, nodeStatus: nodeResponse.status, rustStatus: rustResponse.status, matched: true, }; } async function main() { const nodeBaseUrl = readEnv('M7_NODE_BASE_URL', DEFAULT_NODE_BASE_URL); const rustBaseUrl = readEnv('M7_RUST_BASE_URL', DEFAULT_RUST_BASE_URL); const strict = process.env.M7_COMPARE_STRICT?.trim() !== 'false'; const cases = buildCases(); console.log(`[m7:api-compare] node=${nodeBaseUrl}`); console.log(`[m7:api-compare] rust=${rustBaseUrl}`); console.log(`[m7:api-compare] cases=${cases.map((item) => item.path).join(', ')}`); const results = await Promise.all( cases.map((testCase) => compareCase(nodeBaseUrl, rustBaseUrl, testCase)), ); for (const result of results) { const label = result.matched ? 'OK' : 'DIFF'; console.log( `[m7:api-compare] ${label} ${result.path} node=${result.nodeStatus} rust=${result.rustStatus}`, ); if (result.reason) { console.log(result.reason); } } const failures = results.filter((result) => !result.matched); if (strict) { assert.equal(failures.length, 0, '存在 Node/Rust API contract 差异'); } } main().catch((error) => { console.error('[m7:api-compare] failed'); console.error(error); process.exitCode = 1; });