734 lines
29 KiB
JavaScript
Executable File
734 lines
29 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
||
'use strict';
|
||
|
||
const crypto = require('crypto');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const AUDIT_SCHEMA = 'xhr-fetch-semantics-audit/v3';
|
||
const TRANSCRIPT_SCHEMA = 'network-transcript/v3';
|
||
const SHA256_RE = /^[a-f0-9]{64}$/i;
|
||
|
||
const COMMON_FIELDS = ['actor', 'realmId', 'navigationEpoch', 'sessionId'];
|
||
const REQUEST_FIELDS = ['method', 'url', 'credentials', 'referrer'];
|
||
const RESPONSE_FIELDS = [
|
||
'status',
|
||
'statusText',
|
||
'responseURL',
|
||
'responseType',
|
||
'bodyLength',
|
||
'bodySha256',
|
||
];
|
||
const OPTIONAL_REQUEST_FIELDS = [
|
||
'taskId',
|
||
'origin',
|
||
'cookiePolicy',
|
||
'bodyType',
|
||
'redirect',
|
||
'cache',
|
||
'timeout',
|
||
'withCredentials',
|
||
'async',
|
||
];
|
||
|
||
function parseArgs(argv) {
|
||
const args = {
|
||
caseDir: '',
|
||
browser: '',
|
||
node: '',
|
||
out: '',
|
||
require: false,
|
||
requireNoSend: false,
|
||
json: false,
|
||
markdown: false,
|
||
help: false,
|
||
};
|
||
for (let i = 2; i < argv.length; i++) {
|
||
const arg = argv[i];
|
||
if (arg === '--case-dir' || arg === '--case' || arg === '-d') args.caseDir = argv[++i] || '';
|
||
else if (arg === '--browser') args.browser = argv[++i] || '';
|
||
else if (arg === '--node') args.node = argv[++i] || '';
|
||
else if (arg === '--out') args.out = argv[++i] || '';
|
||
else if (arg === '--require') args.require = true;
|
||
else if (arg === '--require-no-send') args.requireNoSend = true;
|
||
else if (arg === '--json') args.json = true;
|
||
else if (arg === '--markdown') args.markdown = true;
|
||
else if (arg === '--help' || arg === '-h') args.help = true;
|
||
else throw new Error(`未知参数:${arg}`);
|
||
}
|
||
if (!args.json && !args.markdown) args.markdown = true;
|
||
return args;
|
||
}
|
||
|
||
function usage() {
|
||
return `用法:
|
||
node scripts/check_xhr_fetch_semantics.js --case-dir case --require --require-no-send --markdown
|
||
node scripts/check_xhr_fetch_semantics.js --browser case/fixtures/browser-network-transcript.ndjson --node case/tmp/node-network-transcript.ndjson --out case/tmp/xhr-fetch-semantics-audit.json --json
|
||
|
||
说明:先验证 browser/Node transcript 是否包含完整的真实观测字段,再逐事件比较 XHR/fetch/navigation 的请求、响应、Header 顺序、body 摘要、Session 与生命周期。缺失字段不会被默认值掩盖。`;
|
||
}
|
||
|
||
function exists(file) {
|
||
try { return fs.existsSync(file); } catch { return false; }
|
||
}
|
||
|
||
function hasOwn(value, key) {
|
||
return Boolean(value && Object.prototype.hasOwnProperty.call(value, key));
|
||
}
|
||
|
||
function firstOwn(value, keys) {
|
||
for (const key of keys) {
|
||
if (hasOwn(value, key)) return { present: true, key, value: value[key] };
|
||
}
|
||
return { present: false, key: '', value: undefined };
|
||
}
|
||
|
||
function parseTranscript(file) {
|
||
const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
|
||
if (/\.json$/i.test(file)) {
|
||
const raw = JSON.parse(text);
|
||
return {
|
||
meta: raw.meta || raw.metadata || (Array.isArray(raw) ? {} : raw),
|
||
events: Array.isArray(raw.events)
|
||
? raw.events
|
||
: (Array.isArray(raw.items) ? raw.items : (Array.isArray(raw) ? raw : [])),
|
||
};
|
||
}
|
||
const events = [];
|
||
let meta = {};
|
||
for (const line of text.split(/\r?\n/)) {
|
||
if (!line.trim()) continue;
|
||
const event = JSON.parse(line);
|
||
if (event.type === 'meta' || event.event === 'meta') meta = { ...meta, ...event };
|
||
else events.push(event);
|
||
}
|
||
return { meta, events };
|
||
}
|
||
|
||
function sha256Text(value) {
|
||
return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
|
||
}
|
||
|
||
function normalizeHeaderValue(value) {
|
||
if (typeof value === 'string') {
|
||
return {
|
||
sha256: sha256Text(value),
|
||
length: Buffer.byteLength(value, 'utf8'),
|
||
redacted: false,
|
||
};
|
||
}
|
||
if (value && typeof value === 'object') {
|
||
return {
|
||
sha256: String(value.sha256 || '').toLowerCase(),
|
||
length: Number(value.length),
|
||
redacted: value.redacted === true,
|
||
};
|
||
}
|
||
return {
|
||
sha256: '',
|
||
length: Number.NaN,
|
||
redacted: false,
|
||
};
|
||
}
|
||
|
||
function headerEntryParts(item) {
|
||
if (Array.isArray(item)) return { name: item[0], value: item[1] };
|
||
if (item && typeof item === 'object') {
|
||
return {
|
||
name: hasOwn(item, 'name') ? item.name : item.key,
|
||
value: item.value,
|
||
};
|
||
}
|
||
return { name: undefined, value: undefined };
|
||
}
|
||
|
||
function validateAndNormalizeHeaders(value, label, problems, options = {}) {
|
||
if (!Array.isArray(value)) {
|
||
problems.push(`${label} 必须是保留顺序与重复字段的 Header 数组`);
|
||
return [];
|
||
}
|
||
if (options.requireNonEmpty && value.length === 0) {
|
||
problems.push(`${label} 不能为空;完整 network transcript 必须记录实际发送的 Header`);
|
||
}
|
||
const normalized = [];
|
||
for (const [index, item] of value.entries()) {
|
||
const parts = headerEntryParts(item);
|
||
const name = typeof parts.name === 'string' ? parts.name.trim().toLowerCase() : '';
|
||
if (!name) problems.push(`${label}[${index}] 缺少 Header 名称`);
|
||
const headerValue = normalizeHeaderValue(parts.value);
|
||
if (typeof parts.value === 'string') {
|
||
if (parts.value.length === 0) {
|
||
problems.push(`${label}[${index}] ${name || '<unknown>'} 的值为空;应记录真实值或脱敏摘要`);
|
||
}
|
||
} else if (parts.value && typeof parts.value === 'object') {
|
||
if (parts.value.redacted !== true
|
||
|| !SHA256_RE.test(String(parts.value.sha256 || ''))
|
||
|| !Number.isInteger(Number(parts.value.length))
|
||
|| Number(parts.value.length) < 0) {
|
||
problems.push(`${label}[${index}] ${name || '<unknown>'} 的脱敏值必须包含 redacted=true、length 和 SHA-256`);
|
||
}
|
||
} else {
|
||
problems.push(`${label}[${index}] ${name || '<unknown>'} 缺少 Header 值`);
|
||
}
|
||
normalized.push([name, headerValue]);
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
function kindValue(event) {
|
||
return String(firstOwn(event, ['kind', 'event', 'type']).value || '').toLowerCase();
|
||
}
|
||
|
||
function eventClass(kind) {
|
||
if (/^(request|send|network-request)$/.test(kind) || /-request$/.test(kind)) return 'request';
|
||
if (/^(response|complete|loadend|network-response)$/.test(kind) || /-response$/.test(kind)) return 'response';
|
||
if (/realm-destroyed|navigation|reload|pagehide|unload|lifecycle/.test(kind)) return 'lifecycle';
|
||
return 'unknown';
|
||
}
|
||
|
||
function normalizeActor(actor) {
|
||
const value = String(actor || '').toLowerCase();
|
||
return value === 'xmlhttprequest' ? 'xhr' : value;
|
||
}
|
||
|
||
function normalizeEvent(event, index, normalizedHeaders = {}) {
|
||
const body = event.body && typeof event.body === 'object' ? event.body : {};
|
||
const navigationEpoch = firstOwn(event, ['navigationEpoch', 'epoch']);
|
||
const sequence = firstOwn(event, ['sequence', 'seq']);
|
||
return {
|
||
index,
|
||
pairKey: String(firstOwn(event, ['pairKey', 'requestKey', 'logicalId']).value || ''),
|
||
kind: kindValue(event),
|
||
actor: normalizeActor(firstOwn(event, ['actor', 'initiator', 'api']).value),
|
||
realmId: String(firstOwn(event, ['realmId', 'realm']).value || ''),
|
||
navigationEpoch: Number(navigationEpoch.value),
|
||
taskId: String(event.taskId || ''),
|
||
method: String(event.method || '').toUpperCase(),
|
||
url: String(firstOwn(event, ['url', 'requestURL']).value || ''),
|
||
credentials: String(event.credentials || ''),
|
||
referrer: String(event.referrer || ''),
|
||
origin: String(event.origin || ''),
|
||
cookiePolicy: String(event.cookiePolicy || ''),
|
||
bodyType: String(event.bodyType || body.type || ''),
|
||
contentType: String(event.contentType || body.contentType || ''),
|
||
bodyLength: Number(firstOwn(event, ['bodyLength']).present ? event.bodyLength : (body.length ?? body.byteLength)),
|
||
bodySha256: String(event.bodySha256 || body.sha256 || '').toLowerCase(),
|
||
headers: normalizedHeaders.headers || [],
|
||
responseHeaders: normalizedHeaders.responseHeaders || [],
|
||
status: Number(event.status),
|
||
statusText: String(hasOwn(event, 'statusText') ? event.statusText : ''),
|
||
responseURL: String(hasOwn(event, 'responseURL') ? event.responseURL : ''),
|
||
responseType: String(hasOwn(event, 'responseType') ? event.responseType : ''),
|
||
lifecycle: Array.isArray(event.lifecycle) ? event.lifecycle.map(String) : [],
|
||
validRealm: event.validRealm !== false,
|
||
cancelledReason: String(event.cancelledReason || event.cancelReason || ''),
|
||
sessionId: String(event.sessionId || ''),
|
||
sequence: Number(sequence.value),
|
||
redirect: event.redirect,
|
||
cache: event.cache,
|
||
timeout: event.timeout,
|
||
withCredentials: event.withCredentials,
|
||
async: event.async,
|
||
};
|
||
}
|
||
|
||
function requireField(event, aliases, label, problems, options = {}) {
|
||
const found = firstOwn(event, aliases);
|
||
if (!found.present) {
|
||
problems.push(`${label} 缺少 ${aliases[0]}`);
|
||
return undefined;
|
||
}
|
||
if (options.nonEmpty && String(found.value || '') === '') {
|
||
problems.push(`${label} 的 ${aliases[0]} 不能为空`);
|
||
}
|
||
return found.value;
|
||
}
|
||
|
||
function validateSha256(value, label, problems) {
|
||
if (!SHA256_RE.test(String(value || ''))) problems.push(`${label} 必须是完整 SHA-256`);
|
||
}
|
||
|
||
function validateLength(value, label, problems) {
|
||
const number = Number(value);
|
||
if (!Number.isInteger(number) || number < 0) problems.push(`${label} 必须是非负整数`);
|
||
}
|
||
|
||
function requestHasBody(event) {
|
||
const method = String(event.method || '').toUpperCase();
|
||
const body = event.body && typeof event.body === 'object' ? event.body : {};
|
||
return /^(POST|PUT|PATCH)$/.test(method)
|
||
|| hasOwn(event, 'bodyLength')
|
||
|| hasOwn(event, 'bodySha256')
|
||
|| hasOwn(event, 'contentType')
|
||
|| Object.keys(body).length > 0;
|
||
}
|
||
|
||
function validateEvent(event, index, sourceLabel, problems) {
|
||
const label = `${sourceLabel} event[${index}]`;
|
||
const problemStart = problems.length;
|
||
if (!event || typeof event !== 'object' || Array.isArray(event)) {
|
||
problems.push(`${label} 必须是对象`);
|
||
const normalized = normalizeEvent({}, index);
|
||
normalized.validationProblems = problems.slice(problemStart);
|
||
return normalized;
|
||
}
|
||
const kind = requireField(event, ['kind', 'event'], label, problems, { nonEmpty: true });
|
||
const cls = eventClass(String(kind || '').toLowerCase());
|
||
if (cls === 'unknown') problems.push(`${label} 的 kind 无法归类为 request/response/lifecycle`);
|
||
requireField(event, ['pairKey', 'requestKey', 'logicalId'], label, problems, { nonEmpty: true });
|
||
requireField(event, ['actor', 'initiator', 'api'], label, problems, { nonEmpty: true });
|
||
requireField(event, ['realmId', 'realm'], label, problems, { nonEmpty: true });
|
||
const epoch = requireField(event, ['navigationEpoch', 'epoch'], label, problems);
|
||
if (!Number.isInteger(Number(epoch)) || Number(epoch) < 0) problems.push(`${label} 的 navigationEpoch 必须是非负整数`);
|
||
const sequence = requireField(event, ['sequence', 'seq'], label, problems);
|
||
if (!Number.isFinite(Number(sequence))) problems.push(`${label} 的 sequence 必须是数字`);
|
||
requireField(event, ['sessionId'], label, problems, { nonEmpty: true });
|
||
|
||
const normalizedHeaders = {};
|
||
if (cls === 'request') {
|
||
requireField(event, ['method'], label, problems, { nonEmpty: true });
|
||
requireField(event, ['url', 'requestURL'], label, problems, { nonEmpty: true });
|
||
requireField(event, ['credentials'], label, problems, { nonEmpty: true });
|
||
requireField(event, ['referrer'], label, problems);
|
||
const headers = requireField(event, ['headers', 'requestHeaders'], label, problems);
|
||
normalizedHeaders.headers = validateAndNormalizeHeaders(
|
||
headers,
|
||
`${label}.headers`,
|
||
problems,
|
||
{ requireNonEmpty: true },
|
||
);
|
||
if (requestHasBody(event)) {
|
||
const bodyLength = requireField(event, ['bodyLength'], label, problems);
|
||
const bodySha256 = requireField(event, ['bodySha256'], label, problems);
|
||
const contentType = requireField(event, ['contentType'], label, problems);
|
||
validateLength(bodyLength, `${label}.bodyLength`, problems);
|
||
validateSha256(bodySha256, `${label}.bodySha256`, problems);
|
||
if (Number(bodyLength) > 0 && String(contentType || '') === '') {
|
||
problems.push(`${label} 有请求体但 contentType 为空`);
|
||
}
|
||
}
|
||
} else if (cls === 'response') {
|
||
const status = requireField(event, ['status'], label, problems);
|
||
requireField(event, ['statusText'], label, problems);
|
||
const responseURL = requireField(event, ['responseURL'], label, problems);
|
||
requireField(event, ['responseType'], label, problems);
|
||
const bodyLength = requireField(event, ['bodyLength'], label, problems);
|
||
const bodySha256 = requireField(event, ['bodySha256'], label, problems);
|
||
const responseHeaders = requireField(event, ['responseHeaders'], label, problems);
|
||
const lifecycle = requireField(event, ['lifecycle'], label, problems);
|
||
if (!Number.isInteger(Number(status)) || Number(status) < 0 || Number(status) > 599) {
|
||
problems.push(`${label}.status 必须是 0 到 599 的整数`);
|
||
}
|
||
if (Number(status) > 0 && String(responseURL || '') === '') {
|
||
problems.push(`${label} status>0 时必须记录最终 responseURL`);
|
||
}
|
||
validateLength(bodyLength, `${label}.bodyLength`, problems);
|
||
validateSha256(bodySha256, `${label}.bodySha256`, problems);
|
||
normalizedHeaders.responseHeaders = validateAndNormalizeHeaders(
|
||
responseHeaders,
|
||
`${label}.responseHeaders`,
|
||
problems,
|
||
);
|
||
if (!Array.isArray(lifecycle) || lifecycle.length === 0) {
|
||
problems.push(`${label}.lifecycle 必须记录 readyState/event/Promise 顺序`);
|
||
}
|
||
} else if (cls === 'lifecycle') {
|
||
const lifecycle = requireField(event, ['lifecycle'], label, problems);
|
||
if (!Array.isArray(lifecycle) || lifecycle.length === 0) {
|
||
problems.push(`${label}.lifecycle 必须是非空数组`);
|
||
}
|
||
}
|
||
const normalized = normalizeEvent(event, index, normalizedHeaders);
|
||
normalized.validationProblems = problems.slice(problemStart);
|
||
return normalized;
|
||
}
|
||
|
||
function validateMeta(meta, sourceLabel, options, problems) {
|
||
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
|
||
problems.push(`${sourceLabel} transcript 缺少 meta 对象`);
|
||
return;
|
||
}
|
||
if (meta.schemaVersion !== TRANSCRIPT_SCHEMA) {
|
||
problems.push(`${sourceLabel} transcript schemaVersion 必须是 ${TRANSCRIPT_SCHEMA}`);
|
||
}
|
||
if (!String(meta.baselineId || '')) problems.push(`${sourceLabel} transcript 缺少 baselineId`);
|
||
const producer = String(meta.generatedBy || meta.capturedBy || '');
|
||
if (!producer) {
|
||
problems.push(`${sourceLabel} transcript 缺少 generatedBy/capturedBy`);
|
||
} else if (options.browser && !/browser|ruyi|camoufox|cloak|firefox|chrome|chromium|webkit|manual/i.test(producer)) {
|
||
problems.push(`${sourceLabel} transcript 生成来源不可识别:${producer}`);
|
||
} else if (!options.browser && !/node|runtime|audit|probe|recorder/i.test(producer)) {
|
||
problems.push(`${sourceLabel} transcript generatedBy 不可信:${producer}`);
|
||
}
|
||
if (options.browser) {
|
||
if (meta.mode !== 'browser-baseline') {
|
||
problems.push(`${sourceLabel} transcript mode 必须是 browser-baseline`);
|
||
}
|
||
} else {
|
||
if (!SHA256_RE.test(String(meta.runtimeSourceHash || ''))) {
|
||
problems.push(`${sourceLabel} transcript 缺少有效 runtimeSourceHash`);
|
||
}
|
||
if (options.requireNoSend) {
|
||
if (meta.mode !== 'no-send' && meta.networkMode !== 'no-send') {
|
||
problems.push(`${sourceLabel} transcript 必须来自 no-send 模式`);
|
||
}
|
||
if (!hasOwn(meta, 'networkAttempts') || Number(meta.networkAttempts) !== 0) {
|
||
problems.push(`${sourceLabel} transcript 必须明确记录 networkAttempts=0`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function validateTranscript(raw, sourceLabel, options, problems) {
|
||
validateMeta(raw.meta, sourceLabel, options, problems);
|
||
if (!Array.isArray(raw.events) || raw.events.length === 0) {
|
||
problems.push(`${sourceLabel} transcript 没有事件`);
|
||
return [];
|
||
}
|
||
const events = raw.events.map((event, index) => validateEvent(event, index, sourceLabel, problems));
|
||
let previous = Number.NEGATIVE_INFINITY;
|
||
const seen = new Set();
|
||
for (const event of events) {
|
||
if (seen.has(event.sequence)) problems.push(`${sourceLabel} transcript sequence 重复:${event.sequence}`);
|
||
seen.add(event.sequence);
|
||
if (!(event.sequence > previous)) {
|
||
problems.push(`${sourceLabel} transcript 事件必须按严格递增 sequence 写入`);
|
||
}
|
||
previous = event.sequence;
|
||
}
|
||
return events;
|
||
}
|
||
|
||
function compareField(expected, observed, field, diffs) {
|
||
if (!Object.is(expected[field], observed[field])) {
|
||
diffs.push({ field, expected: expected[field], observed: observed[field] });
|
||
}
|
||
}
|
||
|
||
function compareOptionalField(expectedRaw, observedRaw, expected, observed, field, diffs) {
|
||
const expectedPresent = hasOwn(expectedRaw, field);
|
||
const observedPresent = hasOwn(observedRaw, field);
|
||
if (!expectedPresent && !observedPresent) return;
|
||
if (expectedPresent !== observedPresent || expected[field] !== observed[field]) {
|
||
diffs.push({
|
||
field,
|
||
expected: expectedPresent ? expected[field] : '<missing>',
|
||
observed: observedPresent ? observed[field] : '<missing>',
|
||
});
|
||
}
|
||
}
|
||
|
||
function compareHeaders(expected, observed, field, diffs) {
|
||
if (JSON.stringify(expected[field]) !== JSON.stringify(observed[field])) {
|
||
diffs.push({ field, expected: expected[field], observed: observed[field] });
|
||
}
|
||
}
|
||
|
||
function compareLifecycle(expected, observed, diffs) {
|
||
if (JSON.stringify(expected.lifecycle) !== JSON.stringify(observed.lifecycle)) {
|
||
diffs.push({ field: 'lifecycle', expected: expected.lifecycle, observed: observed.lifecycle });
|
||
}
|
||
}
|
||
|
||
function eventKey(event) {
|
||
return `${eventClass(event.kind)}:${event.pairKey}`;
|
||
}
|
||
|
||
function hardInvariants(events) {
|
||
const problems = [];
|
||
const destroyedRealms = new Map();
|
||
for (const event of events) {
|
||
const cls = eventClass(event.kind);
|
||
if (/realm-destroyed|navigation-commit|reload-commit/.test(event.kind)) {
|
||
destroyedRealms.set(`${event.realmId}:${event.navigationEpoch}`, event.sequence);
|
||
if (event.actor === 'xhr') {
|
||
problems.push(`${event.pairKey}:document navigation/reload 被错误归类为 XHR`);
|
||
}
|
||
continue;
|
||
}
|
||
if (cls === 'response' && event.status === 0 && event.responseURL !== '') {
|
||
problems.push(`${event.pairKey}:status=0 时 responseURL 必须为空,当前为 ${event.responseURL}`);
|
||
}
|
||
const destroyedAt = destroyedRealms.get(`${event.realmId}:${event.navigationEpoch}`);
|
||
if (typeof destroyedAt !== 'undefined' && event.sequence > destroyedAt && !event.cancelledReason) {
|
||
problems.push(`${event.pairKey}:旧 realm ${event.realmId}/${event.navigationEpoch} 销毁后仍产生未取消的 timer/XHR/task`);
|
||
}
|
||
if (event.validRealm === false && !event.cancelledReason) {
|
||
problems.push(`${event.pairKey}:失效 realm 的事件缺少取消原因`);
|
||
}
|
||
}
|
||
return problems;
|
||
}
|
||
|
||
function compareEvent(expectedRaw, observedRaw, expected, observed) {
|
||
const diffs = [];
|
||
if (expected.validationProblems.length) {
|
||
diffs.push({
|
||
field: 'browserTranscriptCompleteness',
|
||
expected: 'complete',
|
||
observed: `${expected.validationProblems.length} problem(s)`,
|
||
});
|
||
}
|
||
if (observed.validationProblems.length) {
|
||
diffs.push({
|
||
field: 'nodeTranscriptCompleteness',
|
||
expected: 'complete',
|
||
observed: `${observed.validationProblems.length} problem(s)`,
|
||
});
|
||
}
|
||
for (const field of COMMON_FIELDS) compareField(expected, observed, field, diffs);
|
||
const cls = eventClass(expected.kind);
|
||
if (cls !== eventClass(observed.kind)) {
|
||
diffs.push({ field: 'kindClass', expected: cls, observed: eventClass(observed.kind) });
|
||
return diffs;
|
||
}
|
||
if (cls === 'request') {
|
||
for (const field of REQUEST_FIELDS) compareField(expected, observed, field, diffs);
|
||
compareHeaders(expected, observed, 'headers', diffs);
|
||
const expectedBody = requestHasBody(expectedRaw);
|
||
const observedBody = requestHasBody(observedRaw);
|
||
if (expectedBody !== observedBody) {
|
||
diffs.push({ field: 'bodyPresence', expected: expectedBody, observed: observedBody });
|
||
} else if (expectedBody) {
|
||
for (const field of ['contentType', 'bodyLength', 'bodySha256']) {
|
||
compareField(expected, observed, field, diffs);
|
||
}
|
||
}
|
||
for (const field of OPTIONAL_REQUEST_FIELDS) {
|
||
compareOptionalField(expectedRaw, observedRaw, expected, observed, field, diffs);
|
||
}
|
||
} else if (cls === 'response') {
|
||
for (const field of RESPONSE_FIELDS) compareField(expected, observed, field, diffs);
|
||
compareHeaders(expected, observed, 'responseHeaders', diffs);
|
||
compareLifecycle(expected, observed, diffs);
|
||
} else if (cls === 'lifecycle') {
|
||
compareLifecycle(expected, observed, diffs);
|
||
for (const field of ['method', 'url']) {
|
||
compareOptionalField(expectedRaw, observedRaw, expected, observed, field, diffs);
|
||
}
|
||
}
|
||
return diffs;
|
||
}
|
||
|
||
function emptyResult(caseDir, browserPath, nodePath, problems, warnings = []) {
|
||
return {
|
||
schemaVersion: AUDIT_SCHEMA,
|
||
generatedBy: 'check_xhr_fetch_semantics.js',
|
||
clean: false,
|
||
caseDir,
|
||
browserPath,
|
||
nodePath,
|
||
problems,
|
||
warnings,
|
||
results: [],
|
||
summary: {},
|
||
};
|
||
}
|
||
|
||
function extraEventsByKey(expectedEvents, observedEvents) {
|
||
const remaining = new Map();
|
||
for (const event of expectedEvents) {
|
||
const key = eventKey(event);
|
||
remaining.set(key, (remaining.get(key) || 0) + 1);
|
||
}
|
||
const extra = [];
|
||
for (const event of observedEvents) {
|
||
const key = eventKey(event);
|
||
const count = remaining.get(key) || 0;
|
||
if (count > 0) remaining.set(key, count - 1);
|
||
else extra.push(event);
|
||
}
|
||
return extra;
|
||
}
|
||
|
||
function check(args) {
|
||
const caseDir = path.resolve(args.caseDir || '.');
|
||
const browserPath = path.resolve(args.browser || path.join(caseDir, 'fixtures', 'browser-network-transcript.ndjson'));
|
||
const nodePath = path.resolve(args.node || path.join(caseDir, 'tmp', 'node-network-transcript.ndjson'));
|
||
const problems = [];
|
||
const warnings = [];
|
||
if ((args.require || exists(browserPath) || exists(nodePath)) && !exists(browserPath)) {
|
||
problems.push(`缺少浏览器 network transcript:${browserPath}`);
|
||
}
|
||
if ((args.require || exists(browserPath) || exists(nodePath)) && !exists(nodePath)) {
|
||
problems.push(`缺少 Node network transcript:${nodePath}`);
|
||
}
|
||
if (problems.length) return emptyResult(caseDir, browserPath, nodePath, problems, warnings);
|
||
|
||
if (!exists(browserPath) && !exists(nodePath)) {
|
||
return {
|
||
...emptyResult(caseDir, browserPath, nodePath, [], ['未触发 network transcript 审计']),
|
||
clean: true,
|
||
};
|
||
}
|
||
|
||
let browserRaw;
|
||
let nodeRaw;
|
||
try { browserRaw = parseTranscript(browserPath); } catch (err) { problems.push(`浏览器 transcript 无法解析:${err.message}`); }
|
||
try { nodeRaw = parseTranscript(nodePath); } catch (err) { problems.push(`Node transcript 无法解析:${err.message}`); }
|
||
if (!browserRaw || !nodeRaw) return emptyResult(caseDir, browserPath, nodePath, problems, warnings);
|
||
|
||
const browserEvents = validateTranscript(browserRaw, '浏览器', { browser: true }, problems);
|
||
const nodeEvents = validateTranscript(
|
||
nodeRaw,
|
||
'Node',
|
||
{ browser: false, requireNoSend: args.requireNoSend },
|
||
problems,
|
||
);
|
||
if (browserRaw.meta.baselineId && nodeRaw.meta.baselineId
|
||
&& nodeRaw.meta.baselineId !== browserRaw.meta.baselineId) {
|
||
problems.push(`network transcript baselineId 不一致:${browserRaw.meta.baselineId} != ${nodeRaw.meta.baselineId}`);
|
||
}
|
||
for (const invariant of hardInvariants(browserEvents)) problems.push(`浏览器 transcript:${invariant}`);
|
||
for (const invariant of hardInvariants(nodeEvents)) problems.push(`Node transcript:${invariant}`);
|
||
|
||
const browserTimeline = browserEvents.map(eventKey);
|
||
const nodeTimeline = nodeEvents.map(eventKey);
|
||
if (JSON.stringify(browserTimeline) !== JSON.stringify(nodeTimeline)) {
|
||
problems.push('browser/Node network event timeline 不一致;请求、响应或生命周期顺序发生变化');
|
||
}
|
||
|
||
const results = [];
|
||
const count = Math.max(browserEvents.length, nodeEvents.length);
|
||
for (let index = 0; index < count; index++) {
|
||
const expected = browserEvents[index];
|
||
const observed = nodeEvents[index];
|
||
if (!expected || !observed) {
|
||
const key = expected ? eventKey(expected) : eventKey(observed);
|
||
const diffs = [{
|
||
field: '*',
|
||
expected: expected ? 'browser event' : 'missing',
|
||
observed: observed ? 'node event' : 'missing',
|
||
}];
|
||
results.push({ key, index, clean: false, diffs });
|
||
continue;
|
||
}
|
||
const diffs = compareEvent(
|
||
browserRaw.events[index],
|
||
nodeRaw.events[index],
|
||
expected,
|
||
observed,
|
||
);
|
||
if (diffs.length) {
|
||
problems.push(`${eventKey(expected)}:${diffs.map(diff => diff.field).join('、')} 不一致`);
|
||
}
|
||
results.push({ key: eventKey(expected), index, clean: diffs.length === 0, diffs });
|
||
}
|
||
|
||
const extraNode = extraEventsByKey(browserEvents, nodeEvents);
|
||
const missingNode = extraEventsByKey(nodeEvents, browserEvents);
|
||
const extraNodeEvents = extraNode.length;
|
||
const extraNodeRequests = extraNode.filter(event => eventClass(event.kind) === 'request').length;
|
||
if (extraNodeEvents) {
|
||
problems.push(`Node transcript 比浏览器多出 ${extraNodeEvents} 个事件,其中 ${extraNodeRequests} 个请求`);
|
||
}
|
||
if (missingNode.length) {
|
||
problems.push(`Node transcript 比浏览器少 ${missingNode.length} 个事件`);
|
||
}
|
||
|
||
const browserSessionIds = [...new Set(browserEvents.map(event => event.sessionId).filter(Boolean))];
|
||
const nodeSessionIds = [...new Set(nodeEvents.map(event => event.sessionId).filter(Boolean))];
|
||
if (browserSessionIds.length !== 1) {
|
||
problems.push(`浏览器 transcript 必须使用一个 sessionId,当前为 ${browserSessionIds.join('、') || '未记录'}`);
|
||
}
|
||
if (nodeSessionIds.length !== 1) {
|
||
problems.push(`Node transcript 必须使用一个 sessionId,当前为 ${nodeSessionIds.join('、') || '未记录'}`);
|
||
}
|
||
|
||
const summary = {
|
||
browserEvents: browserEvents.length,
|
||
nodeEvents: nodeEvents.length,
|
||
matched: results.filter(item => item.clean).length,
|
||
mismatched: results.filter(item => !item.clean).length,
|
||
networkMismatches: results.filter(item => !item.clean).length,
|
||
extraNodeEvents,
|
||
extraNodeRequests,
|
||
browserSessionIds,
|
||
nodeSessionIds,
|
||
baselineId: browserRaw.meta.baselineId || nodeRaw.meta.baselineId || '',
|
||
runtimeSourceHash: nodeRaw.meta.runtimeSourceHash || '',
|
||
networkMode: nodeRaw.meta.mode || nodeRaw.meta.networkMode || '',
|
||
networkAttempts: nodeRaw.meta.networkAttempts,
|
||
};
|
||
return {
|
||
schemaVersion: AUDIT_SCHEMA,
|
||
generatedBy: 'check_xhr_fetch_semantics.js',
|
||
clean: problems.length === 0,
|
||
caseDir,
|
||
browserPath,
|
||
nodePath,
|
||
problems,
|
||
warnings,
|
||
results,
|
||
summary,
|
||
};
|
||
}
|
||
|
||
function renderMarkdown(result) {
|
||
const lines = [
|
||
'# XHR/fetch 请求语义审计结果',
|
||
'',
|
||
`- 浏览器 transcript:${result.browserPath}`,
|
||
`- Node transcript:${result.nodePath}`,
|
||
`- 是否通过:${result.clean ? '是' : '否'}`,
|
||
];
|
||
if (result.summary && typeof result.summary.browserEvents !== 'undefined') {
|
||
lines.push(`- 浏览器事件:${result.summary.browserEvents}`);
|
||
lines.push(`- Node 事件:${result.summary.nodeEvents}`);
|
||
lines.push(`- matched:${result.summary.matched}`);
|
||
lines.push(`- mismatch:${result.summary.mismatched}`);
|
||
lines.push(`- Node 多余事件:${result.summary.extraNodeEvents}`);
|
||
lines.push(`- Node 多余请求:${result.summary.extraNodeRequests}`);
|
||
lines.push(`- Node sessionId:${result.summary.nodeSessionIds.join('、') || '未记录'}`);
|
||
lines.push(`- no-send 网络尝试:${String(result.summary.networkAttempts ?? '未记录')}`);
|
||
}
|
||
const bad = result.results.filter(item => !item.clean);
|
||
if (bad.length) {
|
||
lines.push('', '## 逐项差异');
|
||
for (const item of bad.slice(0, 100)) {
|
||
lines.push(`- ${item.index}/${item.key}:${item.diffs.map(diff => diff.field).join('、')}`);
|
||
}
|
||
}
|
||
if (result.problems.length) {
|
||
lines.push('', '## 阻断问题');
|
||
for (const problem of result.problems) lines.push(`- ${problem}`);
|
||
}
|
||
if (result.warnings.length) {
|
||
lines.push('', '## 提醒');
|
||
for (const warning of result.warnings) lines.push(`- ${warning}`);
|
||
}
|
||
return `${lines.join('\n')}\n`;
|
||
}
|
||
|
||
if (require.main === module) {
|
||
try {
|
||
const args = parseArgs(process.argv);
|
||
if (args.help) {
|
||
console.log(usage());
|
||
process.exit(0);
|
||
}
|
||
const result = check(args);
|
||
if (args.out) {
|
||
const out = path.resolve(args.out);
|
||
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||
fs.writeFileSync(out, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
|
||
}
|
||
if (args.json) console.log(JSON.stringify(result, null, 2));
|
||
if (args.markdown) process.stdout.write(renderMarkdown(result));
|
||
process.exit(result.clean ? 0 : 1);
|
||
} catch (err) {
|
||
console.error(err.message || String(err));
|
||
console.error(usage());
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
module.exports = {
|
||
check,
|
||
eventClass,
|
||
hardInvariants,
|
||
normalizeEvent,
|
||
validateAndNormalizeHeaders,
|
||
validateTranscript,
|
||
};
|