diff --git a/specs/013-server-types/tasks.md b/specs/013-server-types/tasks.md index c9f042db..78be9506 100644 --- a/specs/013-server-types/tasks.md +++ b/specs/013-server-types/tasks.md @@ -73,7 +73,7 @@ Each task: rename to `.ts`, update imports, file names that name it (R3 list), f - [ ] T023 The tools beside the services that they do not import (`tools/java-parity`, `tools/library-update`, `tools/svg-export-harness`, the check specs): converted the same way, or recorded as left and why. - Measured 23 Oct with `checkJs` on: 1,229 errors in 47 `.mjs` files, 70 of them in the five tools and 1,159 in 42 specs. The tools are converted, typed, held at zero both counts, and still run as before (parity and the library guard directly under Node, the harness through its new `playwright.config.ts`, which also mends the README's command: it had found no tests since the harness stopped being called a spec) — #643. The specs go next, in batches: the ten check specs outside the content service (load, started, workflows, check-move, check-baseline-totals, the HAR, flaky and lint checks, and content-node's hermetic), 60 errors — #644. The content service's ten smallest (exporter, cache-headers, molecule-details-bound, component-of, diseases, participating-entities, interactors, query-ids, map-results, mapping), 139 — #645. The next eight (pathways-low, complexes, overview-export, schema-objects, orthology, query-attribute-endpoint, interactors-static, referrers), 183 — #647. Seven more (contained-events, pathways-of, flag, query-attribute, discover, diagram-search, references-mapping), 235 — #649. + Measured 23 Oct with `checkJs` on: 1,229 errors in 47 `.mjs` files, 70 of them in the five tools and 1,159 in 42 specs. The tools are converted, typed, held at zero both counts, and still run as before (parity and the library guard directly under Node, the harness through its new `playwright.config.ts`, which also mends the README's command: it had found no tests since the harness stopped being called a spec) — #643. The specs go next, in batches: the ten check specs outside the content service (load, started, workflows, check-move, check-baseline-totals, the HAR, flaky and lint checks, and content-node's hermetic), 60 errors — #644. The content service's ten smallest (exporter, cache-headers, molecule-details-bound, component-of, diseases, participating-entities, interactors, query-ids, map-results, mapping), 139 — #645. The next eight (pathways-low, complexes, overview-export, schema-objects, orthology, query-attribute-endpoint, interactors-static, referrers), 183 — #647. Seven more (contained-events, pathways-of, flag, query-attribute, discover, diagram-search, references-mapping), 235 — #649. icon, facet-query and the Java parity spec, 151 — #650. - [ ] T024 `CLAUDE.md`: the rule for new service code (typed, and a load test entry for a new entry point) if one emerges. diff --git a/tools/content-node/facet-query.spec.mjs b/tools/content-node/facet-query.spec.ts similarity index 78% rename from tools/content-node/facet-query.spec.mjs rename to tools/content-node/facet-query.spec.ts index b7e55322..28707805 100644 --- a/tools/content-node/facet-query.spec.mjs +++ b/tools/content-node/facet-query.spec.ts @@ -4,11 +4,25 @@ // what search-core asks Solr's /facet handler, and its filter correction. // Java's answers measured on 16 Oct 2026. import { URLSearchParams } from 'node:url'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; import { describe, expect, it, beforeAll, afterAll } from 'vitest'; import { app } from './server.ts'; import { facetQueryParams } from './search-endpoints.ts'; import { springList, springScope } from './spring.ts'; +/** A form body as the service sent it to Solr: name and value, in order. */ +type Sent = [string, string][]; +/** The facet query facetQueryParams takes. */ +type FacetQuery = Parameters[0]; +/** + * A facet query with some lists left out. The endpoint always fills them, and + * facetQueryParams reads an absent list as an empty one, which these cases + * rely on. + */ +const paramsFor = (given: Pick & Partial) => + facetQueryParams(given as FacetQuery); + describe('springList, a List parameter as Spring binds it', () => { it('splits one value at commas and trims each part', () => { expect(springList([' Homo sapiens ,Mus musculus'])).toEqual(['Homo sapiens', 'Mus musculus']); @@ -23,7 +37,7 @@ describe('springList, a List parameter as Spring binds it', () => { }); describe('springScope, the Query.Scope parameter with its default', () => { - const message = (type, value) => + const message = (type: string, value: string) => `Failed to convert value of type '${type}' to required type 'org.reactome.server.search.domain.Query$Scope'; ` + 'nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type ' + '[java.lang.String] to type [@io.swagger.v3.oas.annotations.Parameter @org.springframework.web.bind.annotation.RequestParam ' + @@ -59,7 +73,7 @@ describe('springScope, the Query.Scope parameter with its default', () => { describe('facetQueryParams, as SolrCore.getFacetingInformation(Query) asks', () => { it('scopes, then filters by species, types, keywords and compartments, then not deleted', () => { expect( - facetQueryParams({ + paramsFor({ query: 'TP53', scope: 'PHYSICAL_ENTITY', species: ['Homo sapiens', 'Mus musculus'], @@ -78,71 +92,71 @@ describe('facetQueryParams, as SolrCore.getFacetingInformation(Query) asks', () ]); }); it('REFERENCE_ENTITY and BOTH scopes, and no filter for an empty list', () => { - expect(facetQueryParams({ query: 'x', scope: 'REFERENCE_ENTITY', species: [] })).toEqual([ + expect(paramsFor({ query: 'x', scope: 'REFERENCE_ENTITY', species: [] })).toEqual([ ['fq', 'hasReferenceEntity:false'], ['fq', '-deleted'], ['q', 'x'], ]); - expect(facetQueryParams({ query: 'x', scope: 'BOTH' })).toEqual([ + expect(paramsFor({ query: 'x', scope: 'BOTH' })).toEqual([ ['fq', '-deleted'], ['q', 'x'], ]); }); it('drops empty names from a list, leaving a bare tag when nothing is left', () => { const species = ['', 'Homo sapiens']; - expect(facetQueryParams({ query: 'x', scope: 'BOTH', species })[0]).toEqual([ + expect(paramsFor({ query: 'x', scope: 'BOTH', species })[0]).toEqual([ 'fq', '{!tag=sf}species_facet:("Homo sapiens")', ]); - expect(facetQueryParams({ query: 'x', scope: 'BOTH', types: [''] })[0]).toEqual([ - 'fq', - '{!tag=tf}', - ]); + expect(paramsFor({ query: 'x', scope: 'BOTH', types: [''] })[0]).toEqual(['fq', '{!tag=tf}']); }); }); describe('the facet_query endpoint, against a stand-in Solr', () => { - let base; - let server; - let solrServer; - let asked; - let answer; - const saved = {}; + let base: string; + let server: Server; + let solrServer: Server; + /** What the stand-in Solr was asked: the path, and the form it was sent. */ + let asked: [string, Sent][]; + /** The stand-in Solr's answer to a request. */ + let answer: (sent: Sent) => unknown; + const saved: Record = {}; const names = ['SOLR_URL', 'SOLR_USER', 'SOLR_PASSWORD']; beforeAll(async () => { const { createServer } = await import('node:http'); solrServer = createServer((request, response) => { let body = ''; - request.on('data', (chunk) => (body += chunk)); + request.on('data', (chunk: Buffer) => (body += chunk.toString())); request.on('end', () => { const sent = [...new URLSearchParams(body)]; - asked.push([request.url, sent]); + asked.push([request.url ?? '', sent]); response.setHeader('Content-Type', 'application/json'); response.end(JSON.stringify(answer(sent))); }); }); - await new Promise((resolve) => solrServer.listen(0, '127.0.0.1', resolve)); + await new Promise((resolve) => solrServer.listen(0, '127.0.0.1', resolve)); for (const name of names) saved[name] = process.env[name]; - process.env.SOLR_URL = `http://127.0.0.1:${solrServer.address().port}/solr/reactome`; - process.env.SOLR_USER = 'reader'; - process.env.SOLR_PASSWORD = 'not-a-secret'; - await new Promise((resolve) => { - server = app().listen(0, '127.0.0.1', resolve); + process.env['SOLR_URL'] = + `http://127.0.0.1:${(solrServer.address() as AddressInfo).port}/solr/reactome`; + process.env['SOLR_USER'] = 'reader'; + process.env['SOLR_PASSWORD'] = 'not-a-secret'; + await new Promise((resolve) => { + server = app().listen(0, '127.0.0.1', () => resolve()); }); - base = `http://127.0.0.1:${server.address().port}`; + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; }); afterAll(async () => { for (const name of names) if (saved[name] === undefined) delete process.env[name]; else process.env[name] = saved[name]; - await new Promise((resolve) => server.close(resolve)); - await new Promise((resolve) => solrServer.close(resolve)); + await new Promise((resolve) => server.close(() => resolve())); + await new Promise((resolve) => solrServer.close(() => resolve())); }); // Solr's counts for TP53, filtered by species when a species fq is sent. - const counts = (sent) => { + const counts = (sent: Sent) => { const filtered = sent.some( ([name, value]) => name === 'fq' && value.includes('species_facet:') ); @@ -182,7 +196,7 @@ describe('the facet_query endpoint, against a stand-in Solr', () => { const corrected = await fetch( `${base}/ContentService/search/facet_query?query=TP53&species=Nonexistent` ); - expect((await corrected.json()).totalNumFount).toBe(3428); + expect(((await corrected.json()) as { totalNumFount: number }).totalNumFount).toBe(3428); expect(asked.map(([, sent]) => sent.filter(([name]) => name === 'fq'))).toEqual([ [ ['fq', '-isReferenceSummary:true'], @@ -214,11 +228,11 @@ describe('the facet_query endpoint, against a stand-in Solr', () => { const force = await fetch( `${base}/ContentService/search/facet_query?query=x&Force%20filters=maybe` ); - expect((await force.json()).messages).toEqual([ + expect(((await force.json()) as { messages: string[] }).messages).toEqual([ "Failed to convert value of type 'java.lang.String' to required type 'java.lang.Boolean'; nested exception is java.lang.IllegalArgumentException: Invalid boolean value [maybe]", ]); const blank = await fetch(`${base}/ContentService/search/facet_query?query=x&scope=%20`); - expect([blank.status, (await blank.json()).messages]).toEqual([ + expect([blank.status, ((await blank.json()) as { messages: string[] }).messages]).toEqual([ 500, ['Something unexpected happened and the error has been reported.'], ]); @@ -231,11 +245,11 @@ describe('the facet_query endpoint, against a stand-in Solr', () => { 'query=TP53&Force%20filters=&Force%20filters=true', ]) { const response = await fetch(`${base}/ContentService/search/facet_query?${forced}`); - expect([forced, response.status, (await response.json()).messages]).toEqual([ + expect([ forced, - 500, - unexpected, - ]); + response.status, + ((await response.json()) as { messages: string[] }).messages, + ]).toEqual([forced, 500, unexpected]); } const emptyBlank = await fetch(`${base}/ContentService/search/facet_query?query=&scope=%20`); expect([emptyBlank.status, await emptyBlank.text()]).toEqual([200, '']); @@ -273,9 +287,8 @@ describe('the facet_query endpoint, against a stand-in Solr', () => { `${base}/ContentService/search/facet_query?query=TP53&species=Homo%20sapiens` ); // Java's NullPointerException, not a Solr failure. - expect([response.status, (await response.json()).messages]).toEqual([ - 500, - ['Something unexpected happened and the error has been reported.'], - ]); + expect([response.status, ((await response.json()) as { messages: string[] }).messages]).toEqual( + [500, ['Something unexpected happened and the error has been reported.']] + ); }); }); diff --git a/tools/content-node/icon.spec.mjs b/tools/content-node/icon.spec.ts similarity index 75% rename from tools/content-node/icon.spec.mjs rename to tools/content-node/icon.spec.ts index c8403d00..a9770c4e 100644 --- a/tools/content-node/icon.spec.mjs +++ b/tools/content-node/icon.spec.ts @@ -4,6 +4,8 @@ // Entry search-core builds from a Solr document, what is asked of Solr, and // Spring's reading of the parameters. Java's answers measured 16 Oct 2026. import { URLSearchParams } from 'node:url'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; import { describe, expect, it, beforeAll, afterAll } from 'vitest'; import { app } from './server.ts'; import { searchEntry } from './search-core.ts'; @@ -55,11 +57,11 @@ describe('searchEntry, as SolrConverter.buildEntry builds an Entry', () => { 'icon', ]); expect(entry.icon).toBe(true); - expect(entry.hasEHLD).toBe(false); + expect(entry['hasEHLD']).toBe(false); }); it("orders the icon's entities as a TreeSet by display name, ignoring case, dropping equals", () => { - expect(searchEntry(icon, {}).iconPhysicalEntities).toEqual([ + expect(searchEntry(icon, {})['iconPhysicalEntities']).toEqual([ { stId: 'uniprot:Q1', type: 'ReferenceGeneProduct', @@ -92,8 +94,8 @@ describe('searchEntry, as SolrConverter.buildEntry builds an Entry', () => { }); it('id is the stId when there is one, else the dbId', () => { - expect(searchEntry({ dbId: '5' }, {}).id).toBe('5'); - expect(searchEntry({ dbId: '5', stId: 'R-ICO-5' }, {}).id).toBe('R-ICO-5'); + expect(searchEntry({ dbId: '5' }, {})['id']).toBe('5'); + expect(searchEntry({ dbId: '5', stId: 'R-ICO-5' }, {})['id']).toBe('R-ICO-5'); }); it('is an icon whenever it has an icon name, even an empty one NON_EMPTY leaves out', () => { @@ -106,13 +108,13 @@ describe('searchEntry, as SolrConverter.buildEntry builds an Entry', () => { it("leaves out an icon entity's empty parts, as NON_EMPTY does in nested beans", () => { expect( - searchEntry({ dbId: '1', iconPhysicalEntities: ['##n#c'] }, {}).iconPhysicalEntities + searchEntry({ dbId: '1', iconPhysicalEntities: ['##n#c'] }, {})['iconPhysicalEntities'] ).toEqual([{ name: 'n', compartments: 'c', displayName: 'n [c]' }]); }); it('reads replacement dbIds as Long.parseLong does, any script, written exactly', () => { - const ids = (values) => - javaJson(searchEntry({ dbId: '1', replacementDbIds: values }, {}).replacementDbIds); + const ids = (values: string[]) => + javaJson(searchEntry({ dbId: '1', replacementDbIds: values }, {})['replacementDbIds']); expect(ids(['+7', '-8', '٣٤', '1', '9007199254740993'])).toBe('[7,-8,34,1,9007199254740993]'); }); @@ -124,10 +126,9 @@ describe('searchEntry, as SolrConverter.buildEntry builds an Entry', () => { }); it('orders by display name even when one is empty, and leaves it out', () => { - const entities = searchEntry( - { dbId: '1', iconPhysicalEntities: ['s#t## ', 's2#t#n#c'] }, - {} - ).iconPhysicalEntities; + const entities = searchEntry({ dbId: '1', iconPhysicalEntities: ['s#t## ', 's2#t#n#c'] }, {})[ + 'iconPhysicalEntities' + ]; expect(entities).toEqual([ { stId: 's', type: 't', compartments: ' ' }, { stId: 's2', type: 't', name: 'n', compartments: 'c', displayName: 'n [c]' }, @@ -136,8 +137,8 @@ describe('searchEntry, as SolrConverter.buildEntry builds an Entry', () => { it('without highlighting, the summation is the summation even when absent', () => { const doc = { dbId: '1', name: 'n', inferredSummation: 'inferred' }; - expect(searchEntry(doc, {}).summation).toBe(undefined); - expect(searchEntry({ ...doc, summation: 's' }, {}).summation).toBe('s'); + expect(searchEntry(doc, {})['summation']).toBe(undefined); + expect(searchEntry({ ...doc, summation: 's' }, {})['summation']).toBe('s'); }); it('highlighted, only snippets set the name; the inferred summation stays', () => { @@ -159,8 +160,8 @@ describe('searchEntry, as SolrConverter.buildEntry builds an Entry', () => { referenceIdentifiers: ['x', 'y highlighting'], }, }); - expect(hit.name).toBe('plain'); - expect(hit.referenceIdentifier).toBe('y highlighting'); + expect(hit['name']).toBe('plain'); + expect(hit['referenceIdentifier']).toBe('y highlighting'); }); it('writes isDisease where declared and disease after the fields; a deleted entry with its date', () => { @@ -183,53 +184,65 @@ describe('searchEntry, as SolrConverter.buildEntry builds an Entry', () => { 'replacementDbIds', 'disease', ]); - expect(entry.date).toBe(Date.UTC(2020, 0, 2)); - expect(javaJson(entry.replacementDbIds)).toBe('[7]'); + expect(entry['date']).toBe(Date.UTC(2020, 0, 2)); + expect(javaJson(entry['replacementDbIds'])).toBe('[7]'); }); }); describe('the icon endpoints, against a stand-in Solr', () => { - let base; - let server; - let solrServer; - let asked; - let answer; - const saved = {}; + /** A form body as the service sent it to Solr: name and value, in order. */ + type Sent = [string, string][]; + let base: string; + let server: Server; + let solrServer: Server; + /** What the stand-in Solr was asked: the path, and the form it was sent. */ + let asked: [string, Sent][]; + /** The stand-in Solr's answer to a request. */ + let answer: (sent: Sent) => unknown; + const saved: Record = {}; const names = ['SOLR_URL', 'SOLR_USER', 'SOLR_PASSWORD']; beforeAll(async () => { const { createServer } = await import('node:http'); solrServer = createServer((request, response) => { let body = ''; - request.on('data', (chunk) => (body += chunk)); + request.on('data', (chunk: Buffer) => (body += chunk.toString())); request.on('end', () => { const sent = [...new URLSearchParams(body)]; - asked.push([request.url, sent]); + asked.push([request.url ?? '', sent]); response.setHeader('Content-Type', 'application/json'); response.end(JSON.stringify(answer(sent))); }); }); - await new Promise((resolve) => solrServer.listen(0, '127.0.0.1', resolve)); + await new Promise((resolve) => solrServer.listen(0, '127.0.0.1', resolve)); for (const name of names) saved[name] = process.env[name]; - process.env.SOLR_URL = `http://127.0.0.1:${solrServer.address().port}/solr/reactome`; - process.env.SOLR_USER = 'reader'; - process.env.SOLR_PASSWORD = 'not-a-secret'; - await new Promise((resolve) => { - server = app().listen(0, '127.0.0.1', resolve); + process.env['SOLR_URL'] = + `http://127.0.0.1:${(solrServer.address() as AddressInfo).port}/solr/reactome`; + process.env['SOLR_USER'] = 'reader'; + process.env['SOLR_PASSWORD'] = 'not-a-secret'; + await new Promise((resolve) => { + server = app().listen(0, '127.0.0.1', () => resolve()); }); - base = `http://127.0.0.1:${server.address().port}`; + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; }); afterAll(async () => { for (const name of names) if (saved[name] === undefined) delete process.env[name]; else process.env[name] = saved[name]; - await new Promise((resolve) => server.close(resolve)); - await new Promise((resolve) => solrServer.close(resolve)); + await new Promise((resolve) => server.close(() => resolve())); + await new Promise((resolve) => solrServer.close(() => resolve())); }); + /** The nth request Solr was asked; one not asked is the test's failure. */ + const request = (at: number) => { + const found = asked[at]; + if (!found) throw new Error(`Solr was not asked a request ${at}`); + return found; + }; + const found = - (docs, numFound = docs.length) => + (docs: object[], numFound = docs.length) => () => ({ response: { numFound, docs } }); it('/icon/{id} asks /search for that stId among icons, and writes the first Entry', async () => { @@ -241,9 +254,9 @@ describe('the icon endpoints, against a stand-in Solr', () => { }); const response = await fetch(`${base}/ContentService/search/icon/R-ICO-013428`); expect(response.status).toBe(200); - const entry = await response.json(); + const entry = (await response.json()) as Record; expect(entry).toEqual(searchEntry(icon, {})); - expect([entry.id, entry.name]).toEqual(['R-ICO-013428', 'TCR']); + expect([entry['id'], entry['name']]).toEqual(['R-ICO-013428', 'TCR']); expect(asked).toEqual([ [ '/solr/reactome/search', @@ -261,17 +274,18 @@ describe('the icon endpoints, against a stand-in Solr', () => { it("/icon/{id} found but sent no document is Solr's failure, never an empty 200 to cache", async () => { answer = found([], 1); const response = await fetch(`${base}/ContentService/search/icon/R-ICO-013428`); - expect([response.status, (await response.json()).messages]).toEqual([ - 500, - ['Solr exception occurred'], - ]); + expect([response.status, ((await response.json()) as { messages: string[] }).messages]).toEqual( + [500, ['Solr exception occurred']] + ); }); it("/icon/{id} found nowhere is Java's 404, naming the id as given", async () => { answer = found([]); const response = await fetch(`${base}/ContentService/search/icon/R-ICO-013428.json`); expect(response.status).toBe(404); - expect((await response.json()).messages).toEqual(['Icon not found: R-ICO-013428.json']); + expect(((await response.json()) as { messages: string[] }).messages).toEqual([ + 'Icon not found: R-ICO-013428.json', + ]); }); it('/icon/query pages by name, among icons, with the category before the text', async () => { @@ -310,7 +324,7 @@ describe('the icon endpoints, against a stand-in Solr', () => { answer = found([], 0); const response = await fetch(`${base}/ContentService/search/icon/query`); expect(await response.text()).toBe('{"typeName":"Results","entriesCount":0,"rowCount":0}'); - expect(asked[0][1].filter(([name]) => ['start', 'rows', 'q'].includes(name))).toEqual([ + expect(request(0)[1].filter(([name]) => ['start', 'rows', 'q'].includes(name))).toEqual([ ['start', '0'], ['rows', '28'], ['q', '*'], @@ -323,13 +337,14 @@ describe('the icon endpoints, against a stand-in Solr', () => { highlighting: { '013428': { name: ['TCR'] } }, }); const response = await fetch(`${base}/ContentService/search/icon/query?query=TCR`); - expect((await response.json()).entries[0].name).toBe('TCR'); + const { entries } = (await response.json()) as { entries: { name: string }[] }; + expect(entries.map((entry) => entry.name)[0]).toBe('TCR'); }); it("answers Spring's 400, Java's 500s for blank numbers, and pages in int arithmetic", async () => { asked = []; const bad = await fetch(`${base}/ContentService/search/icon/query?pageSize=abc`); - expect([bad.status, (await bad.json()).messages]).toEqual([ + expect([bad.status, ((await bad.json()) as { messages: string[] }).messages]).toEqual([ 400, [ `Failed to convert value of type 'java.lang.String' to required type 'java.lang.Integer'; nested exception is java.lang.NumberFormatException: For input string: "abc"`, @@ -337,16 +352,16 @@ describe('the icon endpoints, against a stand-in Solr', () => { ]); for (const blank of ['pageSize=%20', 'page=%20']) { const response = await fetch(`${base}/ContentService/search/icon/query?${blank}`); - expect([blank, response.status, (await response.json()).messages]).toEqual([ + expect([ blank, - 500, - ['Something unexpected happened and the error has been reported.'], - ]); + response.status, + ((await response.json()) as { messages: string[] }).messages, + ]).toEqual([blank, 500, ['Something unexpected happened and the error has been reported.']]); } expect(asked).toHaveLength(0); answer = found([], 0); await fetch(`${base}/ContentService/search/icon/query?page=2147483647&pageSize=2`); - expect(asked[0][1].find(([name]) => name === 'start')).toEqual(['start', '-4']); + expect(request(0)[1].find(([name]) => name === 'start')).toEqual(['start', '-4']); }); it('routes a literal segment sent percent-encoded as Java does: decoded first', async () => { @@ -354,14 +369,14 @@ describe('the icon endpoints, against a stand-in Solr', () => { answer = found([], 0); const query = await fetch(`${base}/ContentService/search/icon/qu%65ry?pageSize=5`); expect(query.status).toBe(200); - expect(asked[0][1].find(([name]) => name === 'rows')).toEqual(['rows', '5']); + expect(request(0)[1].find(([name]) => name === 'rows')).toEqual(['rows', '5']); asked = []; answer = () => ({ response: { numFound: 0 }, facet_counts: { facet_fields: { iconCategories_facet: [] } }, }); await fetch(`${base}/ContentService/search/icon/f%61cet`); - expect(asked[0][0]).toBe('/solr/reactome/icon/facet'); + expect(request(0)[0]).toBe('/solr/reactome/icon/facet'); }); it('routes by case, as Spring does: a capital, sent plain or escaped, is an id', async () => { @@ -371,13 +386,15 @@ describe('the icon endpoints, against a stand-in Solr', () => { expect([path, response.status]).toEqual([path, 404]); } const escaped = await fetch(`${base}/ContentService/search/icon/%51uery`); - expect((await escaped.json()).messages).toEqual(['Icon not found: Query']); + expect(((await escaped.json()) as { messages: string[] }).messages).toEqual([ + 'Icon not found: Query', + ]); }); it("answers a path no mapping matches with Spring's JSON 404", async () => { const response = await fetch(`${base}/ContentService/data/database/VERSION`); expect(response.status).toBe(404); - const body = await response.json(); + const body = (await response.json()) as { status: number; error: string; path: string }; expect([body.status, body.error, body.path]).toEqual([ 404, 'Not Found', diff --git a/tools/java-parity/parity.spec.mjs b/tools/java-parity/parity.spec.ts similarity index 78% rename from tools/java-parity/parity.spec.mjs rename to tools/java-parity/parity.spec.ts index b306c35e..ae761a9d 100644 --- a/tools/java-parity/parity.spec.mjs +++ b/tools/java-parity/parity.spec.ts @@ -3,7 +3,8 @@ // 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 { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; import { setInterval, clearInterval } from 'node:timers'; import { spawnSync } from 'node:child_process'; import { writeFileSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'; @@ -11,18 +12,33 @@ 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.ts'; +import { + compareAll, + verdict, + type ParityOptions, + type ParityRequest, + type Report, +} from './parity.ts'; import { normalisers } from './normalisers.ts'; +/** One canned answer. */ +type Answer = { status?: number; headers?: Record; body: string | Buffer }; +/** Path to answer, and which side the table is (the candidate's is named). */ +type Table = { name?: string; answers: Record }; +/** A started stub, and its base URL. */ +type Stub = { server: Server; url: string }; + /** A server answering from a table of path -> { status, headers, body }. */ -function stub(table) { +function stub({ name, answers }: Table) { let calls = 0; - const server = createServer((request, response) => { + // Each early return ends the answer (or deliberately leaves it hanging); none + // returns a value anyone reads. + const server = createServer((request, response): void => { calls += 1; // Live third-party data: a different answer every time. - if (request.url === '/live') return response.end(`live ${calls}`); + if (request.url === '/live') return void 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}`); + if (request.url === '/host') return void 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. @@ -32,34 +48,35 @@ function stub(table) { return; } // Two Content-Types, which a parsed header map shows as one. - if (request.url === '/twice' && !table.name) { + if (request.url === '/twice' && !name) { response.setHeader('content-type', ['text/plain', 'text/html']); - return response.end('x'); + return void 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); + setTimeout(() => request.socket.destroy(), 20); + return; } // 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'); + if (request.url === '/badgz' && name) + return void 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' }; + if (request.url === '/ctx/x') return void response.end(`ctx ${name ?? ''}`); + const answer = answers[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) => + return new Promise((resolve) => server.listen(0, '127.0.0.1', () => - resolve({ server, url: `http://127.0.0.1:${server.address().port}` }) + resolve({ server, url: `http://127.0.0.1:${(server.address() as AddressInfo).port}` }) ) ); } -const json = { 'content-type': 'application/json;charset=UTF-8' }; -const reference = { +const json: Record = { 'content-type': 'application/json;charset=UTF-8' }; +const reference: Record = { '/same': { body: 'abc' }, '/byte': { body: 'abc' }, '/status': { status: 200, body: 'x' }, @@ -81,9 +98,10 @@ const reference = { '/badgz': { headers: { 'content-type': 'text/plain' }, body: 'ok' }, '/secret/ABC123': { body: 'x' }, }; -const candidate = { +/** The candidate's table is named; the reference's is not. */ +const candidateName = 'candidate'; +const candidate: Record = { ...reference, - name: 'candidate', '/twice': { headers: { 'content-type': 'text/plain' }, body: 'x' }, '/byte': { body: 'abd' }, '/status': { status: 500, body: 'x' }, @@ -103,25 +121,32 @@ const candidate = { }, }; -let ref; -let cand; +let ref: Stub; +let cand: Stub; beforeAll(async () => { - ref = await stub(reference); - cand = await stub(candidate); + ref = await stub({ answers: reference }); + cand = await stub({ name: candidateName, answers: 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)); + await new Promise((resolve) => ref.server.close(() => resolve())); + await new Promise((resolve) => cand.server.close(() => resolve())); }); -const run = (paths, options = {}) => +const run = (paths: (string | ParityRequest)[], options: Partial = {}) => compareAll( paths.map((path) => (typeof path === 'string' ? { method: 'GET', path } : path)), { reference: ref.url, candidate: cand.url, ...options } ); +/** A report's first result; none is the test's failure, not undefined. */ +const first = (report: Report) => { + const [result] = report.results; + if (!result) throw new Error('the report has no result'); + return result; +}; + describe('the parity harness', () => { it('finds identical answers identical', async () => { const report = await run(['/same']); @@ -159,7 +184,7 @@ describe('the parity harness', () => { 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(); + const strip = (text: string) => normalisers.analysisResult(Buffer.from(text)).toString(); expect(strip('{"summary":{"token":"T1"},"n":9007199254740993}')).toBe( '{"summary":{"token":""},"n":9007199254740993}' ); @@ -185,8 +210,8 @@ describe('the parity harness', () => { it('never puts a body, and with redaction no path, in its report', async () => { const report = await run(['/secret/ABC123'], { redact: true }); 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']); + expect(first(report).request).toBe('#0'); + expect(Object.keys(first(report)).sort()).toEqual(['differs', 'request', 'status']); }); it('calibrates against the reference itself: a difference counts only where the reference agreed with itself', async () => { @@ -197,7 +222,7 @@ describe('the parity harness', () => { // 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(first(live).calibrated).toBe(false); expect(verdict(live)).toBe(2); }); @@ -222,27 +247,27 @@ describe('the parity harness', () => { 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(first(report).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'); + expect(first(report).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'); + expect(first(report).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'); + expect(first(report).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'); + expect(first(report).differs).toContain('status'); }); it('ignores a header only when the run declares it, and says so', async () => { @@ -258,7 +283,7 @@ describe('the parity harness', () => { const before = timers(); const started = Date.now(); const report = await run(['/reset'], { timeout: 60_000 }); - expect(report.results[0].error).toMatch(/^network/); + expect(first(report).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); @@ -266,7 +291,7 @@ describe('the parity harness', () => { 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(first(report).differs).toEqual(expect.arrayContaining(['status', 'body'])); expect(verdict(report)).toBe(1); });