171 lines
4.5 KiB
TypeScript
171 lines
4.5 KiB
TypeScript
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;
|
|
});
|