From b9642f6b5a93d60d553504c229b6d71a948f9946 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Sat, 17 Oct 2026 13:37:14 +0000 Subject: [PATCH 1/5] Spec 010 T006-T007: the Java-against-Java parity harness tools/java-parity sends the same requests to a reference (what answers today) and a candidate (what replaces it) and compares status, the headers clients read, and the SHA-256 of the body after a declared normaliser -- the only way a difference may be hidden, by name. - Reports hold digests and verdicts, never bodies (some answers hold people's records); --redact drops the request too, since some paths are tokens anyone holding them can read. - --calibrate asks the reference twice and counts a request whose reference disagrees with itself (live PSICQUIC data) as unstable, not as a difference. - --simultaneous sends both at once, for live data. - Normalisers: identity; an analysis result, or its gzipped download, without the token (it holds the time and a per-process counter). Each comparison shown failing by mutation (status, headers, body, calibration, redaction). The PDF normaliser (T008) comes with the first PDF comparison: it needs qpdf in a container. Co-Authored-By: Claude Opus 5.5 --- tools/java-parity/normalisers.mjs | 22 +++++ tools/java-parity/parity.mjs | 128 ++++++++++++++++++++++++++++++ tools/java-parity/parity.spec.mjs | 121 ++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 tools/java-parity/normalisers.mjs create mode 100644 tools/java-parity/parity.mjs create mode 100644 tools/java-parity/parity.spec.mjs diff --git a/tools/java-parity/normalisers.mjs b/tools/java-parity/normalisers.mjs new file mode 100644 index 00000000..4480200e --- /dev/null +++ b/tools/java-parity/normalisers.mjs @@ -0,0 +1,22 @@ +/** + * The normalisers parity may apply, each declared here with what it removes + * and why. A difference is only ever hidden by one of these, by name, in the + * run that uses it -- never by loosening the comparison itself. + */ +import { gunzipSync } from 'node:zlib'; + +/** Drop `summary.token`: an analysis token holds the time and a per-process counter, so two runs never share one. */ +function withoutToken(text) { + const value = JSON.parse(text); + if (value?.summary && typeof value.summary === 'object') delete value.summary.token; + return JSON.stringify(value); +} + +export const normalisers = { + /** Byte for byte. */ + identity: (body) => body, + /** An analysis result (JSON) without its token. */ + analysisResult: (body) => Buffer.from(withoutToken(body.toString('utf8'))), + /** The gzipped result download, decompressed, without its token. */ + analysisResultGz: (body) => Buffer.from(withoutToken(gunzipSync(body).toString('utf8'))), +}; diff --git a/tools/java-parity/parity.mjs b/tools/java-parity/parity.mjs new file mode 100644 index 00000000..ec00abf3 --- /dev/null +++ b/tools/java-parity/parity.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +/** + * Java-against-Java parity (spec 010, research R7): the same requests to a + * reference -- what answers today -- and a candidate -- what replaces it -- + * compared on status, the headers clients read, and the bytes of the body + * after a declared normaliser. + * + * Reports carry digests and verdicts, never bodies: some answers hold + * people's records, and with --redact not even the request, because some + * paths are tokens anyone holding them can read. + * + * Usage: + * node tools/java-parity/parity.mjs --reference URL --candidate URL \ + * --requests requests.json [--normalise NAME] [--calibrate] [--redact] [--simultaneous] + * requests.json: [{ "method": "GET", "path": "/ContentService/...", "headers": {}, "body": "..." }] + */ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { normalisers } from './normalisers.mjs'; + +/** The headers a client reads; the rest (Date, Server, Connection) differ by nature. */ +// fetch decodes a Content-Encoding alike on both sides; the header itself is +// still compared. A gzip *file* (a download) arrives as its bytes. +const COMPARED_HEADERS = [ + 'content-type', + 'content-disposition', + 'content-length', + 'content-encoding', + 'access-control-allow-origin', + 'cache-control', +]; + +async function answer(base, { method = 'GET', path, headers = {}, body }) { + const response = await fetch(`${base}${path}`, { + method, + headers, + body, + // A redirect is an answer to compare, not one to follow. + redirect: 'manual', + }); + return { + status: response.status, + headers: Object.fromEntries(COMPARED_HEADERS.map((name) => [name, response.headers.get(name)])), + body: Buffer.from(await response.arrayBuffer()), + }; +} + +const digest = (bytes) => createHash('sha256').update(bytes).digest('hex'); + +/** What differs between two answers, after normalising both bodies the same way. */ +function differences(a, b, normalise) { + const found = []; + if (a.status !== b.status) found.push('status'); + for (const name of COMPARED_HEADERS) { + // Content-Length follows the body; a normalised body is compared instead. + if (name === 'content-length' && normalise !== normalisers.identity) continue; + if (a.headers[name] !== b.headers[name]) found.push(name); + } + if (digest(normalise(a.body)) !== digest(normalise(b.body))) found.push('body'); + return found; +} + +/** + * Compare every request. With `calibrate`, the reference is first asked + * twice: where it disagrees with itself (live third-party data), the request + * is "unstable" and not counted either way. + */ +export async function compareAll( + requests, + { + reference, + candidate, + normalise = normalisers.identity, + calibrate = false, + redact = false, + simultaneous = false, + } +) { + const results = []; + const counts = { identical: 0, different: 0 }; + for (const [index, request] of requests.entries()) { + const label = redact ? `#${index}` : `${request.method ?? 'GET'} ${request.path}`; + const [ref, cand] = simultaneous + ? await Promise.all([answer(reference, request), answer(candidate, request)]) + : [await answer(reference, request), await answer(candidate, request)]; + const result = { request: label, status: [ref.status, cand.status] }; + if (calibrate) { + const again = await answer(reference, request); + result.calibrated = differences(ref, again, normalise).length === 0; + if (!result.calibrated) { + counts.unstable = (counts.unstable ?? 0) + 1; + results.push(result); + continue; + } + } + result.differs = differences(ref, cand, normalise); + counts[result.differs.length ? 'different' : 'identical'] += 1; + results.push(result); + } + return { counts, results }; +} + +// Started directly rather than imported by a test. +if (process.argv[1] && process.argv[1].endsWith('parity.mjs')) { + const args = process.argv.slice(2); + const option = (name) => { + const at = args.indexOf(`--${name}`); + return at === -1 ? undefined : args[at + 1]; + }; + const flag = (name) => args.includes(`--${name}`); + const normalise = normalisers[option('normalise') ?? 'identity']; + if (!option('reference') || !option('candidate') || !option('requests') || !normalise) { + console.error( + 'usage: parity.mjs --reference URL --candidate URL --requests FILE [--normalise NAME] [--calibrate] [--redact] [--simultaneous]' + ); + process.exit(2); + } + const report = await compareAll(JSON.parse(readFileSync(option('requests'), 'utf8')), { + reference: option('reference'), + candidate: option('candidate'), + normalise, + calibrate: flag('calibrate'), + redact: flag('redact'), + simultaneous: flag('simultaneous'), + }); + console.log(JSON.stringify(report, null, 2)); + process.exit(report.counts.different ? 1 : 0); +} diff --git a/tools/java-parity/parity.spec.mjs b/tools/java-parity/parity.spec.mjs new file mode 100644 index 00000000..0e1cba8b --- /dev/null +++ b/tools/java-parity/parity.spec.mjs @@ -0,0 +1,121 @@ +// @vitest-environment node +// +// The Java-against-Java parity harness (spec 010, research R7), against two +// stub servers standing in for the reference (what runs today) and the +// candidate (what replaces it). +import { createServer } from 'node:http'; +import { gzipSync } from 'node:zlib'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { compareAll } from './parity.mjs'; +import { normalisers } from './normalisers.mjs'; + +/** A server answering from a table of path -> { status, headers, body }. */ +function stub(table) { + let calls = 0; + const server = createServer((request, response) => { + calls += 1; + // Live third-party data: a different answer every time. + if (request.url === '/live') return response.end(`live ${calls}`); + const answer = table[request.url] ?? { status: 404, body: 'none' }; + response.writeHead(answer.status ?? 200, answer.headers ?? { 'content-type': 'text/plain' }); + response.end(answer.body); + }); + return new Promise((resolve) => + server.listen(0, '127.0.0.1', () => + resolve({ server, url: `http://127.0.0.1:${server.address().port}` }) + ) + ); +} + +const json = { 'content-type': 'application/json;charset=UTF-8' }; +const reference = { + '/same': { body: 'abc' }, + '/byte': { body: 'abc' }, + '/status': { status: 200, body: 'x' }, + '/header': { + headers: { 'content-type': 'text/plain', 'content-disposition': 'a.txt' }, + body: 'x', + }, + '/token': { headers: json, body: '{"summary":{"token":"T1","type":"X"},"n":1}' }, + '/gz': { + headers: { 'content-type': 'application/gzip' }, + body: gzipSync('{"summary":{"token":"T1"},"n":1}'), + }, + '/secret/ABC123': { body: 'x' }, +}; +const candidate = { + ...reference, + '/byte': { body: 'abd' }, + '/status': { status: 500, body: 'x' }, + '/header': { + headers: { 'content-type': 'text/plain', 'content-disposition': 'b.txt' }, + body: 'x', + }, + '/token': { headers: json, body: '{"summary":{"token":"T2","type":"X"},"n":1}' }, + '/gz': { + headers: { 'content-type': 'application/gzip' }, + body: gzipSync('{"summary":{"token":"T9"},"n":1}'), + }, +}; + +let ref; +let cand; +beforeAll(async () => { + ref = await stub(reference); + cand = await stub(candidate); +}); +afterAll(async () => { + await new Promise((resolve) => ref.server.close(resolve)); + await new Promise((resolve) => cand.server.close(resolve)); +}); + +const run = (paths, options = {}) => + compareAll( + paths.map((path) => ({ method: 'GET', path })), + { reference: ref.url, candidate: cand.url, ...options } + ); + +describe('the parity harness', () => { + it('finds identical answers identical', async () => { + const report = await run(['/same']); + expect(report.counts).toEqual({ identical: 1, different: 0 }); + }); + + it('finds a one-byte, a status and a header difference', async () => { + const report = await run(['/byte', '/status', '/header']); + expect(report.counts).toEqual({ identical: 0, different: 3 }); + expect(report.results.map((r) => r.differs)).toEqual([ + ['body'], + ['status'], + ['content-disposition'], + ]); + }); + + it('removes the analysis token only through a declared normaliser', async () => { + expect((await run(['/token'])).counts.different).toBe(1); + expect( + (await run(['/token'], { normalise: normalisers.analysisResult })).counts.identical + ).toBe(1); + expect((await run(['/gz'], { normalise: normalisers.analysisResultGz })).counts.identical).toBe( + 1 + ); + }); + + it('never puts a body, and with redaction no path, in its report', async () => { + const report = await run(['/secret/ABC123'], { redact: true }); + const text = JSON.stringify(report); + expect(text).not.toContain('ABC123'); + expect(text).not.toContain('"x"'); + expect(report.results[0].request).toBe('#0'); + }); + + it('calibrates against the reference itself: a difference counts only where the reference agreed with itself', async () => { + const report = await run(['/same', '/byte'], { calibrate: true }); + expect(report.results.map((r) => r.calibrated)).toEqual([true, true]); + expect(report.counts).toEqual({ identical: 1, different: 1 }); + // A reference that disagrees with itself is unstable, counted neither way. + const live = await run(['/live'], { calibrate: true }); + expect(live.counts).toEqual({ identical: 0, different: 0, unstable: 1 }); + expect(live.results[0].calibrated).toBe(false); + }); +}); From 2d2cc4a300173ced00250c3163083f9060ebbe3d Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Sat, 17 Oct 2026 13:45:45 +0000 Subject: [PATCH 2/5] Review of #507: the harness compares what it claimed to, and fails loudly - Over node:http, not fetch: fetch dropped a caller's Host, so each side saw its own address and answers built from Host differed for no real reason; it also hid Transfer-Encoding. - Every response header is compared except named noise (Date, Connection, Keep-Alive, Server): a redirect's Location, Vary, ETag, Set-Cookie and every CORS header were not compared before. - The token normaliser blanks the token's bytes and nothing else. The JSON round trip it replaces lost digits past 2^53, folded -0 into 0, dropped duplicate keys and reordered integer-like keys -- differences R and Python clients see. - A failure is an `error` verdict carrying only its kind (a message can quote a body or a token), the run goes on, and each request has a timeout. - Exit 0 only when something was compared and all of it was identical; errors, unstable requests or an empty run exit 2. - Request bodies can be bytes (bodyBase64, bodyFile), for uploads. Each fix shown failing by mutation first. Co-Authored-By: Claude Opus 5.5 --- tools/java-parity/normalisers.mjs | 31 +++-- tools/java-parity/parity.mjs | 180 +++++++++++++++++++++--------- tools/java-parity/parity.spec.mjs | 75 +++++++++++-- 3 files changed, 213 insertions(+), 73 deletions(-) diff --git a/tools/java-parity/normalisers.mjs b/tools/java-parity/normalisers.mjs index 4480200e..60b167b9 100644 --- a/tools/java-parity/normalisers.mjs +++ b/tools/java-parity/normalisers.mjs @@ -2,21 +2,34 @@ * The normalisers parity may apply, each declared here with what it removes * and why. A difference is only ever hidden by one of these, by name, in the * run that uses it -- never by loosening the comparison itself. + * + * Each removes exactly its target's bytes and leaves every other byte as it + * came. A JSON parse-and-print round trip would not: it loses digits past + * 2^53, folds -0 into 0, drops duplicate keys, reorders integer-like keys and + * rewrites number formats and escapes -- differences the API's R and Python + * users would see, hidden from the comparison. */ import { gunzipSync } from 'node:zlib'; -/** Drop `summary.token`: an analysis token holds the time and a per-process counter, so two runs never share one. */ -function withoutToken(text) { - const value = JSON.parse(text); - if (value?.summary && typeof value.summary === 'object') delete value.summary.token; - return JSON.stringify(value); +/** + * An analysis token holds the time and a per-process counter, so two runs + * never share one: its value -- only its value -- is blanked inside + * `summary`. + */ +function withoutToken(bytes) { + // latin1 maps bytes to code units one to one, so nothing else is re-encoded. + const text = bytes.toString('latin1'); + return Buffer.from( + text.replace(/("summary"\s*:\s*\{[^{}]*?"token"\s*:\s*)"(?:[^"\\]|\\.)*"/, '$1""'), + 'latin1' + ); } export const normalisers = { /** Byte for byte. */ identity: (body) => body, - /** An analysis result (JSON) without its token. */ - analysisResult: (body) => Buffer.from(withoutToken(body.toString('utf8'))), - /** The gzipped result download, decompressed, without its token. */ - analysisResultGz: (body) => Buffer.from(withoutToken(gunzipSync(body).toString('utf8'))), + /** An analysis result (JSON) with its token blanked. */ + analysisResult: (body) => withoutToken(body), + /** The gzipped result download, decompressed, with its token blanked. Not gzip: an error. */ + analysisResultGz: (body) => withoutToken(gunzipSync(body)), }; diff --git a/tools/java-parity/parity.mjs b/tools/java-parity/parity.mjs index ec00abf3..883cd4fc 100644 --- a/tools/java-parity/parity.mjs +++ b/tools/java-parity/parity.mjs @@ -2,68 +2,122 @@ /** * Java-against-Java parity (spec 010, research R7): the same requests to a * reference -- what answers today -- and a candidate -- what replaces it -- - * compared on status, the headers clients read, and the bytes of the body - * after a declared normaliser. + * compared on status, every response header but named noise, and the bytes + * of the body after a declared normaliser. * - * Reports carry digests and verdicts, never bodies: some answers hold - * people's records, and with --redact not even the request, because some + * Over node:http, not fetch: fetch drops a Host set by the caller, so each + * side saw its own address and every answer built from Host differed for a + * reason that was not a difference; and fetch hides Transfer-Encoding. + * + * Reports carry digests and verdicts, never bodies -- some answers hold + * people's records -- and errors carry only their kind, never a message, + * which can quote a body. With --redact not even the request, because some * paths are tokens anyone holding them can read. * * Usage: * node tools/java-parity/parity.mjs --reference URL --candidate URL \ - * --requests requests.json [--normalise NAME] [--calibrate] [--redact] [--simultaneous] - * requests.json: [{ "method": "GET", "path": "/ContentService/...", "headers": {}, "body": "..." }] + * --requests requests.json [--normalise NAME] [--host HOST] [--timeout MS] + * [--calibrate] [--redact] [--simultaneous] + * requests.json: [{ "method": "GET", "path": "/...", "headers": {}, + * "body": "text" | "bodyBase64": "..." | "bodyFile": "path" }] + * Exit: 0 all identical; 1 differences; 2 errors, unstable requests, or + * nothing compared -- a run that compared nothing is not a pass; 3 usage. */ import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; +import { request as httpRequest } from 'node:http'; +import { brotliDecompressSync, gunzipSync, inflateSync } from 'node:zlib'; import { normalisers } from './normalisers.mjs'; -/** The headers a client reads; the rest (Date, Server, Connection) differ by nature. */ -// fetch decodes a Content-Encoding alike on both sides; the header itself is -// still compared. A gzip *file* (a download) arrives as its bytes. -const COMPARED_HEADERS = [ - 'content-type', - 'content-disposition', - 'content-length', - 'content-encoding', - 'access-control-allow-origin', - 'cache-control', -]; +/** Headers that differ between any two servers by nature, and only these. */ +const NOISE = new Set(['date', 'connection', 'keep-alive', 'server']); + +function requestBody({ body, bodyBase64, bodyFile }) { + if (bodyFile) return readFileSync(bodyFile); + if (bodyBase64) return Buffer.from(bodyBase64, 'base64'); + return body === undefined ? undefined : Buffer.from(body); +} -async function answer(base, { method = 'GET', path, headers = {}, body }) { - const response = await fetch(`${base}${path}`, { - method, - headers, - body, - // A redirect is an answer to compare, not one to follow. - redirect: 'manual', +/** One answer: status, headers (lower-cased, joined), and the body as sent. */ +function answer(base, req, { host, timeout }) { + const url = new URL(base); + const body = requestBody(req); + return new Promise((resolve, reject) => { + const outgoing = httpRequest( + { + host: url.hostname, + port: url.port, + method: req.method ?? 'GET', + path: req.path, + headers: { + ...(req.headers ?? {}), + ...(host ? { host } : {}), + ...(body ? { 'content-length': body.length } : {}), + }, + }, + (response) => { + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => + resolve({ + status: response.statusCode, + headers: Object.fromEntries( + Object.entries(response.headers).map(([name, value]) => [ + name, + Array.isArray(value) ? value.join(', ') : value, + ]) + ), + body: Buffer.concat(chunks), + }) + ); + response.on('error', reject); + } + ); + outgoing.setTimeout(timeout, () => + outgoing.destroy(Object.assign(new Error(), { kind: 'timeout' })) + ); + outgoing.on('error', reject); + outgoing.end(body); }); - return { - status: response.status, - headers: Object.fromEntries(COMPARED_HEADERS.map((name) => [name, response.headers.get(name)])), - body: Buffer.from(await response.arrayBuffer()), - }; +} + +/** The body a client reads: Content-Encoding undone (the header itself is still compared). */ +function decoded({ headers, body }) { + const encoding = (headers['content-encoding'] ?? '').trim().toLowerCase(); + if (encoding === 'gzip') return gunzipSync(body); + if (encoding === 'br') return brotliDecompressSync(body); + if (encoding === 'deflate') return inflateSync(body); + return body; } const digest = (bytes) => createHash('sha256').update(bytes).digest('hex'); -/** What differs between two answers, after normalising both bodies the same way. */ +/** What differs between two answers, bodies normalised the same way. */ function differences(a, b, normalise) { const found = []; if (a.status !== b.status) found.push('status'); - for (const name of COMPARED_HEADERS) { - // Content-Length follows the body; a normalised body is compared instead. + const names = new Set([...Object.keys(a.headers), ...Object.keys(b.headers)]); + for (const name of [...names].sort()) { + if (NOISE.has(name)) continue; + // Content-Length follows the body; under a normaliser the body is compared instead. if (name === 'content-length' && normalise !== normalisers.identity) continue; if (a.headers[name] !== b.headers[name]) found.push(name); } - if (digest(normalise(a.body)) !== digest(normalise(b.body))) found.push('body'); + if (digest(normalise(decoded(a))) !== digest(normalise(decoded(b)))) found.push('body'); return found; } +/** Only the kind of a failure: a message can quote the body or the URL. */ +const kindOf = (error) => + error?.kind ?? + (error?.code + ? `network ${error.code}` + : 'failed (body unreadable or not as the normaliser expects)'); + /** - * Compare every request. With `calibrate`, the reference is first asked - * twice: where it disagrees with itself (live third-party data), the request - * is "unstable" and not counted either way. + * Compare every request. With `calibrate`, the reference is asked again: + * where it disagrees with itself (live third-party data), the request is + * "unstable" and counted neither way. */ export async function compareAll( requests, @@ -71,6 +125,8 @@ export async function compareAll( reference, candidate, normalise = normalisers.identity, + host, + timeout = 120_000, calibrate = false, redact = false, simultaneous = false, @@ -78,28 +134,40 @@ export async function compareAll( ) { const results = []; const counts = { identical: 0, different: 0 }; - for (const [index, request] of requests.entries()) { - const label = redact ? `#${index}` : `${request.method ?? 'GET'} ${request.path}`; - const [ref, cand] = simultaneous - ? await Promise.all([answer(reference, request), answer(candidate, request)]) - : [await answer(reference, request), await answer(candidate, request)]; - const result = { request: label, status: [ref.status, cand.status] }; - if (calibrate) { - const again = await answer(reference, request); - result.calibrated = differences(ref, again, normalise).length === 0; - if (!result.calibrated) { - counts.unstable = (counts.unstable ?? 0) + 1; - results.push(result); - continue; + const count = (key) => (counts[key] = (counts[key] ?? 0) + 1); + const ask = (base, req) => answer(base, req, { host, timeout }); + for (const [index, req] of requests.entries()) { + const label = redact ? `#${index}` : `${req.method ?? 'GET'} ${req.path}`; + try { + const [ref, cand] = simultaneous + ? await Promise.all([ask(reference, req), ask(candidate, req)]) + : [await ask(reference, req), await ask(candidate, req)]; + const result = { request: label, status: [ref.status, cand.status] }; + if (calibrate) { + result.calibrated = differences(ref, await ask(reference, req), normalise).length === 0; + if (!result.calibrated) { + count('unstable'); + results.push(result); + continue; + } } + result.differs = differences(ref, cand, normalise); + count(result.differs.length ? 'different' : 'identical'); + results.push(result); + } catch (error) { + count('errors'); + results.push({ request: label, error: kindOf(error) }); } - result.differs = differences(ref, cand, normalise); - counts[result.differs.length ? 'different' : 'identical'] += 1; - results.push(result); } return { counts, results }; } +/** The exit code a report earns: 0 only when something was compared and all of it was identical. */ +export function verdict({ counts }) { + if (counts.errors || counts.unstable || counts.identical + counts.different === 0) return 2; + return counts.different ? 1 : 0; +} + // Started directly rather than imported by a test. if (process.argv[1] && process.argv[1].endsWith('parity.mjs')) { const args = process.argv.slice(2); @@ -111,18 +179,20 @@ if (process.argv[1] && process.argv[1].endsWith('parity.mjs')) { const normalise = normalisers[option('normalise') ?? 'identity']; if (!option('reference') || !option('candidate') || !option('requests') || !normalise) { console.error( - 'usage: parity.mjs --reference URL --candidate URL --requests FILE [--normalise NAME] [--calibrate] [--redact] [--simultaneous]' + 'usage: parity.mjs --reference URL --candidate URL --requests FILE [--normalise NAME] [--host HOST] [--timeout MS] [--calibrate] [--redact] [--simultaneous]' ); - process.exit(2); + process.exit(3); } const report = await compareAll(JSON.parse(readFileSync(option('requests'), 'utf8')), { reference: option('reference'), candidate: option('candidate'), normalise, + host: option('host'), + timeout: option('timeout') ? Number(option('timeout')) : undefined, calibrate: flag('calibrate'), redact: flag('redact'), simultaneous: flag('simultaneous'), }); console.log(JSON.stringify(report, null, 2)); - process.exit(report.counts.different ? 1 : 0); + process.exit(verdict(report)); } diff --git a/tools/java-parity/parity.spec.mjs b/tools/java-parity/parity.spec.mjs index 0e1cba8b..8359724f 100644 --- a/tools/java-parity/parity.spec.mjs +++ b/tools/java-parity/parity.spec.mjs @@ -6,7 +6,7 @@ import { createServer } from 'node:http'; import { gzipSync } from 'node:zlib'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { compareAll } from './parity.mjs'; +import { compareAll, verdict } from './parity.mjs'; import { normalisers } from './normalisers.mjs'; /** A server answering from a table of path -> { status, headers, body }. */ @@ -16,6 +16,10 @@ function stub(table) { calls += 1; // Live third-party data: a different answer every time. if (request.url === '/live') return response.end(`live ${calls}`); + // An answer built from the Host it was asked with. + if (request.url === '/host') return response.end(`host ${request.headers.host}`); + // A server that never answers. + if (request.url === '/hang') return; const answer = table[request.url] ?? { status: 404, body: 'none' }; response.writeHead(answer.status ?? 200, answer.headers ?? { 'content-type': 'text/plain' }); response.end(answer.body); @@ -36,11 +40,17 @@ const reference = { headers: { 'content-type': 'text/plain', 'content-disposition': 'a.txt' }, body: 'x', }, - '/token': { headers: json, body: '{"summary":{"token":"T1","type":"X"},"n":1}' }, + '/redirect': { status: 302, headers: { location: '/a' }, body: '' }, + '/vary': { headers: { 'content-type': 'text/plain', vary: 'Accept' }, body: 'x' }, + '/token': { + headers: json, + body: '{"summary":{"token":"T1","type":"X"},"n":9007199254740993}', + }, '/gz': { headers: { 'content-type': 'application/gzip' }, body: gzipSync('{"summary":{"token":"T1"},"n":1}'), }, + '/text404': { status: 404, body: 'secret-body-text' }, '/secret/ABC123': { body: 'x' }, }; const candidate = { @@ -51,7 +61,12 @@ const candidate = { headers: { 'content-type': 'text/plain', 'content-disposition': 'b.txt' }, body: 'x', }, - '/token': { headers: json, body: '{"summary":{"token":"T2","type":"X"},"n":1}' }, + '/redirect': { status: 302, headers: { location: '/b' }, body: '' }, + '/vary': { headers: { 'content-type': 'text/plain' }, body: 'x' }, + '/token': { + headers: json, + body: '{"summary":{"token":"T2","type":"X"},"n":9007199254740993}', + }, '/gz': { headers: { 'content-type': 'application/gzip' }, body: gzipSync('{"summary":{"token":"T9"},"n":1}'), @@ -65,13 +80,15 @@ beforeAll(async () => { cand = await stub(candidate); }); afterAll(async () => { + ref.server.closeAllConnections(); + cand.server.closeAllConnections(); await new Promise((resolve) => ref.server.close(resolve)); await new Promise((resolve) => cand.server.close(resolve)); }); const run = (paths, options = {}) => compareAll( - paths.map((path) => ({ method: 'GET', path })), + paths.map((path) => (typeof path === 'string' ? { method: 'GET', path } : path)), { reference: ref.url, candidate: cand.url, ...options } ); @@ -79,6 +96,7 @@ describe('the parity harness', () => { it('finds identical answers identical', async () => { const report = await run(['/same']); expect(report.counts).toEqual({ identical: 1, different: 0 }); + expect(verdict(report)).toBe(0); }); it('finds a one-byte, a status and a header difference', async () => { @@ -89,9 +107,20 @@ describe('the parity harness', () => { ['status'], ['content-disposition'], ]); + expect(verdict(report)).toBe(1); + }); + + it('compares every header but named noise: a redirect target, a Vary', async () => { + const report = await run(['/redirect', '/vary']); + expect(report.results.map((r) => r.differs)).toEqual([['location'], ['vary']]); }); - it('removes the analysis token only through a declared normaliser', async () => { + it('asks both sides with the same Host', async () => { + const report = await run([{ method: 'GET', path: '/host' }], { host: 'reactome.example' }); + expect(report.counts.identical).toBe(1); + }); + + it('removes the analysis token only through a declared normaliser, leaving every other byte', async () => { expect((await run(['/token'])).counts.different).toBe(1); expect( (await run(['/token'], { normalise: normalisers.analysisResult })).counts.identical @@ -99,23 +128,51 @@ describe('the parity harness', () => { expect((await run(['/gz'], { normalise: normalisers.analysisResultGz })).counts.identical).toBe( 1 ); + // Byte for byte apart from the token: no JSON round trip to lose a digit. + const strip = (text) => normalisers.analysisResult(Buffer.from(text)).toString(); + expect(strip('{"summary":{"token":"T1"},"n":9007199254740993}')).toBe( + '{"summary":{"token":""},"n":9007199254740993}' + ); + expect(strip('{"summary":{"token":"T1"},"n":-0}')).not.toBe( + strip('{"summary":{"token":"T1"},"n":0}') + ); + }); + + it('records a failure as an error, with no body or secret in it, and does not stop', async () => { + const report = await run(['/text404', '/same', '/hang'], { + normalise: normalisers.analysisResultGz, + redact: true, + timeout: 300, + }); + expect(report.results.map((r) => r.error !== undefined)).toEqual([true, true, true]); + expect(report.counts.errors).toBe(3); + expect(JSON.stringify(report)).not.toContain('secret-body-text'); + expect(verdict(report)).toBe(2); + const plain = await run(['/same', '/hang'], { timeout: 300 }); + expect(plain.counts).toEqual({ identical: 1, different: 0, errors: 1 }); }); it('never puts a body, and with redaction no path, in its report', async () => { const report = await run(['/secret/ABC123'], { redact: true }); - const text = JSON.stringify(report); - expect(text).not.toContain('ABC123'); - expect(text).not.toContain('"x"'); + expect(JSON.stringify(report)).not.toContain('ABC123'); expect(report.results[0].request).toBe('#0'); + expect(Object.keys(report.results[0]).sort()).toEqual(['differs', 'request', 'status']); }); it('calibrates against the reference itself: a difference counts only where the reference agreed with itself', async () => { const report = await run(['/same', '/byte'], { calibrate: true }); expect(report.results.map((r) => r.calibrated)).toEqual([true, true]); expect(report.counts).toEqual({ identical: 1, different: 1 }); - // A reference that disagrees with itself is unstable, counted neither way. + // A reference that disagrees with itself is unstable, counted neither way, + // and a run that compared nothing is not a pass. const live = await run(['/live'], { calibrate: true }); expect(live.counts).toEqual({ identical: 0, different: 0, unstable: 1 }); expect(live.results[0].calibrated).toBe(false); + expect(verdict(live)).toBe(2); + }); + + it('asks both at once with --simultaneous, with the same result', async () => { + const report = await run(['/same', '/byte'], { simultaneous: true }); + expect(report.counts).toEqual({ identical: 1, different: 1 }); }); }); From ccc56dc175c421fc89d0f5f774b6f26f35381877 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Sat, 17 Oct 2026 13:54:32 +0000 Subject: [PATCH 3/5] Review of #507, second pass: no false pass from the base URL, a real timeout - A base URL's context path is kept. Dropped, both sides went to the same wrong resource and matching 404s compared as a pass, exit 0. - The scheme is honoured (https supported) or refused, never ignored; a base with a query or fragment is refused; IPv6 hosts work. - The timeout covers the whole request: a body trickled a byte at a time never went quiet and held the run open. - Headers come from rawHeaders, every copy kept: the parsed map kept one Content-Type of two. - A status difference is reported even when the body cannot be normalised; a token normaliser that finds no token is an error; errors name what failed (network, body decoding, normaliser). - --ignore-header declares a header to skip for the run, and the report lists it. Each shown failing first; the suite passes three runs in three. Co-Authored-By: Claude Opus 5.5 --- tools/java-parity/normalisers.mjs | 8 +- tools/java-parity/parity.mjs | 129 ++++++++++++++++++++++-------- tools/java-parity/parity.spec.mjs | 61 ++++++++++++++ 3 files changed, 159 insertions(+), 39 deletions(-) diff --git a/tools/java-parity/normalisers.mjs b/tools/java-parity/normalisers.mjs index 60b167b9..e40db6eb 100644 --- a/tools/java-parity/normalisers.mjs +++ b/tools/java-parity/normalisers.mjs @@ -19,10 +19,10 @@ import { gunzipSync } from 'node:zlib'; function withoutToken(bytes) { // latin1 maps bytes to code units one to one, so nothing else is re-encoded. const text = bytes.toString('latin1'); - return Buffer.from( - text.replace(/("summary"\s*:\s*\{[^{}]*?"token"\s*:\s*)"(?:[^"\\]|\\.)*"/, '$1""'), - 'latin1' - ); + const token = /("summary"\s*:\s*\{[^{}]*?"token"\s*:\s*)"(?:[^"\\]|\\.)*"/; + // No token to blank is not an answer this normaliser is for: an error, not a silent pass-through. + if (!token.test(text)) throw new Error('no summary.token'); + return Buffer.from(text.replace(token, '$1""'), 'latin1'); } export const normalisers = { diff --git a/tools/java-parity/parity.mjs b/tools/java-parity/parity.mjs index 883cd4fc..4c7ca33c 100644 --- a/tools/java-parity/parity.mjs +++ b/tools/java-parity/parity.mjs @@ -17,7 +17,7 @@ * Usage: * node tools/java-parity/parity.mjs --reference URL --candidate URL \ * --requests requests.json [--normalise NAME] [--host HOST] [--timeout MS] - * [--calibrate] [--redact] [--simultaneous] + * [--ignore-header NAME]... [--calibrate] [--redact] [--simultaneous] * requests.json: [{ "method": "GET", "path": "/...", "headers": {}, * "body": "text" | "bodyBase64": "..." | "bodyFile": "path" }] * Exit: 0 all identical; 1 differences; 2 errors, unstable requests, or @@ -26,6 +26,7 @@ import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { request as httpRequest } from 'node:http'; +import { request as httpsRequest } from 'node:https'; import { brotliDecompressSync, gunzipSync, inflateSync } from 'node:zlib'; import { normalisers } from './normalisers.mjs'; @@ -38,17 +39,52 @@ function requestBody({ body, bodyBase64, bodyFile }) { return body === undefined ? undefined : Buffer.from(body); } -/** One answer: status, headers (lower-cased, joined), and the body as sent. */ -function answer(base, req, { host, timeout }) { +/** + * A base URL as the place to send a path: scheme, host (an IPv6 one without + * its brackets), port, and any context path, which is kept -- dropping it + * sent both sides to the same wrong resource, whose matching 404s compared + * as a pass. Anything it cannot honour is refused, never ignored. + */ +function baseOf(base) { const url = new URL(base); + if (url.protocol !== 'http:' && url.protocol !== 'https:') + throw new Error(`base must be http or https: ${url.protocol}`); + if (url.search || url.hash) throw new Error('base must not carry a query or fragment'); + return { + send: url.protocol === 'https:' ? httpsRequest : httpRequest, + hostname: url.hostname.replace(/^\[(.*)\]$/, '$1'), + port: url.port || (url.protocol === 'https:' ? 443 : 80), + prefix: url.pathname.replace(/\/$/, ''), + }; +} + +/** + * Headers as sent: from rawHeaders, every copy kept and joined in order -- + * the parsed map keeps one Content-Type or Location of two, so a side that + * sent two compared equal to one that sent one. + */ +function rawHeaders(raw) { + const headers = {}; + for (let at = 0; at < raw.length; at += 2) { + const name = raw[at].toLowerCase(); + headers[name] = name in headers ? `${headers[name]}, ${raw[at + 1]}` : raw[at + 1]; + } + return headers; +} + +const failure = (kind) => Object.assign(new Error(kind), { kind }); + +/** One answer: status, headers as sent, and the body as sent -- within `timeout` in all. */ +function answer(base, req, { host, timeout }) { + const { send, hostname, port, prefix } = baseOf(base); const body = requestBody(req); return new Promise((resolve, reject) => { - const outgoing = httpRequest( + const outgoing = send( { - host: url.hostname, - port: url.port, + hostname, + port, method: req.method ?? 'GET', - path: req.path, + path: `${prefix}${req.path}`, headers: { ...(req.headers ?? {}), ...(host ? { host } : {}), @@ -58,25 +94,24 @@ function answer(base, req, { host, timeout }) { (response) => { const chunks = []; response.on('data', (chunk) => chunks.push(chunk)); - response.on('end', () => + response.on('end', () => { + clearTimeout(timer); resolve({ status: response.statusCode, - headers: Object.fromEntries( - Object.entries(response.headers).map(([name, value]) => [ - name, - Array.isArray(value) ? value.join(', ') : value, - ]) - ), + headers: rawHeaders(response.rawHeaders), body: Buffer.concat(chunks), - }) - ); + }); + }); response.on('error', reject); } ); - outgoing.setTimeout(timeout, () => - outgoing.destroy(Object.assign(new Error(), { kind: 'timeout' })) - ); - outgoing.on('error', reject); + // The whole request, not a silence: a body trickled a byte at a time + // never goes quiet, and held the run open. + const timer = setTimeout(() => outgoing.destroy(failure('timeout')), timeout); + outgoing.on('error', (error) => { + clearTimeout(timer); + reject(error); + }); outgoing.end(body); }); } @@ -84,35 +119,51 @@ function answer(base, req, { host, timeout }) { /** The body a client reads: Content-Encoding undone (the header itself is still compared). */ function decoded({ headers, body }) { const encoding = (headers['content-encoding'] ?? '').trim().toLowerCase(); - if (encoding === 'gzip') return gunzipSync(body); - if (encoding === 'br') return brotliDecompressSync(body); - if (encoding === 'deflate') return inflateSync(body); + try { + if (encoding === 'gzip') return gunzipSync(body); + if (encoding === 'br') return brotliDecompressSync(body); + if (encoding === 'deflate') return inflateSync(body); + } catch { + throw failure('body decoding'); + } return body; } +/** A body through the normaliser; a body it cannot take is the normaliser's failure. */ +function normalised(normalise, answer) { + const body = decoded(answer); + try { + return normalise(body); + } catch { + throw failure('normaliser'); + } +} + const digest = (bytes) => createHash('sha256').update(bytes).digest('hex'); /** What differs between two answers, bodies normalised the same way. */ -function differences(a, b, normalise) { +function differences(a, b, normalise, ignored = new Set()) { const found = []; if (a.status !== b.status) found.push('status'); const names = new Set([...Object.keys(a.headers), ...Object.keys(b.headers)]); for (const name of [...names].sort()) { - if (NOISE.has(name)) continue; + if (NOISE.has(name) || ignored.has(name)) continue; // Content-Length follows the body; under a normaliser the body is compared instead. if (name === 'content-length' && normalise !== normalisers.identity) continue; if (a.headers[name] !== b.headers[name]) found.push(name); } - if (digest(normalise(decoded(a))) !== digest(normalise(decoded(b)))) found.push('body'); + // Different statuses are a difference whatever the bodies are: an error + // page the normaliser cannot take must not turn that into an error. + if (found.includes('status')) { + if (digest(decoded(a)) !== digest(decoded(b))) found.push('body'); + return found; + } + if (digest(normalised(normalise, a)) !== digest(normalised(normalise, b))) found.push('body'); return found; } /** Only the kind of a failure: a message can quote the body or the URL. */ -const kindOf = (error) => - error?.kind ?? - (error?.code - ? `network ${error.code}` - : 'failed (body unreadable or not as the normaliser expects)'); +const kindOf = (error) => error?.kind ?? (error?.code ? `network ${error.code}` : 'failed'); /** * Compare every request. With `calibrate`, the reference is asked again: @@ -130,8 +181,13 @@ export async function compareAll( calibrate = false, redact = false, simultaneous = false, + ignoreHeaders = [], } ) { + // Refused before anything is sent: a base it cannot honour is a usage error. + baseOf(reference); + baseOf(candidate); + const ignored = new Set(ignoreHeaders.map((name) => name.toLowerCase())); const results = []; const counts = { identical: 0, different: 0 }; const count = (key) => (counts[key] = (counts[key] ?? 0) + 1); @@ -144,14 +200,15 @@ export async function compareAll( : [await ask(reference, req), await ask(candidate, req)]; const result = { request: label, status: [ref.status, cand.status] }; if (calibrate) { - result.calibrated = differences(ref, await ask(reference, req), normalise).length === 0; + result.calibrated = + differences(ref, await ask(reference, req), normalise, ignored).length === 0; if (!result.calibrated) { count('unstable'); results.push(result); continue; } } - result.differs = differences(ref, cand, normalise); + result.differs = differences(ref, cand, normalise, ignored); count(result.differs.length ? 'different' : 'identical'); results.push(result); } catch (error) { @@ -159,7 +216,8 @@ export async function compareAll( results.push({ request: label, error: kindOf(error) }); } } - return { counts, results }; + // A declared exclusion is part of the result, never silent. + return { counts, ...(ignored.size ? { ignoredHeaders: [...ignored] } : {}), results }; } /** The exit code a report earns: 0 only when something was compared and all of it was identical. */ @@ -179,7 +237,7 @@ if (process.argv[1] && process.argv[1].endsWith('parity.mjs')) { const normalise = normalisers[option('normalise') ?? 'identity']; if (!option('reference') || !option('candidate') || !option('requests') || !normalise) { console.error( - 'usage: parity.mjs --reference URL --candidate URL --requests FILE [--normalise NAME] [--host HOST] [--timeout MS] [--calibrate] [--redact] [--simultaneous]' + 'usage: parity.mjs --reference URL --candidate URL --requests FILE [--normalise NAME] [--host HOST] [--timeout MS] [--ignore-header NAME]... [--calibrate] [--redact] [--simultaneous]' ); process.exit(3); } @@ -192,6 +250,7 @@ if (process.argv[1] && process.argv[1].endsWith('parity.mjs')) { calibrate: flag('calibrate'), redact: flag('redact'), simultaneous: flag('simultaneous'), + ignoreHeaders: args.flatMap((arg, at) => (arg === '--ignore-header' ? [args[at + 1]] : [])), }); console.log(JSON.stringify(report, null, 2)); process.exit(verdict(report)); diff --git a/tools/java-parity/parity.spec.mjs b/tools/java-parity/parity.spec.mjs index 8359724f..cd008fa7 100644 --- a/tools/java-parity/parity.spec.mjs +++ b/tools/java-parity/parity.spec.mjs @@ -4,6 +4,7 @@ // stub servers standing in for the reference (what runs today) and the // candidate (what replaces it). import { createServer } from 'node:http'; +import { setInterval, clearInterval } from 'node:timers'; import { gzipSync } from 'node:zlib'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { compareAll, verdict } from './parity.mjs'; @@ -20,6 +21,19 @@ function stub(table) { if (request.url === '/host') return response.end(`host ${request.headers.host}`); // A server that never answers. if (request.url === '/hang') return; + // One that never stops answering, a byte at a time. + if (request.url === '/trickle') { + const timer = setInterval(() => response.write('.'), 50); + response.on('close', () => clearInterval(timer)); + return; + } + // Two Content-Types, which a parsed header map shows as one. + if (request.url === '/twice' && !table.name) { + response.setHeader('content-type', ['text/plain', 'text/html']); + return response.end('x'); + } + // Context-path aware: only /ctx/x exists. + if (request.url === '/ctx/x') return response.end(`ctx ${table.name ?? ''}`); const answer = table[request.url] ?? { status: 404, body: 'none' }; response.writeHead(answer.status ?? 200, answer.headers ?? { 'content-type': 'text/plain' }); response.end(answer.body); @@ -55,6 +69,8 @@ const reference = { }; const candidate = { ...reference, + name: 'candidate', + '/twice': { headers: { 'content-type': 'text/plain' }, body: 'x' }, '/byte': { body: 'abd' }, '/status': { status: 500, body: 'x' }, '/header': { @@ -175,4 +191,49 @@ describe('the parity harness', () => { const report = await run(['/same', '/byte'], { simultaneous: true }); expect(report.counts).toEqual({ identical: 1, different: 1 }); }); + + it("keeps a base URL's path, and refuses a scheme or query it cannot honour", async () => { + // Without the prefix both sides would answer the same 404 -- a false pass. + const report = await compareAll([{ method: 'GET', path: '/x' }], { + reference: `${ref.url}/ctx`, + candidate: `${cand.url}/ctx`, + }); + expect(report.counts).toEqual({ identical: 0, different: 1 }); + for (const bad of ['ftp://127.0.0.1:1', `${ref.url}/?q=1`]) + await expect( + compareAll([{ path: '/x' }], { reference: bad, candidate: cand.url }) + ).rejects.toThrow(/base/); + }); + + it('times out the whole request, not just a silence', async () => { + const started = Date.now(); + const report = await run(['/trickle'], { timeout: 300 }); + expect(report.results[0].error).toBe('timeout'); + expect(Date.now() - started).toBeLessThan(2000); + }); + + it('sees a header sent twice', async () => { + const report = await run(['/twice']); + expect(report.results[0].differs).toContain('content-type'); + // And how the body was framed: Content-Length on one side, chunked on the other. + expect(report.results[0].differs).toContain('transfer-encoding'); + }); + + it('names what failed: the normaliser, not the network', async () => { + const report = await run(['/same'], { normalise: normalisers.analysisResultGz }); + expect(report.results[0].error).toBe('normaliser'); + // A token normaliser that finds no token is an error, not a silent pass. + expect(() => normalisers.analysisResult(Buffer.from('{"n":1}'))).toThrow(); + }); + + it('reports a status difference even when the body cannot be normalised', async () => { + const report = await run(['/status'], { normalise: normalisers.analysisResult }); + expect(report.results[0].differs).toContain('status'); + }); + + it('ignores a header only when the run declares it, and says so', async () => { + const report = await run(['/header'], { ignoreHeaders: ['content-disposition'] }); + expect(report.counts.identical).toBe(1); + expect(report.ignoredHeaders).toEqual(['content-disposition']); + }); }); From 29536555e572eb44ec152d81c82efb7df7fa21ff Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Sat, 17 Oct 2026 14:03:41 +0000 Subject: [PATCH 4/5] Tasks 010: T003-T007 ticked with their PRs; T008 waits for the first PDF Co-Authored-By: Claude Opus 5.5 --- specs/010-java-services/tasks.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/specs/010-java-services/tasks.md b/specs/010-java-services/tasks.md index fedf5663..3c7c2cae 100644 --- a/specs/010-java-services/tasks.md +++ b/specs/010-java-services/tasks.md @@ -21,7 +21,7 @@ - [ ] T001 [EXT] Ask the Reactome organisation's owners to create the library-service repository, licensed AGPL-3.0 (research R2), and record its name in this file and in `plan.md` - [ ] T002 [EXT] Ask the owners of `reactome/content-service` and `reactome/analysis-service` to merge the deployed branches (`7b9eceb` on `add-content-page-endpoints`; `9b147e1`, PR #25). The pins stay on these commits either way (research R1). -- [x] T003 [P] Record the pinned commits of research R1 in one place, `deploy/java/PINS.md`, each with the reason it is pinned and how it was verified (byte comparison of classes) +- [x] T003 [P] Record the pinned commits of research R1 in one place, `deploy/java/PINS.md`, each with the reason it is pinned and how it was verified (byte comparison of classes) — #505 --- @@ -31,22 +31,22 @@ ### Tests first -- [x] T004 Extend `deploy/nginx/routes.spec.ts`: +- [x] T004 Extend `deploy/nginx/routes.spec.ts`: — #505 - parse `deploy/nginx/common/routes.conf` as well as `routes-core.conf`, in nginx's order; - classify each `proxy_pass` as node, Java service or Tomcat; - assert that no URL in node's endpoint samples or the proxy-order node list reaches a Java service or Tomcat. - Show it failing by adding a location that sends a node URL to Tomcat. -- [x] T005 Add a test in `deploy/nginx/routes.spec.ts` that an exporter address routed away from Tomcat keeps the exporter location's rate limit. Show it failing with a `routes-core.conf` location for `/ContentService/exporter/event/` (research R6 trap). +- [x] T005 Add a test in `deploy/nginx/routes.spec.ts` that an exporter address routed away from Tomcat keeps the exporter location's rate limit. Show it failing with a `routes-core.conf` location for `/ContentService/exporter/event/` (research R6 trap). — #505 ### Implementation -- [ ] T006 Create the parity harness in `tools/java-parity/`, per research R7: +- [x] T006 Create the parity harness in `tools/java-parity/`, per research R7: — #507 - same request to a reference and a candidate, same Host; - compare status, a fixed header set and the SHA-256 of the body; - per-class normalisers, declared in `tools/java-parity/normalisers.mjs`; - JSON report of identical, different and both-error counts, with no bodies or token values in the output. -- [ ] T007 Add a self-test to `tools/java-parity/parity.spec.mjs`. Two stub servers that differ in one byte must report a difference; identical stubs must not. Show it failing with the comparison disabled. -- [ ] T008 [P] Add the PDF normaliser to `tools/java-parity/normalisers.mjs`: +- [x] T007 Add a self-test to `tools/java-parity/parity.spec.mjs`. Two stub servers that differ in one byte must report a difference; identical stubs must not. Show it failing with the comparison disabled. — #507 +- [ ] T008 [P] (With the first PDF comparison, T030: it needs `qpdf` in a container.) Add the PDF normaliser to `tools/java-parity/normalisers.mjs`: - `qpdf --qdf --object-streams=disable --static-id`, run in a throwaway container; - mask CreationDate, ModDate and the cover-page date. - Test it in `tools/java-parity/parity.spec.mjs`: two PDFs differing only in those fields compare equal, and a PDF with a changed body does not. @@ -205,12 +205,11 @@ Each cutover is one PR. It adds the group's patterns to node's table, one nginx ## Phase 8: Polish and side findings -- [ ] T058 [EXT] Decide the analysis report's species overview figure. The PDF report embeds the retired Fireworks image, drawn by Java's fireworks-exporter; the site's overview is now the Reacfoam (Voronoi) map. Showing that in the report needs analysis-report changed upstream (its owners) or the figure supplied to it. - - [ ] T054 [P] Set `client_max_body_size` in `deploy/nginx/local.conf` to match the other deployments (64 MB), so local uploads over 1 MB are not refused. Test it with `deploy/nginx/test-config.sh`. - [ ] T055 [P] Declare the proxy package that `projects/website-angular/src/scripts/serve-prod.js` uses in `package.json` (it is only present as a dev-only transitive dependency), and check the lockfile changes only that entry - [ ] T056 Update `specs/006-content-service-to-node/tasks.md` Phase 9 rows with the PR that moved each Java-homed operation, as each moves - [ ] T057 Update the curator-facing documents if any reader-visible behaviour is touched by a cutover (constitution: Development Workflow) +- [ ] T058 [EXT] Decide the analysis report's species overview figure. The PDF report embeds the retired Fireworks image, drawn by Java's fireworks-exporter; the site's overview is now the Reacfoam (Voronoi) map. Showing that in the report needs analysis-report changed upstream (its owners) or the figure supplied to it. --- From fe38b07eb4ee98e0d1c234597f7ab26b9894be18 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Sat, 17 Oct 2026 14:10:37 +0000 Subject: [PATCH 5/5] Review of #507, last pass: no timer left armed, usage errors exit 3 - A response reset part way through clears its timer: the run went on, but the process stayed alive until the timeout fired (two minutes by default) for anyone importing the harness. - An unusable base URL or a dangling --ignore-header exits 3 (usage), not 1, which means "differences". - A status difference whose error page claims an encoding it lacks is compared as sent, and reported as a difference rather than an error. Each shown failing first; the timer test measures timers armed by the run itself, not the test runner's. Co-Authored-By: Claude Opus 5.5 --- tools/java-parity/parity.mjs | 34 ++++++++++++++++- tools/java-parity/parity.spec.mjs | 61 +++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/tools/java-parity/parity.mjs b/tools/java-parity/parity.mjs index 4c7ca33c..1b28d3d2 100644 --- a/tools/java-parity/parity.mjs +++ b/tools/java-parity/parity.mjs @@ -102,7 +102,12 @@ function answer(base, req, { host, timeout }) { body: Buffer.concat(chunks), }); }); - response.on('error', reject); + // A reset part way through: the run goes on at once, and no timer + // is left armed to keep the process alive until it fires. + response.on('error', (error) => { + clearTimeout(timer); + reject(error); + }); } ); // The whole request, not a silence: a body trickled a byte at a time @@ -155,7 +160,15 @@ function differences(a, b, normalise, ignored = new Set()) { // Different statuses are a difference whatever the bodies are: an error // page the normaliser cannot take must not turn that into an error. if (found.includes('status')) { - if (digest(decoded(a)) !== digest(decoded(b))) found.push('body'); + const raw = (answer) => { + try { + return decoded(answer); + } catch { + // An error page that claims an encoding it lacks: compare it as sent. + return answer.body; + } + }; + if (digest(raw(a)) !== digest(raw(b))) found.push('body'); return found; } if (digest(normalised(normalise, a)) !== digest(normalised(normalise, b))) found.push('body'); @@ -241,6 +254,23 @@ if (process.argv[1] && process.argv[1].endsWith('parity.mjs')) { ); process.exit(3); } + // Usage errors exit 3, never 1 ("differences"): an unusable base, a + // dangling --ignore-header. + const usage = (message) => { + console.error(`parity.mjs: ${message}`); + process.exit(3); + }; + for (const base of [option('reference'), option('candidate')]) { + try { + baseOf(base); + } catch { + usage('each base must be an http or https URL without a query'); + } + } + args.forEach((arg, at) => { + if (arg === '--ignore-header' && (!args[at + 1] || args[at + 1].startsWith('--'))) + usage('--ignore-header needs a header name'); + }); const report = await compareAll(JSON.parse(readFileSync(option('requests'), 'utf8')), { reference: option('reference'), candidate: option('candidate'), diff --git a/tools/java-parity/parity.spec.mjs b/tools/java-parity/parity.spec.mjs index cd008fa7..a0435a45 100644 --- a/tools/java-parity/parity.spec.mjs +++ b/tools/java-parity/parity.spec.mjs @@ -5,6 +5,10 @@ // candidate (what replaces it). import { createServer } from 'node:http'; import { setInterval, clearInterval } from 'node:timers'; +import { spawnSync } from 'node:child_process'; +import { writeFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { gzipSync } from 'node:zlib'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { compareAll, verdict } from './parity.mjs'; @@ -32,6 +36,15 @@ function stub(table) { response.setHeader('content-type', ['text/plain', 'text/html']); return response.end('x'); } + // A body cut off by a reset, part way. + if (request.url === '/reset') { + response.writeHead(200, { 'content-length': '100' }); + response.write('partial'); + return setTimeout(() => request.socket.destroy(), 20); + } + // A status difference whose error page claims gzip and is not. + if (request.url === '/badgz' && table.name) + return response.writeHead(500, { 'content-encoding': 'gzip' }).end('not gzip'); // Context-path aware: only /ctx/x exists. if (request.url === '/ctx/x') return response.end(`ctx ${table.name ?? ''}`); const answer = table[request.url] ?? { status: 404, body: 'none' }; @@ -65,6 +78,7 @@ const reference = { body: gzipSync('{"summary":{"token":"T1"},"n":1}'), }, '/text404': { status: 404, body: 'secret-body-text' }, + '/badgz': { headers: { 'content-type': 'text/plain' }, body: 'ok' }, '/secret/ABC123': { body: 'x' }, }; const candidate = { @@ -236,4 +250,51 @@ describe('the parity harness', () => { expect(report.counts.identical).toBe(1); expect(report.ignoredHeaders).toEqual(['content-disposition']); }); + + it('lets a reset part way through end at once, leaving no timer behind', async () => { + // Timers of the run's own: the process may hold others (the test runner's). + const timers = () => + process.getActiveResourcesInfo().filter((kind) => kind === 'Timeout').length; + const before = timers(); + const started = Date.now(); + const report = await run(['/reset'], { timeout: 60_000 }); + expect(report.results[0].error).toMatch(/^network/); + expect(Date.now() - started).toBeLessThan(2000); + // A timer left armed would keep the process alive for the full minute. + expect(timers() - before).toBe(0); + }); + + it('reports a status difference even when the error page cannot be decoded', async () => { + const report = await run(['/badgz']); + expect(report.results[0].differs).toEqual(expect.arrayContaining(['status', 'body'])); + expect(verdict(report)).toBe(1); + }); + + it('exits 3, not 1, on a usage error', () => { + const dir = mkdtempSync(join(tmpdir(), 'parity-')); + try { + const requests = join(dir, 'requests.json'); + writeFileSync(requests, JSON.stringify([{ path: '/x' }])); + const cli = join(import.meta.dirname, 'parity.mjs'); + for (const args of [ + ['--reference', 'ftp://x', '--candidate', 'http://127.0.0.1:1', '--requests', requests], + ['--reference', 'not a url', '--candidate', 'http://127.0.0.1:1', '--requests', requests], + [ + '--reference', + 'http://127.0.0.1:1', + '--candidate', + 'http://127.0.0.1:1', + '--requests', + requests, + '--ignore-header', + ], + ]) + expect([args.join(' '), spawnSync(process.execPath, [cli, ...args]).status]).toEqual([ + args.join(' '), + 3, + ]); + } finally { + rmSync(dir, { recursive: true }); + } + }); });