diff --git a/package-lock.json b/package-lock.json index e6c3667..21be272 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "testcontainers": "12.2.1" }, "devDependencies": { - "@biomejs/biome": "2.5.15", + "@biomejs/biome": "2.6.2", "@types/node": "26.6.5", "get-port": "7.2.0", "tsx": "4.23.15", @@ -26,9 +26,9 @@ "license": "Apache-2.0" }, "node_modules/@biomejs/biome": { - "version": "2.5.15", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.15.tgz", - "integrity": "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ==", + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.6.2.tgz", + "integrity": "sha512-JLTFAFF9vfJcWwLhwuL4ZHflLg+XfTEC4IUnAlJRX1cMRt57++Xd1Ysb9Fdmlh9YUrvyCXfPEHjjEIqKJGdPKw==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -42,20 +42,56 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.5.15", - "@biomejs/cli-darwin-x64": "2.5.15", - "@biomejs/cli-linux-arm64": "2.5.15", - "@biomejs/cli-linux-arm64-musl": "2.5.15", - "@biomejs/cli-linux-x64": "2.5.15", - "@biomejs/cli-linux-x64-musl": "2.5.15", - "@biomejs/cli-win32-arm64": "2.5.15", - "@biomejs/cli-win32-x64": "2.5.15" + "@biomejs/cli-android-arm64": "2.6.2", + "@biomejs/cli-android-x64": "2.6.2", + "@biomejs/cli-darwin-arm64": "2.6.2", + "@biomejs/cli-darwin-x64": "2.6.2", + "@biomejs/cli-linux-arm64": "2.6.2", + "@biomejs/cli-linux-arm64-musl": "2.6.2", + "@biomejs/cli-linux-x64": "2.6.2", + "@biomejs/cli-linux-x64-musl": "2.6.2", + "@biomejs/cli-win32-arm64": "2.6.2", + "@biomejs/cli-win32-x64": "2.6.2" + } + }, + "node_modules/@biomejs/cli-android-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-android-arm64/-/cli-android-arm64-2.6.2.tgz", + "integrity": "sha512-dMpdpH7zFEL5FxuIA9LJW+RoNyC3oeDB7PJyjqVVwtMNoKB5uo8OZYmrVSBikn71txzYwy/3xga7UME6bJQZ6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-android-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-android-x64/-/cli-android-x64-2.6.2.tgz", + "integrity": "sha512-XCwEhPBGYzuRA502dRkCcQ+5cefQwuxQR7f9wa4do92dvIcuBL6VMLMFB/fL9hlpZ4dGSfHdpGFSIDlp52mg0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.21.3" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.5.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.15.tgz", - "integrity": "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg==", + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.6.2.tgz", + "integrity": "sha512-rpXtabRgyZNz3aCjd/cRoSZGYi92R9eAXdUUe1sigIsRDyfMwgThaa98Yxt1fiZ7mxquqkAyIHIo08N/e1ToBA==", "cpu": [ "arm64" ], @@ -70,9 +106,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.5.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.15.tgz", - "integrity": "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw==", + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.6.2.tgz", + "integrity": "sha512-7GGzlm1GxUKoQ9CNV8tQZ+XXad4sz+v+uzNsWlhaGjsnZqUOVBw1OmAMpG3uNV/Mr0UrCpRJvSK0qvfuqSQOjg==", "cpu": [ "x64" ], @@ -87,9 +123,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.5.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.15.tgz", - "integrity": "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw==", + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.6.2.tgz", + "integrity": "sha512-Qs4CofCE8MdkjZFfEjswh2NAcbyQ7IVYtGSzQBvLqFlla/CSXroGbktUFndWG/kVSTywadauzAspYWtSfqAc2g==", "cpu": [ "arm64" ], @@ -107,9 +143,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.5.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.15.tgz", - "integrity": "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw==", + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-I+FYCLVSmR3cYfKcQ+KNeLOZ5w0wRd9fQFlw3JbSvy7bkegvTqdHaBRRcJzHQB/1ldldTLIcG21ka6X2cTWmJw==", "cpu": [ "arm64" ], @@ -127,9 +163,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.5.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.15.tgz", - "integrity": "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA==", + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.6.2.tgz", + "integrity": "sha512-FJKsb95418139xDdbv9XnNJsIIwvsgR21Of0aE9B9U+nQlOtRq5gJl2ps0jZUmYBxTaOlP9BKo8N+Ff8+Rui1g==", "cpu": [ "x64" ], @@ -147,9 +183,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.5.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.15.tgz", - "integrity": "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ==", + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-wnjEXAK2pDd2j8r9gKI3L+44sIxjkYzS5aHmD1+K57RNJ3sFyT4n22NH6QFPqqeVdRBxN54H3kckUUCrFKCcJw==", "cpu": [ "x64" ], @@ -167,9 +203,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.5.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.15.tgz", - "integrity": "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg==", + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.6.2.tgz", + "integrity": "sha512-/PEOo6evlgrQOfKhMNtC/4Gk8ai3hySXXEtjUzCqZe/4iAbMdqelpp7qvZEDyLxlZS+BjOMQFpq2gXI/BPaAqw==", "cpu": [ "arm64" ], @@ -184,9 +220,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.5.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.15.tgz", - "integrity": "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g==", + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.6.2.tgz", + "integrity": "sha512-FVlWD+mDDz8nN5LWKRWUhhb7wKIK0VE3oXQvQBX7Fwwj00CvqMVhMyiz1iz8O5aZGmnkhFlKSwJPrUvMh5prQw==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index dfdbc73..5277b6d 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "testcontainers": "12.2.1" }, "devDependencies": { - "@biomejs/biome": "2.5.15", + "@biomejs/biome": "2.6.2", "@types/node": "26.6.5", "get-port": "7.2.0", "tsx": "4.23.15", diff --git a/src/Event.test.ts b/src/Event.test.ts index b2f0175..efbb2c4 100644 --- a/src/Event.test.ts +++ b/src/Event.test.ts @@ -164,6 +164,7 @@ suite('Event', () => { test( 'throws an error if the hash verification fails.', containerStartTimeout, + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. async (): Promise => { await container.stop(); const imageTag = getImageTag(); @@ -203,6 +204,7 @@ suite('Event', () => { test( 'throws an error if the signature verification fails.', containerStartTimeout, + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. async (): Promise => { await container.stop(); const imageTag = getImageTag(); @@ -238,6 +240,7 @@ suite('Event', () => { }, ); + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. test('verifies the signature.', containerStartTimeout, async (): Promise => { await container.stop(); const imageTag = getImageTag(); @@ -273,6 +276,7 @@ suite('Event', () => { test( 'verifies the signature of an event as written, read, and observed, whatever its data and trace context.', containerStartTimeout, + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. async (): Promise => { await container.stop(); const imageTag = getImageTag(); diff --git a/src/Event.ts b/src/Event.ts index 50101ab..3e7a515 100644 --- a/src/Event.ts +++ b/src/Event.ts @@ -13,9 +13,20 @@ const hashVersionRegex = /^esdb:hash:(?[^:]*):/u; // have like this. const missingField = '-'; +// A signature starts with this prefix, followed by its bytes in hex. +const signaturePrefix = 'esdb:signature:v1:'; + const getSha256 = (input: string): string => crypto.createHash('sha256').update(input).digest('hex'); +const getSignatureBytes = (signature: string): Buffer => { + if (!signature.startsWith(signaturePrefix)) { + throw new Error(`Signature must start with '${signaturePrefix}'`); + } + + return Buffer.from(signature.slice(signaturePrefix.length), 'hex'); +}; + class Event { public specversion: string; public id: string; @@ -33,6 +44,7 @@ class Event { public tracestate?: string; public signature: string | null; + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The constructor assigns each field of the event, one statement per field. public constructor({ specversion, id, @@ -145,14 +157,7 @@ class Event { this.verifyHash(); - const signaturePrefix = 'esdb:signature:v1:'; - - if (!this.signature.startsWith(signaturePrefix)) { - throw new Error(`Signature must start with '${signaturePrefix}'`); - } - - const signature = this.signature.slice(signaturePrefix.length); - const signatureBytes = Buffer.from(signature, 'hex'); + const signatureBytes = getSignatureBytes(this.signature); const hashBytes = Buffer.from(this.hash, 'utf8'); const isSignatureValid = crypto.verify(null, hashBytes, verificationKey, signatureBytes); diff --git a/src/abortSignal.test.ts b/src/abortSignal.test.ts index 6483ef7..9e62a1e 100644 --- a/src/abortSignal.test.ts +++ b/src/abortSignal.test.ts @@ -137,6 +137,7 @@ suite('abortSignal', { timeout: 30_000 }, () => { defineTestsFor(request); } + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. test('throws an abort error if the caller aborts while the response body arrives.', async (): Promise => { server.respond = (_request: FakeRequest, response: ServerResponse): void => { writeHead(response, 200, 'application/json'); @@ -173,6 +174,7 @@ suite('abortSignal', { timeout: 30_000 }, () => { await server.connectionsClosed(); }); + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. test('throws an unknown outcome if the caller aborts a write while the response body arrives.', async (): Promise => { server.respond = (_request: FakeRequest, response: ServerResponse): void => { writeHead(response, 200, 'application/json'); diff --git a/src/container/isReachable.test.ts b/src/container/isReachable.test.ts index a58f6e7..37d4f18 100644 --- a/src/container/isReachable.test.ts +++ b/src/container/isReachable.test.ts @@ -13,6 +13,7 @@ interface Answer { delay?: number; } +// biome-ignore lint/nursery/noExcessiveStatementsPerFunction: A suite counts each of its tests as a statement. suite('isReachable', () => { let server: FakeServer; diff --git a/src/container/startContainer.test.ts b/src/container/startContainer.test.ts index a7277bb..d818913 100644 --- a/src/container/startContainer.test.ts +++ b/src/container/startContainer.test.ts @@ -47,6 +47,7 @@ const listContainers = async (testId: string): Promise => { // instead of asking for its events afterwards. Docker passes on its events in // order, so to stop, the test creates a container of its own and waits for its // event, after which the events of all containers created before have arrived. +// biome-ignore lint/nursery/noExcessiveStatementsPerFunction: Watching opens the event stream of Docker, collects the start labels from it, and returns the function that ends it, all of which share the stream. const watchCreatedContainers = async ( testId: string, image: string, @@ -98,6 +99,7 @@ const watchCreatedContainers = async ( }; }; +// biome-ignore lint/nursery/noExcessiveStatementsPerFunction: A suite counts each of its tests as a statement. suite('startContainer', () => { const image = `thenativeweb/eventsourcingdb:${getImageTag()}`; @@ -256,6 +258,7 @@ suite('startContainer', () => { test( 'replaces a running container whose port testcontainers cannot find, and removes it.', integrationTimeout, + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. async (): Promise => { const testId = randomUUID(); const endFailing = await failPortLookups(1); @@ -327,6 +330,7 @@ suite('startContainer', () => { test( 'does not count pulling the image towards the startup timeout.', integrationTimeout, + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. async (): Promise => { const testId = randomUUID(); const client = await getContainerRuntimeClient(); @@ -377,6 +381,7 @@ suite('startContainer', () => { test( 'removes a container that Docker creates only after the wait for it has ended, without waiting for it.', integrationTimeout, + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. async (): Promise => { const testId = randomUUID(); const client = await getContainerRuntimeClient(); @@ -439,6 +444,7 @@ suite('startContainer', () => { test( 'reports a failed removal after the error of the start, and goes on with the next attempt.', integrationTimeout, + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. async (): Promise => { const testId = randomUUID(); const client = await getContainerRuntimeClient(); diff --git a/src/container/startContainer.ts b/src/container/startContainer.ts index 7e93c52..383320c 100644 --- a/src/container/startContainer.ts +++ b/src/container/startContainer.ts @@ -32,6 +32,15 @@ interface StartContainerOptions { onBackgroundRemoval?: (removal: Promise) => void; } +// A failed attempt leaves behind the containers that carry its start label, +// and possibly a start and a wait for readiness that are still pending. +interface FailedAttempt { + client: ContainerRuntimeClient; + startId: string; + starting: Promise; + readiness: Promise; +} + const maximumAttempts = 3; const millisecondsPerSecond = 1000; const defaultSettleTimeout = 10_000; @@ -58,7 +67,7 @@ class ReadinessWait implements WaitStrategy { return this; } - // biome-ignore lint/nursery/useThisInClassMethods: The interface of testcontainers asks for this method. + // biome-ignore lint/style/useThisInClassMethods: The interface of testcontainers asks for this method. public isStartupTimeoutSet(): boolean { return true; } @@ -89,16 +98,50 @@ const waitForSettling = async (promises: Promise[], timeout: number): P return hasSettled; }; +// Removes what a failed attempt has left behind. It removes the container at +// once. If the time ran out while Docker was still creating or starting the +// container, it then waits for that to end, for a while at most, and removes +// what was created meanwhile. If that takes longer, it removes the container +// once Docker has created it, without waiting for that. A failed removal is +// collected. +const removeLeftoversOfAttempt = async ( + { client, startId, starting, readiness }: FailedAttempt, + options: StartContainerOptions, + removalErrors: unknown[], +): Promise => { + const removeContainers = async (): Promise => { + try { + await removeContainersOfAttempt(client, startId); + } catch (removalError) { + removalErrors.push(removalError); + } + }; + + await removeContainers(); + + // The attempt has failed already, so how the start ends no longer + // matters, only that nothing of it is left. + const hasSettled = await waitForSettling( + [starting, readiness], + options.settleTimeout ?? defaultSettleTimeout, + ); + await removeContainers(); + + if (!hasSettled) { + // The caller does not wait for a start that Docker does not finish, and + // the removal collects its own errors. + const removal = Promise.allSettled([starting]).then(removeContainers); + options.onBackgroundRemoval?.(removal); + } +}; + // Starts a container and waits until its database is reachable, for the // startup timeout at most, from creating the container on. If that fails, it -// removes the container at once. If the time ran out while Docker was still -// creating or starting the container, it then waits for that to end, for a -// while at most, and removes what was created meanwhile. If that takes longer, -// it removes the container once Docker has created it, without waiting for -// that. A failed removal does not change the outcome of the attempt, but is -// collected. It returns -// undefined if the database did not become reachable, so that another -// container can take its place. +// removes what the attempt has left behind. A failed removal does not change +// the outcome of the attempt, but is collected. It returns undefined if the +// database did not become reachable, so that another container can take its +// place. +// biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The attempt owns its deadline and its controllers, which starting, waiting, and telling a timeout from a failure all share, and releases them in one place. const attemptToStart = async ( client: ContainerRuntimeClient, options: StartContainerOptions, @@ -134,30 +177,11 @@ const attemptToStart = async ( return container; } catch (error) { attemptEnded.abort(); - const removeContainers = async (): Promise => { - try { - await removeContainersOfAttempt(client, startId); - } catch (removalError) { - removalErrors.push(removalError); - } - }; - - await removeContainers(); - - // The attempt has failed already, so how the start ends no longer - // matters, only that nothing of it is left. - const hasSettled = await waitForSettling( - [starting, readiness], - options.settleTimeout ?? defaultSettleTimeout, + await removeLeftoversOfAttempt( + { client, startId, starting, readiness }, + options, + removalErrors, ); - await removeContainers(); - - if (!hasSettled) { - // The caller does not wait for a start that Docker does not finish, and - // the removal collects its own errors. - const removal = Promise.allSettled([starting]).then(removeContainers); - options.onBackgroundRemoval?.(removal); - } if ((deadline.signal.aborted && error === deadline.signal.reason) || isPortError(error)) { return undefined; @@ -175,6 +199,7 @@ const attemptToStart = async ( // reachable within the startup timeout, gives way to another one, up to 3 // containers in total. If Docker fails, or if the container exits, starting // fails at once. +// biome-ignore lint/nursery/noExcessiveStatementsPerFunction: Starting prepares Docker, runs the attempts one after another, and turns every failure into one error that names the failed removals as well. const startContainer = async (options: StartContainerOptions): Promise => { const removalErrors: unknown[] = []; const createError = (message: string, errorOptions?: ErrorOptions): Error => diff --git a/src/container/waitUntilReachable.ts b/src/container/waitUntilReachable.ts index 4da09e5..a2d155f 100644 --- a/src/container/waitUntilReachable.ts +++ b/src/container/waitUntilReachable.ts @@ -35,6 +35,7 @@ const readMappedPort = (container: ContainerInfo, port: number): number | undefi // Returns the mapped port once the database answers on it, and undefined // until then. +// biome-ignore lint/nursery/noExcessiveStatementsPerFunction: A poll checks the container, its port, and its database in turn, and each check can end the poll early. const poll = async ({ client, startId, port, signal }: Readiness): Promise => { const [container] = await listContainersOfAttempt(client, startId); if (container === undefined) { diff --git a/src/convertCloudEventToEvent.test.ts b/src/convertCloudEventToEvent.test.ts index cb53cb3..fb4e101 100644 --- a/src/convertCloudEventToEvent.test.ts +++ b/src/convertCloudEventToEvent.test.ts @@ -4,6 +4,7 @@ import type { CloudEvent } from './CloudEvent.js'; import { convertCloudEventToEvent } from './convertCloudEventToEvent.js'; suite('convertCloudEventToEvent', () => { + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. test('converts a cloud event to an event.', () => { const now = Date.now(); diff --git a/src/detectApiVersion.test.ts b/src/detectApiVersion.test.ts index 41d43cd..b951e7a 100644 --- a/src/detectApiVersion.test.ts +++ b/src/detectApiVersion.test.ts @@ -33,6 +33,7 @@ interface FailedProbe { const expectedEventType: EventType = { type: 'io.eventsourcingdb.test', hasEvents: true }; +// biome-ignore lint/nursery/noExcessiveStatementsPerFunction: A suite counts each of its tests as a statement. suite('detectApiVersion', { timeout: 30_000 }, () => { let server: FakeServer; let client: Client; diff --git a/src/detectApiVersion.ts b/src/detectApiVersion.ts index c2e131a..30d7cd7 100644 --- a/src/detectApiVersion.ts +++ b/src/detectApiVersion.ts @@ -35,6 +35,7 @@ const detectApiVersion = async ({ url: URL; stallTimeout: number; }): Promise => + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The callback tells apart every answer that the ping can get, in the order in which they have to be checked. await runWithStallGuard({ stallTimeout }, async stallGuard => { const response = await stallGuard.send(new URL('/api/v2/system/ping', url).toString(), { method: 'GET', diff --git a/src/json/getRawJsonElements.ts b/src/json/getRawJsonElements.ts index 078d4ef..db9bb47 100644 --- a/src/json/getRawJsonElements.ts +++ b/src/json/getRawJsonElements.ts @@ -1,28 +1,21 @@ -import { scanJsonValue, skipJsonWhitespace } from './scanJson.js'; +import { scanJsonItems, scanJsonValue, skipJsonWhitespace } from './scanJson.js'; // Returns the elements of a JSON array exactly as the text contains them, or // an empty array if the text is not an array. const getRawJsonElements = (text: string): string[] => { const elements: string[] = []; - let position = skipJsonWhitespace(text, 0); + const start = skipJsonWhitespace(text, 0); - if (text[position] !== '[') { + if (text[start] !== '[') { return elements; } - position = skipJsonWhitespace(text, position + 1); + scanJsonItems(text, start, elementStart => { + const elementEnd = scanJsonValue(text, elementStart); + elements.push(text.slice(elementStart, elementEnd)); - while (position < text.length && text[position] !== ']') { - const elementEnd = scanJsonValue(text, position); - elements.push(text.slice(position, elementEnd)); - - position = skipJsonWhitespace(text, elementEnd); - if (text[position] !== ',') { - break; - } - - position = skipJsonWhitespace(text, position + 1); - } + return elementEnd; + }); return elements; }; diff --git a/src/json/getRawJsonMember.ts b/src/json/getRawJsonMember.ts index 6d1b33d..2b7b137 100644 --- a/src/json/getRawJsonMember.ts +++ b/src/json/getRawJsonMember.ts @@ -1,22 +1,20 @@ -import { scanJsonString, scanJsonValue, skipJsonWhitespace } from './scanJson.js'; +import { scanJsonItems, scanJsonString, scanJsonValue, skipJsonWhitespace } from './scanJson.js'; // Returns the value of a member of a JSON object exactly as the text contains // it, or undefined if the text is not an object or has no such member. If the // object has the member more than once, the last one wins, as with JSON.parse. const getRawJsonMember = (text: string, key: string): string | undefined => { - let position = skipJsonWhitespace(text, 0); + const start = skipJsonWhitespace(text, 0); - if (text[position] !== '{') { + if (text[start] !== '{') { return; } - position = skipJsonWhitespace(text, position + 1); - let rawValue: string | undefined; - while (text[position] === '"') { - const keyEnd = scanJsonString(text, position); - const currentKey: unknown = JSON.parse(text.slice(position, keyEnd)); + scanJsonItems(text, start, memberStart => { + const keyEnd = scanJsonString(text, memberStart); + const currentKey: unknown = JSON.parse(text.slice(memberStart, keyEnd)); const colon = skipJsonWhitespace(text, keyEnd); const valueStart = skipJsonWhitespace(text, colon + 1); @@ -26,13 +24,8 @@ const getRawJsonMember = (text: string, key: string): string | undefined => { rawValue = text.slice(valueStart, valueEnd); } - position = skipJsonWhitespace(text, valueEnd); - if (text[position] !== ',') { - break; - } - - position = skipJsonWhitespace(text, position + 1); - } + return valueEnd; + }); return rawValue; }; diff --git a/src/json/scanJson.ts b/src/json/scanJson.ts index bad9093..1098121 100644 --- a/src/json/scanJson.ts +++ b/src/json/scanJson.ts @@ -38,6 +38,7 @@ const scanJsonString = (text: string, start: number): number => { throw new Error('Failed to scan JSON string.'); }; +// biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The scan follows strings and the nesting depth in one loop, character by character. const scanJsonContainer = (text: string, start: number): number => { let depth = 0; let position = start; @@ -85,4 +86,25 @@ const scanJsonValue = (text: string, start: number): number => { return position; }; -export { scanJsonString, scanJsonValue, skipJsonWhitespace }; +// Scans the items of the JSON array or object whose opening bracket is at the +// given position, the elements of an array or the members of an object, one +// after another. It passes the start of each item to the given function, +// which returns the position right after the item. +const scanJsonItems = ( + text: string, + start: number, + scanItem: (itemStart: number) => number, +): void => { + let position = skipJsonWhitespace(text, start + 1); + + while (position < text.length && text[position] !== ']' && text[position] !== '}') { + position = skipJsonWhitespace(text, scanItem(position)); + if (text[position] !== ',') { + return; + } + + position = skipJsonWhitespace(text, position + 1); + } +}; + +export { scanJsonItems, scanJsonString, scanJsonValue, skipJsonWhitespace }; diff --git a/src/ndjson/readNdJsonStream.ts b/src/ndjson/readNdJsonStream.ts index 24805e5..a163512 100644 --- a/src/ndjson/readNdJsonStream.ts +++ b/src/ndjson/readNdJsonStream.ts @@ -7,6 +7,7 @@ interface NdJsonLine { value: Record; } +// biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The generator holds the reader, the buffer, and the abort listener, which its finally block has to release however the stream ends. const readNdJsonStream = async function* ( stream: ReadableStream, signal: AbortSignal, diff --git a/src/observeEvents.test.ts b/src/observeEvents.test.ts index 9e731c7..9fc7ee2 100644 --- a/src/observeEvents.test.ts +++ b/src/observeEvents.test.ts @@ -12,6 +12,7 @@ import { integrationTimeout } from './testing/integrationTimeout.js'; // arrived, and fails if they have not arrived within this many milliseconds. const observeTimeout = 10_000; +// biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The helper has to clear its timer however observing ends, and only then checks whether enough events arrived. const observeUntil = async ( observe: (signal: AbortSignal) => AsyncGenerator, expectedCount: number, diff --git a/src/ping.test.ts b/src/ping.test.ts index 8a6bae8..c054fc6 100644 --- a/src/ping.test.ts +++ b/src/ping.test.ts @@ -25,8 +25,9 @@ suite('ping', () => { async (): Promise => { const client = container.getClient(); - // Should not throw. - await client.ping(); + await assert.doesNotReject(async () => { + await client.ping(); + }); }, ); diff --git a/src/preconditionOptions.test.ts b/src/preconditionOptions.test.ts index 139eaed..7cc9a59 100644 --- a/src/preconditionOptions.test.ts +++ b/src/preconditionOptions.test.ts @@ -100,6 +100,7 @@ const isIncompatibleServerError = (error: unknown): boolean => error.name === 'IncompatibleServerError' && error.message === 'The server does not offer the v2 API, which a recursive precondition needs.'; +// biome-ignore lint/nursery/noExcessiveStatementsPerFunction: A suite counts each of its tests as a statement. suite('preconditionOptions', { timeout: 30_000 }, () => { let server: FakeServer; let client: Client; @@ -328,6 +329,7 @@ suite('preconditionOptions', { timeout: 30_000 }, () => { assert.deepEqual(getPaths(), ['/api/v1/verify-api-token', '/api/v1/verify-api-token']); }); + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. test('goes on with the v1 API for every other request while it finds out again, and uses the v2 API once it finds it.', async (): Promise => { const probe = Promise.withResolvers(); heldProbe = probe; diff --git a/src/readEventType.test.ts b/src/readEventType.test.ts index df27765..79596c3 100644 --- a/src/readEventType.test.ts +++ b/src/readEventType.test.ts @@ -44,21 +44,20 @@ suite('readEventType', () => { const client = container.getClient(); const apiVersion = await getApiVersionOf(container); + // The v1 API has no code for a malformed event type. + const expected = + apiVersion === 'v1' + ? { code: undefined, status: 400 } + : { code: ErrorCode.eventTypeMalformed, status: 200 }; + await assert.rejects( async () => { await client.readEventType('malformed.eventType.'); }, (error: unknown): boolean => { assert.ok(error instanceof DbApiError); - - // The v1 API has no code for a malformed event type. - if (apiVersion === 'v1') { - assert.equal(error.code, undefined); - assert.equal(error.status, 400); - } else { - assert.equal(error.code, ErrorCode.eventTypeMalformed); - assert.equal(error.status, 200); - } + assert.equal(error.code, expected.code); + assert.equal(error.status, expected.status); return true; }, ); diff --git a/src/readEvents.test.ts b/src/readEvents.test.ts index abfaabf..cecbdfe 100644 --- a/src/readEvents.test.ts +++ b/src/readEvents.test.ts @@ -7,6 +7,7 @@ import { getImageTag } from './getImageTag.js'; import { containerStartTimeout } from './testing/containerStartTimeout.js'; import { integrationTimeout } from './testing/integrationTimeout.js'; +// biome-ignore lint/nursery/noExcessiveStatementsPerFunction: A suite counts each of its tests as a statement. suite('readEvents', () => { let container: Container; @@ -99,6 +100,7 @@ suite('readEvents', () => { assert.equal(eventsRead.length, 2); }); + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. test('reads chronologically.', integrationTimeout, async (): Promise => { const client = container.getClient(); @@ -138,6 +140,7 @@ suite('readEvents', () => { assert.equal(secondEventRead.data.value, 42); }); + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. test('reads anti-chronologically.', integrationTimeout, async (): Promise => { const client = container.getClient(); @@ -177,6 +180,7 @@ suite('readEvents', () => { assert.equal(secondEventRead.data.value, 23); }); + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. test('reads with lower bound.', integrationTimeout, async (): Promise => { const client = container.getClient(); @@ -214,6 +218,7 @@ suite('readEvents', () => { assert.equal(eventRead.data.value, 42); }); + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. test('reads with upper bound.', integrationTimeout, async (): Promise => { const client = container.getClient(); @@ -251,6 +256,7 @@ suite('readEvents', () => { assert.equal(eventRead.data.value, 23); }); + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. test('reads from latest event.', integrationTimeout, async (): Promise => { const client = container.getClient(); diff --git a/src/readSubjects.test.ts b/src/readSubjects.test.ts index fd06bad..06cf549 100644 --- a/src/readSubjects.test.ts +++ b/src/readSubjects.test.ts @@ -34,6 +34,7 @@ suite('readSubjects', () => { }, ); + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. test('reads all subjects.', integrationTimeout, async (): Promise => { const client = container.getClient(); @@ -69,6 +70,7 @@ suite('readSubjects', () => { assert.equal(subjectsRead[3], '/test/2'); }); + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. test('reads all subjects from the subject.', integrationTimeout, async (): Promise => { const client = container.getClient(); diff --git a/src/readSubjectsOptions.test.ts b/src/readSubjectsOptions.test.ts index 3c9e369..8e4876e 100644 --- a/src/readSubjectsOptions.test.ts +++ b/src/readSubjectsOptions.test.ts @@ -30,6 +30,7 @@ suite('readSubjects', { timeout: 30_000 }, () => { } }); + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: A suite counts each of its tests as a statement. suite('v1', () => { let server: FakeServer; let client: Client; diff --git a/src/registerEventSchema.test.ts b/src/registerEventSchema.test.ts index af1ff91..d676cac 100644 --- a/src/registerEventSchema.test.ts +++ b/src/registerEventSchema.test.ts @@ -35,8 +35,9 @@ suite('registerEventSchema', () => { additionalProperties: false, }; - // Should not throw. - await client.registerEventSchema(eventType, schema); + await assert.doesNotReject(async () => { + await client.registerEventSchema(eventType, schema); + }); }); test( diff --git a/src/rollback.test.ts b/src/rollback.test.ts index f429cdc..f185fd5 100644 --- a/src/rollback.test.ts +++ b/src/rollback.test.ts @@ -170,6 +170,7 @@ const keptAnswers: KeptAnswer[] = [ ), ]; +// biome-ignore lint/nursery/noExcessiveStatementsPerFunction: A suite counts each of its tests as a statement. suite('a server that is rolled back to a version without the v2 API', { timeout: 30_000 }, () => { let server: FakeServer; let client: Client; @@ -227,6 +228,7 @@ suite('a server that is rolled back to a version without the v2 API', { timeout: }; const defineKeepTest = (call: Call, { name, status, answer }: KeptAnswer): void => { + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. test(`keeps the API after ${name}.`, async (): Promise => { server.respond = respondTo(call); await call.run(client); @@ -267,6 +269,7 @@ suite('a server that is rolled back to a version without the v2 API', { timeout: }); } + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. test('keeps the API after the status 404 of the v1 API, which it uses once the server is rolled back.', async (): Promise => { server.respond = respondTo(readEventType); await readEventType.run(client); @@ -312,17 +315,12 @@ suite('a server that is rolled back to a version without the v2 API', { timeout: let isProbeHeld = false; const respond = respondTo(readEventType); server.respond = (request: FakeRequest, response: ServerResponse): void => { - if (isProbe(request)) { - if (isProbeHeld) { - respond(request, response); - return; - } - + if (isProbe(request) && !isProbeHeld) { isProbeHeld = true; heldProbe.resolve(response); return; } - if (getApiVersion(request) === 'v2' && !offersV2) { + if (!isProbe(request) && getApiVersion(request) === 'v2' && !offersV2) { heldRequests.shift()?.resolve(response); return; } @@ -344,6 +342,7 @@ suite('a server that is rolled back to a version without the v2 API', { timeout: return [result, await held.promise]; }; + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. test('shares the probe, and keeps its answer, if requests of the v2 API that started before fail with 404 meanwhile.', async (): Promise => { const [failsFirst, firstResponse] = await sendHeld(); const [failsDuringProbe, duringProbeResponse] = await sendHeld(); @@ -378,6 +377,7 @@ suite('a server that is rolled back to a version without the v2 API', { timeout: ]); }); + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. test('keeps the answer of the probe if it finds the v2 API again, and a request of the v2 API that started before fails with 404 afterwards.', async (): Promise => { const [failsFirst, firstResponse] = await sendHeld(); const [failsLate, lateResponse] = await sendHeld(); diff --git a/src/runEventQlQuery.test.ts b/src/runEventQlQuery.test.ts index f9dd307..ce07f9a 100644 --- a/src/runEventQlQuery.test.ts +++ b/src/runEventQlQuery.test.ts @@ -35,6 +35,7 @@ suite('runEventQlQuery', () => { }, ); + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. test('reads all rows the query returns.', integrationTimeout, async (): Promise => { const client = container.getClient(); diff --git a/src/stall/readResponseText.ts b/src/stall/readResponseText.ts index cffe1a3..f0bd941 100644 --- a/src/stall/readResponseText.ts +++ b/src/stall/readResponseText.ts @@ -2,6 +2,7 @@ import type { StallGuard } from './StallGuard.js'; // Reads the body of a response as text, like response.text(), but restarts // the clock of the stall guard with every chunk that arrives. +// biome-ignore lint/nursery/noExcessiveStatementsPerFunction: Reading restarts the stall guard before every chunk, and has to release the reader however reading ends. const readResponseText = async (response: Response, stallGuard: StallGuard): Promise => { if (!response.body) { return ''; diff --git a/src/stallTimeout.test.ts b/src/stallTimeout.test.ts index f68237a..9780c67 100644 --- a/src/stallTimeout.test.ts +++ b/src/stallTimeout.test.ts @@ -208,6 +208,7 @@ suite('stallTimeout', { timeout: 30_000 }, () => { for (const apiVersion of apiVersions) { const { event, row, heartbeat, end } = lines[apiVersion]; + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: A suite counts each of its tests as a statement. suite(apiVersion, () => { let server: FakeServer; let client: Client; @@ -256,6 +257,7 @@ suite('stallTimeout', { timeout: 30_000 }, () => { test('ends a stream with heartbeats once nothing arrives for 30 seconds by default.', { timeout: 5000, + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. }, async (context): Promise => { server.respond = (_request: FakeRequest, response: ServerResponse): void => { writeHead(response, 200, ndJson); @@ -353,8 +355,7 @@ suite('stallTimeout', { timeout: 30_000 }, () => { trickle(response, answer.body); }; - // Should not throw. - await run(client); + await assert.doesNotReject(async () => await run(client)); }); }); }; diff --git a/src/types/hasShapeOf.ts b/src/types/hasShapeOf.ts index a69c43c..ed54453 100644 --- a/src/types/hasShapeOf.ts +++ b/src/types/hasShapeOf.ts @@ -9,32 +9,17 @@ type Shape = { const isGuard = (value: unknown): value is Guard => typeof value === 'function'; const isShape = (value: unknown): value is Shape => isRecord(value); -const hasShapeOf = (value: unknown, shape: Shape): value is T => { - if (!isRecord(value)) { - return false; +// A value matches a guard if the guard accepts it, and a nested shape if it is +// a record of that shape. +const matchesSpec = (value: unknown, spec: unknown): boolean => { + if (isGuard(spec)) { + return spec(value); } - for (const [key, spec] of Object.entries(shape)) { - const val = value[key]; - - if (isGuard(spec)) { - if (!spec(val)) { - return false; - } - - continue; - } - - if (!(isRecord(val) && isShape(spec))) { - return false; - } - - if (!hasShapeOf(val, spec)) { - return false; - } - } - - return true; + return isRecord(value) && isShape(spec) && hasShapeOf(value, spec); }; +const hasShapeOf = (value: unknown, shape: Shape): value is T => + isRecord(value) && Object.entries(shape).every(([key, spec]) => matchesSpec(value[key], spec)); + export { hasShapeOf }; diff --git a/src/v1/V1Transport.ts b/src/v1/V1Transport.ts index a19b46e..052b1d6 100644 --- a/src/v1/V1Transport.ts +++ b/src/v1/V1Transport.ts @@ -277,6 +277,7 @@ class V1Transport implements Transport { const apiToken = this.#apiToken; const stallTimeout = this.#stallTimeout; + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The generator sends the query and handles every kind of line that its stream can contain, failing on any other. return streamWithStallGuard({ stallTimeout, signal }, async function* (stallGuard) { const response = await stallGuard.send(url, { method: 'POST', @@ -320,6 +321,7 @@ class V1Transport implements Transport { const apiToken = this.#apiToken; const stallTimeout = this.#stallTimeout; + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The generator sends the request, handles every kind of line that its stream can contain, and fails if the stream ends, since observing never ends on its own. return streamWithStallGuard({ stallTimeout, signal }, async function* (stallGuard) { const response = await stallGuard.send(url, { method: 'POST', @@ -411,6 +413,7 @@ class V1Transport implements Transport { // only a string, and only to read the subject alone. const normalizedSubject = !recursive && isString(subject) ? normalizeSubject(subject) : subject; + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The generator sends the request and handles every kind of line that its stream can contain, ending once it has read the subject alone. return streamWithStallGuard({ stallTimeout, signal }, async function* (stallGuard) { const response = await stallGuard.send(url, { method: 'POST', @@ -483,6 +486,7 @@ class V1Transport implements Transport { const apiToken = this.#apiToken; const stallTimeout = this.#stallTimeout; + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The generator sends the request and handles every kind of line that its stream can contain, failing on any other. return streamWithStallGuard({ stallTimeout, signal }, async function* (stallGuard) { const response = await stallGuard.send(url, { method: 'POST', diff --git a/src/v2/readV2Stream.ts b/src/v2/readV2Stream.ts index 1b79de3..b1475ff 100644 --- a/src/v2/readV2Stream.ts +++ b/src/v2/readV2Stream.ts @@ -14,6 +14,7 @@ import type { V2Item } from './V2Item.js'; // broke off and fails, unless the caller aborted it. A server that answers // with 404 or 405 no longer offers the v2 API, so the client forgets which API // the server offers, and the stream fails as with any other status. +// biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The generator checks the response, yields its items, and then tells whether the stream broke off, which depends on how the loop over the items ended. const readV2Stream = async function* ( response: Response, stallGuard: StallGuard, diff --git a/src/verifyApiToken.test.ts b/src/verifyApiToken.test.ts index 077be1c..d6be798 100644 --- a/src/verifyApiToken.test.ts +++ b/src/verifyApiToken.test.ts @@ -26,8 +26,9 @@ suite('verifyApiToken', () => { async (): Promise => { const client = container.getClient(); - // Should not throw. - await client.verifyApiToken(); + await assert.doesNotReject(async () => { + await client.verifyApiToken(); + }); }, ); diff --git a/src/writeBeforeSending.test.ts b/src/writeBeforeSending.test.ts index 3ae18e9..cb6474f 100644 --- a/src/writeBeforeSending.test.ts +++ b/src/writeBeforeSending.test.ts @@ -58,6 +58,7 @@ const isAbortError = (error: unknown): boolean => // however it fails, so it fails with the error itself. The server accepts the // connection, but never answers the TLS handshake, so the request never gets // beyond establishing the connection. +// biome-ignore lint/nursery/noExcessiveStatementsPerFunction: A suite counts each of its tests as a statement. suite('a write that fails before its request went out', { timeout: 30_000 }, () => { let server: net.Server; let sockets: net.Socket[]; diff --git a/src/writeEvents.test.ts b/src/writeEvents.test.ts index 5bc2ed2..3149708 100644 --- a/src/writeEvents.test.ts +++ b/src/writeEvents.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { afterEach, beforeEach, suite, test } from 'node:test'; +import { afterEach, beforeEach, suite, type TestContext, test } from 'node:test'; import type { Client } from './Client.js'; import { Container } from './Container.js'; import { DbApiError } from './DbApiError.js'; @@ -30,6 +30,13 @@ const getEvent = (subject: string): EventCandidate => ({ }, }); +// Writes one event on each of the subjects, one write after another. +const writeEventsOn = async (client: Client, subjects: string[]): Promise => { + for (const subject of subjects) { + await client.writeEvents([getEvent(subject)]); + } +}; + const readAllEvents = async (client: Client): Promise => { const events: Event[] = []; for await (const event of client.readEvents('/', { recursive: true })) { @@ -98,6 +105,7 @@ const preconditionOptionsCases: PreconditionOptionsCase[] = [ }, ]; +// biome-ignore lint/nursery/noExcessiveStatementsPerFunction: A suite counts each of its tests as a statement. suite('writeEvents', () => { let container: Container; @@ -131,6 +139,7 @@ suite('writeEvents', () => { assert.equal(writtenEvent.id, '0'); }); + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. test('writes multiple events.', integrationTimeout, async (): Promise => { const client = container.getClient(); @@ -166,6 +175,7 @@ suite('writeEvents', () => { assert.equal(secondWrittenEvent.data.value, 42); }); + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. test('writes the trace context of an event.', integrationTimeout, async (): Promise => { const client = container.getClient(); @@ -240,6 +250,7 @@ suite('writeEvents', () => { test( 'supports the isSubjectPopulated precondition.', integrationTimeout, + // biome-ignore lint/nursery/noExcessiveStatementsPerFunction: The test is one linear sequence of steps, and splitting it would make it harder to follow. async (): Promise => { const client = container.getClient(); @@ -344,27 +355,28 @@ suite('writeEvents', () => { }, ); - const definePreconditionOptionsTest = ({ + const definePreconditionOptionsTests = ({ name, recursive, subjects, violated, holding, }: PreconditionOptionsCase): void => { - test( - `supports the ${name} precondition with the recursive option set to ${recursive}.`, - integrationTimeout, - async (): Promise => { - const client = container.getClient(); - const apiVersion = await getApiVersionOf(container); + // The v1 API cannot check a recursive precondition, so the write fails + // before it is sent, whether the precondition would hold or not. + if (recursive) { + test( + `rejects the ${name} precondition with the recursive option set to true through the v1 API.`, + integrationTimeout, + async (context: TestContext): Promise => { + if ((await getApiVersionOf(container)) !== 'v1') { + context.skip('The server offers the v2 API, which checks recursive preconditions.'); + return; + } - for (const subject of subjects) { - await client.writeEvents([getEvent(subject)]); - } + const client = container.getClient(); + await writeEventsOn(client, subjects); - // The v1 API cannot check a recursive precondition, so the write fails - // before it is sent, whether the precondition would hold or not. - if (recursive && apiVersion === 'v1') { for (const precondition of [violated, holding]) { await assert.rejects(async () => { await client.writeEvents([getEvent('/test')], [precondition]); @@ -372,9 +384,24 @@ suite('writeEvents', () => { } assert.equal((await readAllEvents(client)).length, subjects.length); + }, + ); + } + + test( + `supports the ${name} precondition with the recursive option set to ${recursive}.`, + integrationTimeout, + async (context: TestContext): Promise => { + if (recursive && (await getApiVersionOf(container)) === 'v1') { + context.skip( + 'The server offers only the v1 API, which cannot check recursive preconditions.', + ); return; } + const client = container.getClient(); + await writeEventsOn(client, subjects); + await assert.rejects(async () => { await client.writeEvents([getEvent('/test')], [violated]); }, isPreconditionViolated); @@ -386,6 +413,6 @@ suite('writeEvents', () => { }; for (const preconditionOptionsCase of preconditionOptionsCases) { - definePreconditionOptionsTest(preconditionOptionsCase); + definePreconditionOptionsTests(preconditionOptionsCase); } });