diff --git a/scripts/conformance/server-parity/artifact-summary.mjs b/scripts/conformance/server-parity/artifact-summary.mjs index a2a24cac3..424392a1b 100644 --- a/scripts/conformance/server-parity/artifact-summary.mjs +++ b/scripts/conformance/server-parity/artifact-summary.mjs @@ -1,6 +1,7 @@ import {constants, existsSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, closeSync, writeFileSync} from 'node:fs'; import {basename, dirname, join, relative, resolve} from 'node:path'; import {pathToFileURL} from 'node:url'; +import {StringDecoder} from 'node:string_decoder'; const FILE_LIMIT = 64 * 1024; const CONTENT_LIMIT = 2 * 1024 * 1024; @@ -10,7 +11,7 @@ function priority(path, failed) { const name = basename(path); if (/\.status$/.test(name) || /-status\.txt$/.test(name)) return -1; if (failed && /(?:^|\/)query-authority\/clients\/(?:php|rust)\/[^/]+\/(?:client\.stderr|observer\.log)$/.test(path)) return 0; - if (/^(rust|mysql|postgres|comparator|codec-load|artifact-summary)(-execution)?-tests\.txt$/.test(name)) return 0; + if (TEST_SUMMARY.test(name)) return 0; if (/^(comparison|compare)([-.].*)?\.json$/.test(name) || /-comparison\.json$/.test(name)) return 1; if (failed && (/^(?:php|embedded|rust)-record\.txt$/.test(name) || /\.failure-excerpt\.txt$/.test(name) || /^sdk-(?:signals|queries)-profile\.log$/.test(name))) return 1; @@ -22,6 +23,62 @@ function priority(path, failed) { return null; } +const TEST_SUMMARY = /^(rust|mysql|postgres|comparator|codec-load|artifact-summary)(-execution)?-tests\.txt$/; + +// A node:test (TAP) report's failing tests: each `not ok` line with its +// indented YAML diagnostic block, ending at the block's `...` line. A large +// report keeps only its tail, which can miss a failure reported early. The +// report is read in fixed chunks, one bounded line at a time: memory stays +// within `limit` plus one chunk and one line, whatever the report's size. +// Failures past the excerpt limit are still counted. +const SCAN_CHUNK = 64 * 1024; +const LINE_LIMIT = 8 * 1024; +const BLOCK_LINES = 200; + +export function scanTapFailures(path, limit) { + const descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + const chunk = Buffer.alloc(SCAN_CHUNK); + const decoder = new StringDecoder('utf8'); + const kept = []; + let keptBytes = 0; + let count = 0; + let pending = ''; + let block = null; + const keep = text => { + const size = Buffer.byteLength(text) + 1; + if (keptBytes + size > limit) return; + kept.push(text); + keptBytes += size; + }; + const line = text => { + if (block !== null) { + if (block.lines < BLOCK_LINES) { + keep(text); + block.lines++; + } + if (text === block.end || block.lines >= BLOCK_LINES) block = null; + return; + } + const match = /^(\s*)not ok \d+ /.exec(text); + if (match) { + count++; + keep(text); + block = {end: `${match[1]} ...`, lines: 1}; + } + }; + try { + let read; + while ((read = readSync(descriptor, chunk, 0, SCAN_CHUNK, null)) > 0) { + const parts = (pending + decoder.write(chunk.subarray(0, read))).split('\n'); + pending = parts.pop().slice(0, LINE_LIMIT); + for (const part of parts) line(part.slice(0, LINE_LIMIT)); + } + pending = (pending + decoder.end()).slice(0, LINE_LIMIT); + if (pending !== '') line(pending); + } finally { closeSync(descriptor); } + return {text: kept.length > 0 ? `${kept.join('\n')}\n` : '', count}; +} + function inventory(root, path = root, files = []) { if (!lstatSync(path).isDirectory()) throw new Error('Evidence root must be a directory.'); for (const entry of readdirSync(path, {withFileTypes: true})) { @@ -70,12 +127,26 @@ export function summarizeEvidence(source, destination, {outcome = 'unknown', rev } finally { closeSync(descriptor); } retained.push({path: file.path, source_bytes: file.source_bytes, retained_bytes: size, tail_only: size < file.source_bytes}); bytes += size; + // A tail-only test report also keeps its failing tests, bounded. + if (size < file.source_bytes && TEST_SUMMARY.test(basename(file.path)) && retained.length < ENTRY_LIMIT) { + const failures = scanTapFailures(join(source, file.path), Math.min(FILE_LIMIT, CONTENT_LIMIT - bytes)); + if (failures.count > 0) { + const excerpt = Buffer.from(failures.text); + if (excerpt.length > 0) { + const path = `${file.path}.failures.txt`; + writeFileSync(join(destination, path), excerpt); + retained.push({path, source_bytes: file.source_bytes, retained_bytes: excerpt.length, tail_only: false, + derived_from: file.path, failures: failures.count}); + bytes += excerpt.length; + } + } + } } const summary = { schema: 'durable-workflow.server-parity-artifact-summary.v1', outcome, revision, scanned_files: files.length, scanned_bytes: files.reduce((sum, file) => sum + file.source_bytes, 0), retained_bytes: bytes, retained, - omitted_files: files.length - retained.length, + omitted_files: files.length - retained.filter(file => file.derived_from === undefined).length, limits: {file_bytes: FILE_LIMIT, content_bytes: CONTENT_LIMIT, entries: ENTRY_LIMIT, excluded_directories: ['vendor', 'node_modules', 'target', '.git', 'objects']}, scope: 'Test summaries and bounded diagnostic tails only. Raw records, HTTP journals, databases and payload bytes are not retained. This summary does not rerun or qualify assertions.', diff --git a/tests/Unit/ServerParityArtifactSummaryTest.mjs b/tests/Unit/ServerParityArtifactSummaryTest.mjs index 7ec275f81..dce60536a 100644 --- a/tests/Unit/ServerParityArtifactSummaryTest.mjs +++ b/tests/Unit/ServerParityArtifactSummaryTest.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; +import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, truncateSync, writeFileSync} from 'node:fs'; import {join} from 'node:path'; import {tmpdir} from 'node:os'; import {summarizeEvidence} from '../../scripts/conformance/server-parity/artifact-summary.mjs'; @@ -151,3 +151,42 @@ test('a failed record outline survives a full collection of setup logs and stays summarizeEvidence(source, success, {outcome: 'success'}); assert.equal(existsSync(join(success, 'legacy-marker/php/record.failure-excerpt.txt')), false); }); + +test('a tail-only test report also keeps its failing tests', t => { + const {source, output} = fixture(t); + const failure = [ + 'not ok 59 - only the adapter job caches', + ' ---', + " location: 'tests/Unit/ServerParityAdapterCacheTest.mjs:22:1'", + " error: 'Expected values to be strictly deep-equal'", + ' ...', + ].join('\n'); + const passing = Array.from({length: 4000}, (_, index) => `# Subtest: passing ${index}\nok ${index + 100} - passing ${index}\n ---\n duration_ms: 0.1\n ...`).join('\n'); + writeFileSync(join(source, 'comparator-tests.txt'), `${failure}\n${passing}\n# pass 4000\n# fail 1\n`); + writeFileSync(join(source, 'rust-tests.txt'), `${'ok 1 - fine\n'.repeat(10000)}# fail 0\n`); + const small = 'not ok 1 - small failure\n ---\n ...\n# fail 1\n'; + writeFileSync(join(source, 'codec-load-tests.txt'), small); + const summary = summarizeEvidence(source, output, {outcome: 'failure'}); + const tail = readFileSync(join(output, 'comparator-tests.txt'), 'utf8'); + assert.equal(tail.includes('not ok 59'), false, 'the failure is outside the retained tail'); + assert.equal(readFileSync(join(output, 'comparator-tests.txt.failures.txt'), 'utf8'), `${failure}\n`); + const derived = summary.retained.find(file => file.path === 'comparator-tests.txt.failures.txt'); + assert.deepEqual([derived.derived_from, derived.failures, derived.tail_only], ['comparator-tests.txt', 1, false]); + assert.equal(existsSync(join(output, 'rust-tests.txt.failures.txt')), false, 'a passing report has no failures file'); + assert.equal(readFileSync(join(output, 'codec-load-tests.txt'), 'utf8'), small); + assert.equal(existsSync(join(output, 'codec-load-tests.txt.failures.txt')), false, 'a whole report needs no excerpt'); +}); + +test('a failure excerpt is derived output and is read in bounded chunks', t => { + const {source, output} = fixture(t); + const failure = 'not ok 7 - early λ failure\n ---\n error: boom\n ...\n'; + const report = join(source, 'comparator-tests.txt'); + writeFileSync(report, failure); + // A sparse 256 MiB report: the scan must stream it, not load it whole. + truncateSync(report, 256 * 1024 * 1024); + const summary = summarizeEvidence(source, output, {outcome: 'failure'}); + assert.equal(readFileSync(join(output, 'comparator-tests.txt.failures.txt'), 'utf8'), failure); + assert.deepEqual([summary.scanned_files, summary.retained.length, summary.omitted_files], [1, 2, 0], + 'derived output does not count against source omission'); + assert.equal(summary.retained.find(file => file.derived_from).failures, 1); +});