feat: complete M7 cutover preparation
This commit is contained in:
170
scripts/m7-api-compare.ts
Normal file
170
scripts/m7-api-compare.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
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<string, unknown>;
|
||||
const normalized: Record<string, unknown> = {};
|
||||
|
||||
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<string, unknown>)
|
||||
.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<CompareResult> {
|
||||
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;
|
||||
});
|
||||
Reference in New Issue
Block a user