Files
NetMesh/scripts/compare-ci-test-baseline.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

552 lines
17 KiB
JavaScript

'use strict';
const fs = require('node:fs');
const ANSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
// Known app/test logger tags that emit volatile `# [Tag] ...` TAP comments on
// every npm test run. Skip these specifically; unknown bracket tags still enter
// nonTapOutput so new runner diagnostics keep failing the gate.
const VOLATILE_TAP_DIAG_TAG_RE = new RegExp(
'^# \\[(?:'
+ [
'transferDiag',
'transferBridge',
'FileWatcher',
'SessionLogStream',
'SSH',
'SSH Exec',
'KeyboardInteractive',
'Telnet',
'GlobalShortcut',
'Chain',
'PortForward',
'PortForwardingService',
'SFTP',
'SFTP Chain',
'TempDir',
'Terminal',
'AutoUpdate',
'Passphrase',
'Test',
'resolve-mosh-bin-release',
'resolve-et-bin-release',
'KeywordHighlight',
'Cursor SDK',
'Main',
'vaultBackupBridge',
'CloudSyncManager',
'ZMODEM',
'TrayPanel',
'sdk',
'scp-it',
'Plugins',
'netcatty-mcp',
'DirtyEditorGuard',
'Credentials',
'afterPack',
].map((tag) => tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
+ ')\\]',
);
function normalizeNonTapLine(line) {
return String(line || '')
.replace(/\(node:\d+\)/g, '(node:<pid>)')
.replace(
/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi,
'<uuid>',
)
.replace(/\bsftp-\d+\b/g, 'sftp-<id>')
.replace(
/\b(?:requestId|sessionId|transferId|id)\b(['"]?\s*[:=]\s*['"]?)[^'"\s,}\]]+/gi,
'$1<id>',
)
.replace(/https?:\/\/127\.0\.0\.1:\d+/g, 'http://127.0.0.1:<port>');
}
function normalizeRuntimeDetail(detail) {
return detail
.replace(
/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\b/g,
'<timestamp>',
)
.replace(/(?:\/private)?\/var\/folders\/\S+|\/tmp\/\S+/g, '<tmp-path>')
.replace(/:\d+:\d+\b/g, ':<line>:<column>');
}
function normalizeAssertionDetail(detail) {
return normalizeRuntimeDetail(detail).replace(
/\b(now|timestamp|pid|nonce|random(?:Value)?)\b(['"]?\s*[:=]\s*)[^,\s}\]]+/gi,
'$1$2<volatile>',
);
}
function normalizeStableDiagnosticDetail(detail) {
return normalizeAssertionDetail(detail);
}
function collectNonTapOutput(lines) {
const output = [];
let inDiagnostic = false;
let diagnosticOwnerIndent = -1;
let awaitingDiagnosticIndent = null;
for (const rawLine of lines) {
const line = rawLine.trim();
if (inDiagnostic) {
const indent = rawLine.match(/^\s*/)?.[0].length || 0;
if (/^\s+\.\.\.\s*$/.test(rawLine) && indent > diagnosticOwnerIndent) {
inDiagnostic = false;
diagnosticOwnerIndent = -1;
continue;
}
const boundary = line.match(
/^(?:(?:ok|not ok) \d+ - |# (?:tests|suites|pass|fail|cancelled|skipped|todo|duration_ms) |1\.\.\d+$)/,
);
if (!boundary || indent > diagnosticOwnerIndent) continue;
output.push('<unterminated-tap-diagnostic>');
inDiagnostic = false;
diagnosticOwnerIndent = -1;
}
if (awaitingDiagnosticIndent !== null) {
if (!line) continue;
const indent = rawLine.match(/^\s*/)?.[0].length || 0;
if (/^\s+---\s*$/.test(rawLine) && indent > awaitingDiagnosticIndent) {
inDiagnostic = true;
diagnosticOwnerIndent = awaitingDiagnosticIndent;
awaitingDiagnosticIndent = null;
continue;
}
awaitingDiagnosticIndent = null;
}
// Node's test runner emits YAML diagnostics after both ok and not ok.
// Treat both as attached TAP so volatile duration_ms values never look like
// added runner noise between baseline and candidate runs.
const tapResult = rawLine.match(/^(\s*)(?:ok|not ok) \d+ - /);
if (tapResult) {
awaitingDiagnosticIndent = tapResult[1].length;
continue;
}
if (
!line ||
/^TAP version \d+$/.test(line) ||
/^ok \d+ - /.test(line) ||
/^# (?:Subtest:|tests |suites |pass |fail |cancelled |skipped |todo |duration_ms )/.test(line) ||
VOLATILE_TAP_DIAG_TAG_RE.test(line) ||
/^1\.\.\d+$/.test(line)
) {
continue;
}
output.push(
normalizeNonTapLine(
line
.replace(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\b/g, '<timestamp>')
.replace(/(?:\/private)?\/var\/folders\/\S+|\/tmp\/\S+/g, '<tmp-path>')
.replace(/:\d+:\d+\b/g, ':<line>:<column>'),
),
);
}
if (inDiagnostic) output.push('<unterminated-tap-diagnostic>');
return output;
}
function parseTapResult(text, exitCode) {
const normalized = String(text || '').replace(ANSI_RE, '').replace(/\r\n?/g, '\n');
const lines = normalized.split('\n');
const failures = [];
const failureRecords = [];
const successes = [];
let failCount = null;
let cancelledCount = null;
let skippedCount = null;
let todoCount = null;
let testCount = null;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const succeeded = line.match(/^\s*ok \d+ - (.+)$/);
const tapDirective = /\s+#\s*(?:SKIP|TODO)\b/i;
if (succeeded && !tapDirective.test(line)) {
successes.push(succeeded[1].trim());
}
const failed = line.match(/^\s*not ok \d+ - (.+)$/);
if (failed && !tapDirective.test(line)) {
const name = failed[1].trim();
const diagnostic = [];
const errorDetails = [];
let inDiagnostic = false;
let errorBlockIndent = -1;
let skippingStack = false;
let stackIndent = -1;
for (let detailIndex = index + 1; detailIndex < lines.length; detailIndex += 1) {
const detail = lines[detailIndex];
if (/^\s*(?:ok|not ok) \d+ - /.test(detail) || /^\s*# (?:tests|fail|cancelled|skipped|todo) \d+\s*$/.test(detail)) {
break;
}
if (/^\s*---\s*$/.test(detail)) {
inDiagnostic = true;
continue;
}
if (inDiagnostic && /^\s*\.\.\.\s*$/.test(detail)) break;
if (!inDiagnostic) continue;
const indent = detail.match(/^\s*/)?.[0].length || 0;
if (errorBlockIndent >= 0) {
if (!detail.trim()) continue;
if (indent > errorBlockIndent) {
errorDetails.push(detail.trim());
continue;
}
errorBlockIndent = -1;
}
if (skippingStack) {
if (!detail.trim() || indent > stackIndent) continue;
skippingStack = false;
}
if (/^\s*stack:\s*(?:\|-)?\s*$/.test(detail)) {
skippingStack = true;
stackIndent = indent;
continue;
}
if (/^\s*duration_ms:/.test(detail)) continue;
if (/^\s*(?:actual|expected):/.test(detail)) continue;
if (/^\s*error:\s*(?:\|-|>)\s*$/.test(detail)) {
diagnostic.push('error:');
errorBlockIndent = indent;
continue;
}
if (detail.trim()) {
diagnostic.push(normalizeStableDiagnosticDetail(detail.trimEnd()));
}
}
const assertionFailure = diagnostic.some((detail) =>
/^\s*code:\s*['"]?ERR_ASSERTION['"]?\s*$/.test(detail),
);
const stableErrorDetails = assertionFailure
? errorDetails.map(normalizeStableDiagnosticDetail)
: errorDetails.map(normalizeRuntimeDetail);
diagnostic.push(...stableErrorDetails.map((detail) => `error-detail: ${detail}`));
failures.push(name);
failureRecords.push({
name,
identity: diagnostic.length ? `${name}\n${diagnostic.join('\n')}` : name,
});
}
const failSummary = line.match(/^\s*# fail (\d+)\s*$/);
if (failSummary) {
failCount = Number(failSummary[1]);
}
const cancelledSummary = line.match(/^\s*# cancelled (\d+)\s*$/);
if (cancelledSummary) {
cancelledCount = Number(cancelledSummary[1]);
}
const skippedSummary = line.match(/^\s*# skipped (\d+)\s*$/);
if (skippedSummary) {
skippedCount = Number(skippedSummary[1]);
}
const todoSummary = line.match(/^\s*# todo (\d+)\s*$/);
if (todoSummary) {
todoCount = Number(todoSummary[1]);
}
const testSummary = line.match(/^\s*# tests (\d+)\s*$/);
if (testSummary) {
testCount = Number(testSummary[1]);
}
}
return {
exitCode: Number(exitCode),
failures,
failureRecords,
successes,
failCount,
cancelledCount,
skippedCount,
todoCount,
testCount,
nonTapOutput: collectNonTapOutput(lines),
complete:
failCount !== null &&
cancelledCount !== null &&
skippedCount !== null &&
todoCount !== null &&
testCount !== null,
};
}
function countFailures(failures) {
const counts = new Map();
for (const failure of failures) {
counts.set(failure, (counts.get(failure) || 0) + 1);
}
return counts;
}
function compareTapResults(baseline, candidate) {
const baselineSuccessCounts = countFailures(baseline.successes);
const candidateSuccessCounts = countFailures(candidate.successes);
const candidateSuccessPool = new Map(candidateSuccessCounts);
const missingBaselineSuccesses = [];
for (const [success, count] of baselineSuccessCounts) {
const available = candidateSuccessPool.get(success) || 0;
const missing = count - available;
candidateSuccessPool.set(success, Math.max(0, available - count));
for (let i = 0; i < missing; i += 1) missingBaselineSuccesses.push(success);
}
const candidateFailurePool = countFailures(
candidate.failureRecords.map((failure) => failure.identity),
);
const missingBaselineFailures = [];
for (const failure of baseline.failureRecords) {
const matchingFailures = candidateFailurePool.get(failure.identity) || 0;
if (matchingFailures > 0) {
candidateFailurePool.set(failure.identity, matchingFailures - 1);
continue;
}
const matchingSuccesses = candidateSuccessPool.get(failure.name) || 0;
if (matchingSuccesses > 0) {
candidateSuccessPool.set(failure.name, matchingSuccesses - 1);
continue;
}
missingBaselineFailures.push(failure.name);
}
const result = ({
passed,
kind,
newFailures = [],
candidateFailures = candidate.exitCode === 0 ? [] : candidate.failures,
}) => ({
passed,
kind,
baselineFailures: baseline.failures,
candidateFailures,
newFailures,
missingBaselineSuccesses,
missingBaselineFailures,
});
if (candidate.exitCode === 0) {
// Require every baseline failure title to reappear as a same-named success
// before accepting a red-to-green transition. Aggregate counts alone would allow
// deleting the failing test and adding an unrelated passer.
const baselineFailCounts = countFailures(baseline.failures);
const baselineSuccessCountsForFix = countFailures(baseline.successes);
const candidateSuccessCountsForFix = countFailures(candidate.successes);
const unresolvedBaselineFailures = [];
for (const [name, failCount] of baselineFailCounts) {
const required =
failCount + (baselineSuccessCountsForFix.get(name) || 0);
const available = candidateSuccessCountsForFix.get(name) || 0;
for (let i = 0; i < required - available; i += 1) {
unresolvedBaselineFailures.push(name);
}
}
// A fully green candidate may rename success titles when quantitative
// coverage does not shrink, but only against a complete baseline summary.
// An incomplete baseline cannot prove coverage was preserved, so fail closed.
const quantitativeClean =
baseline.complete &&
candidate.complete &&
candidate.failCount === 0 &&
candidate.cancelledCount === 0 &&
unresolvedBaselineFailures.length === 0 &&
candidate.skippedCount <= baseline.skippedCount &&
candidate.todoCount <= baseline.todoCount &&
candidate.testCount >= baseline.testCount;
if (quantitativeClean) {
return result({ passed: true, kind: 'clean' });
}
const coverageShrunk =
baseline.complete &&
candidate.complete &&
candidate.failCount === 0 &&
candidate.cancelledCount === 0 &&
candidate.skippedCount <= baseline.skippedCount &&
candidate.todoCount <= baseline.todoCount &&
candidate.testCount < baseline.testCount &&
missingBaselineSuccesses.length > 0;
if (coverageShrunk) {
return result({
passed: false,
kind: 'missing_baseline_successes',
newFailures: missingBaselineSuccesses,
});
}
return result({
passed: false,
kind: 'unclassified_failure',
newFailures: unresolvedBaselineFailures.length
? unresolvedBaselineFailures
: missingBaselineSuccesses,
});
}
if (baseline.exitCode === 0) {
return result({
passed: false,
kind: 'new_failures',
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
}
if (!baseline.complete || !candidate.complete) {
return result({
passed: false,
kind: 'unclassified_failure',
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
}
if (missingBaselineSuccesses.length) {
return result({
passed: false,
kind: 'missing_baseline_successes',
candidateFailures: candidate.failures,
newFailures: missingBaselineSuccesses,
});
}
if (candidate.exitCode !== baseline.exitCode) {
return result({
passed: false,
kind: 'unclassified_failure',
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
}
const baselineNonTapCounts = countFailures(baseline.nonTapOutput);
const candidateNonTapCounts = countFailures(candidate.nonTapOutput);
const addedNonTapOutput = [...candidateNonTapCounts].some(
([line, count]) => count > (baselineNonTapCounts.get(line) || 0),
);
if (addedNonTapOutput) {
return result({
passed: false,
kind: 'unclassified_failure',
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
}
if (candidate.failCount === 0 || candidate.testCount < baseline.testCount) {
return result({
passed: false,
kind: 'unclassified_failure',
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
}
if (
candidate.cancelledCount > 0 ||
candidate.skippedCount > baseline.skippedCount ||
candidate.todoCount > baseline.todoCount
) {
return result({
passed: false,
kind: 'cancelled_tests',
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
}
const baselineCounts = countFailures(
baseline.failureRecords.map((failure) => failure.identity),
);
const candidateCounts = countFailures(
candidate.failureRecords.map((failure) => failure.identity),
);
const newFailures = [];
for (const [identity, count] of candidateCounts) {
const extra = count - (baselineCounts.get(identity) || 0);
const name = candidate.failureRecords.find(
(failure) => failure.identity === identity,
)?.name || identity;
for (let i = 0; i < extra; i += 1) newFailures.push(name);
}
const parsedCountsMatch =
baseline.failureRecords.length >= Number(baseline.failCount) &&
candidate.failureRecords.length >= Number(candidate.failCount);
const passed =
newFailures.length === 0 &&
missingBaselineFailures.length === 0 &&
parsedCountsMatch;
const reportedFailures = newFailures.length
? newFailures
: missingBaselineFailures;
return result({
passed,
kind: passed
? 'baseline_only'
: newFailures.length
? 'new_failures'
: 'unclassified_failure',
candidateFailures: candidate.failures,
newFailures: reportedFailures,
});
}
function parseArgs(argv) {
const values = {};
for (let i = 0; i < argv.length; i += 2) {
const key = argv[i];
if (!key?.startsWith('--') || argv[i + 1] === undefined) {
throw new Error('Expected --name value arguments.');
}
values[key.slice(2)] = argv[i + 1];
}
return values;
}
function main(argv = process.argv.slice(2)) {
const args = parseArgs(argv);
for (const required of [
'baseline-log',
'baseline-exit',
'candidate-log',
'candidate-exit',
'output',
]) {
if (!(required in args)) throw new Error(`Missing --${required}.`);
}
const baseline = parseTapResult(
fs.readFileSync(args['baseline-log'], 'utf8'),
args['baseline-exit'],
);
const candidate = parseTapResult(
fs.readFileSync(args['candidate-log'], 'utf8'),
args['candidate-exit'],
);
const result = compareTapResults(baseline, candidate);
fs.writeFileSync(args.output, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
console.log(
result.passed
? `Test comparison accepted: ${result.kind}.`
: `Test comparison failed: ${result.kind}.`,
);
if (result.newFailures.length) {
console.error(`New failures:\n- ${result.newFailures.join('\n- ')}`);
}
if (!result.passed && result.missingBaselineSuccesses?.length) {
console.error(
`Missing baseline successes:\n- ${result.missingBaselineSuccesses.join('\n- ')}`,
);
}
return result.passed ? 0 : 1;
}
if (require.main === module) {
try {
process.exitCode = main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 2;
}
}
module.exports = {
compareTapResults,
main,
parseTapResult,
};