Files
reverse-skills-max/vendor-new/xbsReverseSkill/web-js-env-patcher/scripts/check_xhr_fetch_semantics.js

734 lines
29 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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,
};