diff --git a/.changeset/tidy-moles-connect.md b/.changeset/tidy-moles-connect.md new file mode 100644 index 0000000..3fadcf3 --- /dev/null +++ b/.changeset/tidy-moles-connect.md @@ -0,0 +1,22 @@ +--- +'prool': patch +--- + +Added typed named endpoints, Server discovery, and generic Testcontainers-backed instances. + +```ts +import { Instance } from 'prool/testcontainers' +import { GenericContainer } from 'testcontainers' + +const service = Instance.testcontainer({ + name: 'service', + container: () => new GenericContainer('service:latest'), + endpoints: { + default: { protocol: 'http', port: 8080 }, + metrics: { protocol: 'http', port: 9090 }, + }, +}) + +await service.start() +service.endpoints.metrics +``` diff --git a/src/Instance.test.ts b/src/Instance.test.ts index 5156202..0034386 100644 --- a/src/Instance.test.ts +++ b/src/Instance.test.ts @@ -1,5 +1,5 @@ import { Instance } from 'prool' -import { expect, test } from 'vitest' +import { expect, expectTypeOf, test, vi } from 'vitest' test('default', async () => { let started = false @@ -327,6 +327,86 @@ test('behavior: dynamic host/port via setEndpoint', async () => { expect(instance.port).toEqual(9999) }) +test('behavior: named endpoints', async () => { + const foo = Instance.define(() => { + return { + endpoints: { + default: { + host: 'endpoint.localhost', + port: 3100, + protocol: 'https' as const, + }, + metrics: { + host: 'localhost', + port: 9090, + protocol: 'http' as const, + }, + }, + name: 'foo', + host: 'localhost', + port: 3000, + async start(_, { setEndpoint }) { + setEndpoint?.({ host: '127.0.0.1', port: 4000 }) + setEndpoint?.('metrics', { + host: '127.0.0.1', + port: 5000, + protocol: 'http', + }) + expectTypeOf>().toBeCallableWith( + 'metrics', + { + host: '127.0.0.1', + port: 5000, + protocol: 'http', + }, + ) + expectTypeOf>().toBeCallableWith( + // @ts-expect-error metrics uses HTTP. + 'metrics', + { + host: '127.0.0.1', + port: 5000, + protocol: 'tcp', + }, + ) + expectTypeOf>().toBeCallableWith( + // @ts-expect-error missing is not a declared endpoint. + 'missing', + { host: '127.0.0.1', port: 5000, protocol: 'http' }, + ) + }, + async stop() {}, + } + }) + + const instance = foo() + const endpoints = instance.endpoints + + expect(instance.endpoints.default).toEqual({ + host: 'endpoint.localhost', + port: 3100, + protocol: 'https', + }) + expect(instance.endpoints.metrics).toEqual({ + host: 'localhost', + port: 9090, + protocol: 'http', + }) + expectTypeOf(instance.endpoints.metrics.protocol).toEqualTypeOf<'http'>() + + await instance.start() + + expect(instance.host).toEqual('127.0.0.1') + expect(instance.port).toEqual(4000) + expect(instance.endpoints.default.protocol).toBe('https') + expect(instance.endpoints.default).toBe(endpoints.default) + expect(instance.endpoints.metrics).toEqual({ + host: '127.0.0.1', + port: 5000, + protocol: 'http', + }) +}) + test('behavior: start() returning void keeps original host/port', async () => { const foo = Instance.define(() => { return { @@ -380,3 +460,67 @@ test('options: timeout', async () => { 'Instance "bar" failed to stop in time', ) }) + +test('options: clears settled start timeout', async () => { + vi.useFakeTimers() + try { + let starts = 0 + const release = Promise.withResolvers() + const foo = Instance.define(() => ({ + host: 'localhost', + name: 'foo', + port: 3000, + async start() { + starts++ + if (starts === 2) await release.promise + }, + async stop() {}, + })) + const instance = foo({ timeout: 100 }) + + await instance.start() + await instance.stop() + await vi.advanceTimersByTimeAsync(60) + + const started = expect(instance.start()).resolves.toBeTypeOf('function') + await vi.advanceTimersByTimeAsync(50) + release.resolve() + + await started + await instance.stop() + } finally { + vi.useRealTimers() + } +}) + +test('options: clears settled stop timeout', async () => { + vi.useFakeTimers() + try { + let stops = 0 + const release = Promise.withResolvers() + const foo = Instance.define(() => ({ + host: 'localhost', + name: 'foo', + port: 3000, + async start() {}, + async stop() { + stops++ + if (stops === 2) await release.promise + }, + })) + const instance = foo({ timeout: 100 }) + + await instance.start() + await instance.stop() + await instance.start() + await vi.advanceTimersByTimeAsync(60) + + const stopped = expect(instance.stop()).resolves.toBeUndefined() + await vi.advanceTimersByTimeAsync(50) + release.resolve() + + await stopped + } finally { + vi.useRealTimers() + } +}) diff --git a/src/Instance.ts b/src/Instance.ts index 211028a..7f90606 100644 --- a/src/Instance.ts +++ b/src/Instance.ts @@ -13,8 +13,32 @@ type EventTypes = { stdout: [message: string] } +export type Endpoint = { + /** Host the endpoint is available on. */ + host: string + /** Port the endpoint is available on. */ + port: number + /** Transport protocol used by the endpoint. */ + protocol: protocol +} + +export declare namespace Endpoint { + export type Protocol = 'http' | 'https' | 'tcp' | 'ws' | 'wss' +} + +type EndpointMap = Record + +type InstanceEndpoints = Readonly< + { + default: endpoints extends { default: infer endpoint extends Endpoint } + ? endpoint + : Endpoint + } & Omit +> + export type Instance< _internal extends object | undefined = object | undefined, + endpoints extends EndpointMap = EndpointMap, > = Pick< EventEmitter, | 'addListener' @@ -30,7 +54,9 @@ export type Instance< */ create( parameters?: { port?: number | undefined } | undefined, - ): Omit, 'create'> + ): Omit, 'create'> + /** Named endpoints. `host` and `port` alias `default`. */ + endpoints: InstanceEndpoints /** * Host the instance is running on. */ @@ -113,11 +139,14 @@ export type InstanceOptions = { export function define< _internal extends object | undefined, parameters = undefined, + endpoints extends EndpointMap = {}, >( - fn: define.DefineFn, -): define.ReturnType<_internal, parameters> { + fn: define.DefineFn, +): define.ReturnType<_internal, parameters, endpoints> { return (...[parametersOrOptions, options_]) => { - function create(createParameters: Parameters[0] = {}) { + function create( + createParameters: { port?: number | undefined } = {}, + ): Omit, 'create'> { const parameters = parametersOrOptions as parameters const options = options_ || parametersOrOptions || {} @@ -126,8 +155,32 @@ export function define< ...instance, ...createParameters, } - let host = instance.host - let port = createParameters?.port ?? instance.port + const initialEndpoints = instance.endpoints as EndpointMap | undefined + const endpointMap: EndpointMap & { default: Endpoint } = { + ...initialEndpoints, + default: { + host: initialEndpoints?.['default']?.host ?? instance.host, + port: + createParameters.port ?? + initialEndpoints?.['default']?.port ?? + instance.port, + protocol: initialEndpoints?.['default']?.protocol ?? 'http', + }, + } + const setEndpoint = (( + ...args: + | [endpoint: Partial] + | [name: string, endpoint: Endpoint] + ) => { + if (typeof args[0] === 'string') { + endpointMap[args[0]] = args[1] + return + } + endpointMap.default = { + ...endpointMap.default, + ...args[0], + } + }) as define.SetEndpoint const { messageBuffer = 20, timeout } = options let restartResolver = Promise.withResolvers() @@ -162,12 +215,13 @@ export function define< }, }, get host() { - return host + return endpointMap.default.host }, name, get port() { - return port + return endpointMap.default.port }, + endpoints: endpointMap as InstanceEndpoints, get status() { if (restarting) return 'restarting' return status @@ -179,10 +233,13 @@ export function define< `Instance "${name}" is not in an idle or stopped state. Status: ${status}`, ) + const resolver = Promise.withResolvers<() => void>() + startResolver = resolver + + let timer: NodeJS.Timeout | undefined if (typeof timeout === 'number') { - const timer = setTimeout(() => { - clearTimeout(timer) - startResolver.reject( + timer = setTimeout(() => { + resolver.reject( new Error(`Instance "${name}" failed to start in time.`), ) }, timeout) @@ -195,41 +252,43 @@ export function define< status = 'starting' start( { - port, + port: endpointMap.default.port, }, { emitter, - setEndpoint(endpoint) { - if (endpoint.host) host = endpoint.host - if (endpoint.port) port = endpoint.port - }, + setEndpoint, status: this.status, }, ) .then(() => { + if (timer) clearTimeout(timer) status = 'started' stopResolver = Promise.withResolvers() - startResolver.resolve(this.stop.bind(this)) + resolver.resolve(this.stop.bind(this)) }) .catch((error) => { + if (timer) clearTimeout(timer) status = 'idle' this.messages.clear() emitter.off('message', onMessage) - startResolver.reject(error) + resolver.reject(error) }) - return startResolver.promise + return resolver.promise }, async stop() { if (status === 'stopping') return stopResolver.promise if (status === 'starting') throw new Error(`Instance "${name}" is starting.`) + const resolver = Promise.withResolvers() + stopResolver = resolver + + let timer: NodeJS.Timeout | undefined if (typeof timeout === 'number') { - const timer = setTimeout(() => { - clearTimeout(timer) - stopResolver.reject( + timer = setTimeout(() => { + resolver.reject( new Error(`Instance "${name}" failed to stop in time.`), ) }, timeout) @@ -241,6 +300,7 @@ export function define< status: this.status, }) .then((...args) => { + if (timer) clearTimeout(timer) status = 'stopped' this.messages.clear() @@ -249,14 +309,15 @@ export function define< emitter.off('exit', onExit) startResolver = Promise.withResolvers<() => void>() - stopResolver.resolve(...args) + resolver.resolve(...args) }) .catch((error) => { + if (timer) clearTimeout(timer) status = 'started' - stopResolver.reject(error) + resolver.reject(error) }) - return stopResolver.promise + return resolver.promise }, async restart() { if (restarting) return restartResolver.promise @@ -292,11 +353,13 @@ export declare namespace define { export type DefineFn< parameters, _internal extends object | undefined = object | undefined, + endpoints extends EndpointMap = {}, > = (parameters: parameters) => Pick & { _internal?: _internal | undefined + endpoints?: endpoints | undefined start( options: InstanceStartOptions, - options_internal: InstanceStartOptions_internal, + options_internal: InstanceStartOptions_internal, ): Promise stop(options_internal: InstanceStopOptions_internal): Promise } @@ -304,15 +367,26 @@ export declare namespace define { export type ReturnType< _internal extends object | undefined = object | undefined, parameters = undefined, + endpoints extends EndpointMap = {}, > = ( ...parameters: parameters extends undefined ? [options?: InstanceOptions] : [parameters: parameters, options?: InstanceOptions] - ) => Instance<_internal> + ) => Instance<_internal, endpoints> + + export type SetEndpoint = { + (endpoint: Partial['default']>): void + ( + name: name, + endpoint: Exclude, + ): void + } - export type InstanceStartOptions_internal = { + export type InstanceStartOptions_internal< + endpoints extends EndpointMap = EndpointMap, + > = { emitter: EventEmitter - setEndpoint?(endpoint: { host?: string; port?: number }): void + setEndpoint?: SetEndpoint status: Instance['status'] } diff --git a/src/Pool.test.ts b/src/Pool.test.ts index e8c74f8..17f61a9 100644 --- a/src/Pool.test.ts +++ b/src/Pool.test.ts @@ -1,10 +1,17 @@ import getPort from 'get-port' import { Instance, Pool, Server } from 'prool' -import { afterEach, beforeAll, describe, expect, test } from 'vitest' +import { + afterEach, + beforeAll, + describe, + expect, + expectTypeOf, + test, +} from 'vitest' import { altoOptions } from '../test/utils.js' -let pool: ReturnType +let pool: ReturnType | undefined const port = await getPort() beforeAll(() => @@ -19,12 +26,40 @@ beforeAll(() => afterEach(async () => { try { - await pool.stopAll() + await pool?.stopAll() } catch (err) { console.error(err) } }) +test('preserves named endpoint types', async () => { + const foo = Instance.define(() => ({ + endpoints: { + metrics: { + host: 'localhost', + port: 9090, + protocol: 'http' as const, + }, + }, + host: 'localhost', + name: 'foo', + port: 3000, + async start() {}, + async stop() {}, + })) + const namedPool = Pool.define({ instance: foo() }) + + const instance = await namedPool.start(1) + + expect(instance.endpoints.metrics).toEqual({ + host: 'localhost', + port: 9090, + protocol: 'http', + }) + expectTypeOf(instance.endpoints.metrics.protocol).toEqualTypeOf<'http'>() + await namedPool.destroyAll() +}) + describe.each([ { instance: Instance.anvil({ port: await getPort() }) }, { instance: Instance.tempo({ port: await getPort() }) }, @@ -274,7 +309,7 @@ describe.each([ await pool.start(1) await pool.start(2) - await expect(() => pool.start(3)).rejects.toThrowError( + await expect(() => pool!.start(3)).rejects.toThrowError( 'Instance limit of 2 reached.', ) }) diff --git a/src/Pool.ts b/src/Pool.ts index 82a449a..8feb1e6 100644 --- a/src/Pool.ts +++ b/src/Pool.ts @@ -2,14 +2,17 @@ import getPort from 'get-port' import type { Instance } from './Instance.js' -type Instance_ = Omit +type StartedInstance = ReturnType -export type Pool = Pick< - Map, +export type Pool< + key extends number | string = number | string, + instance extends Instance = Instance, +> = Pick< + Map>, 'entries' | 'keys' | 'forEach' | 'get' | 'has' | 'size' | 'values' > & { _internal: { - instance: Instance_ | ((key: key) => Instance_) + instance: instance | ((key: key) => instance) } destroy(key: key): Promise destroyAll(): Promise @@ -17,7 +20,7 @@ export type Pool = Pick< start( key: key, options?: { port?: number | undefined } | undefined, - ): Promise + ): Promise> stop(key: key): Promise stopAll(): Promise } @@ -36,12 +39,15 @@ export type Pool = Pick< * const instance_3 = await pool.start(3) * ``` */ -export function define( - parameters: define.Parameters, -): define.ReturnType { +export function define< + key extends number | string = number, + instance extends Instance = Instance, +>( + parameters: define.Parameters, +): define.ReturnType { const { limit } = parameters - type Instance_ = Omit + type Instance_ = StartedInstance const instances = new Map() // Define promise instances for mutators to avoid race conditions, and return @@ -130,7 +136,8 @@ export function define( : parameters.instance const { port = await getPort() } = options - const instance_ = instances.get(key) || instance.create({ port }) + const instance_ = + instances.get(key) || (instance.create({ port }) as Instance_) instance_ .start() .then(() => { @@ -190,13 +197,18 @@ export function define( } export declare namespace define { - export type Parameters = { + export type Parameters< + key extends number | string = number | string, + instance extends Instance = Instance, + > = { /** Instance for the pool. */ - instance: Instance | ((key: key) => Instance) + instance: instance | ((key: key) => instance) /** The maximum number of instances that can be started. */ limit?: number | undefined } - export type ReturnType = - Pool + export type ReturnType< + key extends number | string = number | string, + instance extends Instance = Instance, + > = Pool } diff --git a/src/Server.test.ts b/src/Server.test.ts index fba35eb..3258730 100644 --- a/src/Server.test.ts +++ b/src/Server.test.ts @@ -16,6 +16,90 @@ beforeAll(async () => { }).start() }) +test('request: lifecycle endpoint discovery', async () => { + const foo = Instance.define(() => { + let starts = 0 + return { + endpoints: { + metrics: { + host: 'localhost', + port: 9090, + protocol: 'http' as const, + }, + }, + host: 'localhost', + name: 'foo', + port: 3000, + async start(_, { setEndpoint }) { + starts++ + setEndpoint?.('metrics', { + host: '127.0.0.1', + port: 9090 + starts, + protocol: 'http', + }) + }, + async stop() {}, + } + }) + const server = Server.create({ instance: foo() }) + const stop = await server.start() + const { port } = server.address()! + + const start = await fetch(`http://localhost:${port}/1/start`).then( + (response) => response.json(), + ) + expect(start).toMatchObject({ + endpoints: { + default: { protocol: 'http' }, + metrics: { + host: '127.0.0.1', + port: 9091, + protocol: 'http', + }, + }, + }) + expect(start).toMatchObject({ + host: start.endpoints.default.host, + port: start.endpoints.default.port, + }) + + const restart = await fetch(`http://localhost:${port}/1/restart`).then( + (response) => response.json(), + ) + expect(restart.endpoints.metrics.port).toBe(9092) + + await stop() +}) + +test('request: rejects a TCP default proxy', async () => { + const database = Instance.define(() => ({ + endpoints: { + default: { + host: 'localhost', + port: 5432, + protocol: 'tcp' as const, + }, + }, + host: 'localhost', + name: 'database', + port: 5432, + async start() {}, + async stop() {}, + })) + const server = Server.create({ instance: database() }) + const stop = await server.start() + const { port } = server.address()! + + const response = await fetch(`http://localhost:${port}/1`) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + message: 'Cannot proxy tcp endpoint over HTTP.', + }) + + await stop() +}) + describe.each([ { instance: Instance.anvil({ port: await getPort() }) }, { instance: Instance.tempo({ port: await getPort() }) }, @@ -96,6 +180,11 @@ describe.each([ const json = (await response.json()) as any expect(json.host).toBeDefined() expect(json.port).toBeDefined() + expect(json.endpoints.default).toEqual({ + host: json.host, + port: json.port, + protocol: 'http', + }) const response_err = await fetch(`http://localhost:${port}/1/start`) expect(response_err.status).toBe(400) diff --git a/src/Server.ts b/src/Server.ts index f99a3ed..a84fc42 100644 --- a/src/Server.ts +++ b/src/Server.ts @@ -6,24 +6,32 @@ import { } from 'node:http' import type { AddressInfo } from 'node:net' import httpProxy from 'http-proxy' +import type { Endpoint, Instance } from './Instance.js' import { extractPath } from './internal/utils.js' import * as Pool from './Pool.js' const { createProxyServer } = httpProxy +const websocketProtocols: Partial> = { + http: 'ws', + https: 'wss', + ws: 'ws', + wss: 'wss', +} -export type CreateServerParameters = Pool.define.Parameters & - ( - | { - /** Host to run the server on. */ - host?: string | undefined - /** Port to run the server on. */ - port: number - } - | { - host?: undefined - port?: undefined - } - ) +export type CreateServerParameters = + Pool.define.Parameters & + ( + | { + /** Host to run the server on. */ + host?: string | undefined + /** Port to run the server on. */ + port: number + } + | { + host?: undefined + port?: undefined + } + ) export type CreateServerReturnType = Omit< Server, @@ -57,8 +65,8 @@ export type CreateServerReturnType = Omit< * // "http://localhost:8545/healthcheck" * ``` */ -export function create( - parameters: CreateServerParameters, +export function create( + parameters: CreateServerParameters, ): CreateServerReturnType { const { host = '::', instance, limit, port } = parameters @@ -91,9 +99,9 @@ export function create( if (typeof id === 'number') { if (path === '/') { - const { host, port } = pool.get(id) || (await pool.start(id)) + const instance = pool.get(id) || (await pool.start(id)) return proxy.web(request, response, { - target: `http://${host}:${port}`, + target: httpProxyTarget(instance.endpoints.default), }) } if (path === '/destroy') { @@ -101,8 +109,8 @@ export function create( return done(response, 200) } if (path === '/start') { - const { host, port } = await pool.start(id) - return done(response, 200, { host, port }) + const instance = await pool.start(id) + return done(response, 200, instanceDescriptor(instance)) } if (path === '/stop') { await pool.stop(id) @@ -110,7 +118,12 @@ export function create( } if (path === '/restart') { await pool.restart(id) - return done(response, 200) + const instance = pool.get(id) + return done( + response, + 200, + instance ? instanceDescriptor(instance) : undefined, + ) } if (path === '/messages') { const messages = pool.get(id)?.messages.get() || [] @@ -154,10 +167,16 @@ export function create( const { id, path } = extractPath(url) if (typeof id === 'number' && path === '/') { - const { host, port } = pool.get(id) || (await pool.start(id)) - proxy.ws(request, socket, head, { - target: `ws://${host}:${port}`, - }) + try { + const instance = pool.get(id) || (await pool.start(id)) + proxy.ws(request, socket, head, { + target: websocketProxyTarget(instance.endpoints.default), + }) + } catch (error) { + socket.destroy( + error instanceof Error ? error : new Error(String(error)), + ) + } return } @@ -183,6 +202,31 @@ export function create( }) } +function httpProxyTarget(endpoint: Endpoint) { + if (endpoint.protocol !== 'http' && endpoint.protocol !== 'https') + throw new Error(`Cannot proxy ${endpoint.protocol} endpoint over HTTP.`) + return `${endpoint.protocol}://${endpoint.host}:${endpoint.port}` +} + +function websocketProxyTarget(endpoint: Endpoint) { + const protocol = websocketProtocols[endpoint.protocol] + if (!protocol) + throw new Error( + `Cannot proxy ${endpoint.protocol} endpoint over WebSocket.`, + ) + return `${protocol}://${endpoint.host}:${endpoint.port}` +} + +function instanceDescriptor( + instance: Pick, +) { + return { + endpoints: instance.endpoints, + host: instance.host, + port: instance.port, + } +} + function done(res: ServerResponse, statusCode: number, json?: unknown) { return res .writeHead(statusCode, { diff --git a/src/testcontainers/Instance.test.ts b/src/testcontainers/Instance.test.ts index 577f712..6b7cf00 100644 --- a/src/testcontainers/Instance.test.ts +++ b/src/testcontainers/Instance.test.ts @@ -127,6 +127,8 @@ test('behavior: faucet funds address', { timeout: 120_000 }, async () => { }) const zonePort = await getPort() +const zoneImage = + 'ghcr.io/tempoxyz/tempo-zone@sha256:dfc5e922c0ea9eebfa3345aa2ddb3df6f9070d20526348366f1746472a1959e2' describe('tempoZone', () => { const defineZoneInstance = ( @@ -140,11 +142,17 @@ describe('tempoZone', () => { return instance } - test('default image with quiet logs', { timeout: 600_000 }, async () => { - const instance = defineZoneInstance({ log: 'warn' }) + test('named endpoints', { timeout: 600_000 }, async () => { + const instance = defineZoneInstance({ image: zoneImage, log: 'warn' }) await instance.start() expect(instance.status).toEqual('started') + expect(instance.endpoints.default).toEqual({ + host: instance.host, + port: instance.port, + protocol: 'http', + }) + expect(instance.endpoints.l1).toMatchObject({ protocol: 'ws' }) const rpc = async (method: string, params: unknown[]) => { const response = await fetch(`http://${instance.host}:${instance.port}`, { @@ -171,6 +179,10 @@ describe('tempoZone', () => { // Private RPC rejects unauthenticated requests after becoming reachable. const { privateRpc } = instance._internal expect(privateRpc).toBeDefined() + expect(instance.endpoints.privateRpc).toEqual({ + ...privateRpc, + protocol: 'http', + }) const response = await fetch( `http://${privateRpc!.host}:${privateRpc!.port}`, { diff --git a/src/testcontainers/Instance.ts b/src/testcontainers/Instance.ts index de68adb..7f5d78a 100644 --- a/src/testcontainers/Instance.ts +++ b/src/testcontainers/Instance.ts @@ -4,6 +4,7 @@ import { PullPolicy, type StartedNetwork, type StartedTestContainer, + type TestContainer, Wait, } from 'testcontainers' import * as Instance from '../Instance.js' @@ -14,7 +15,130 @@ import { } from '../instances/tempoZone.js' import * as ContainerOptions from './containerOptions.js' -export type { Instance, InstanceOptions } from '../Instance.js' +export type { Endpoint, Instance, InstanceOptions } from '../Instance.js' + +/** + * Defines an instance backed by a Testcontainers container. + * + * @example + * ```ts + * const instance = Instance.testcontainer({ + * name: 'service', + * container: () => new GenericContainer('service:latest'), + * endpoints: { + * default: { protocol: 'http', port: 8080 }, + * metrics: { protocol: 'http', port: 9090 }, + * }, + * }) + * ``` + */ +export function testcontainer< + const endpointDefinitions extends testcontainer.EndpointDefinitions, +>( + parameters: testcontainer.Parameters, + options?: Instance.InstanceOptions, +): Instance.Instance> { + const ports = [ + ...new Set( + Object.values(parameters.endpoints).map((endpoint) => endpoint.port), + ), + ] + const initialEndpoints = Object.fromEntries( + Object.entries(parameters.endpoints).map(([name, endpoint]) => [ + name, + { + host: 'localhost', + port: endpoint.port, + protocol: endpoint.protocol, + }, + ]), + ) as testcontainer.Endpoints + + const definition = Instance.define< + undefined, + undefined, + testcontainer.Endpoints + >(() => { + let container: StartedTestContainer | undefined + + async function stopContainer() { + if (!container) return + const started = container + await started.stop() + if (container === started) container = undefined + } + + return { + endpoints: initialEndpoints, + host: initialEndpoints.default.host, + name: parameters.name, + port: initialEndpoints.default.port, + async start(_, { setEndpoint }) { + await stopContainer() + const started = await parameters + .container() + .withExposedPorts(...ports) + .start() + container = started + + try { + const host = started.getHost() + const endpoints = Object.entries(parameters.endpoints).map( + ([name, definition]) => + [ + name, + { + host, + port: started.getMappedPort(definition.port), + protocol: definition.protocol, + }, + ] as const, + ) + const applyEndpoint = setEndpoint as + | ((name: string, endpoint: Instance.Endpoint) => void) + | undefined + for (const [name, endpoint] of endpoints) + applyEndpoint?.(name, endpoint) + } catch (error) { + const [result] = await Promise.allSettled([started.stop()]) + if (result?.status === 'fulfilled') container = undefined + throw error + } + }, + async stop() { + await stopContainer() + }, + } + }) + + return definition(options) +} + +export declare namespace testcontainer { + export type EndpointDefinition< + protocol extends Instance.Endpoint.Protocol = Instance.Endpoint.Protocol, + > = { + port: number + protocol: protocol + } + + export type EndpointDefinitions = { + default: EndpointDefinition + [name: string]: EndpointDefinition + } + + export type Endpoints = { + [name in keyof definitions]: Instance.Endpoint< + definitions[name]['protocol'] + > + } + + export type Parameters = { + container: () => TestContainer + endpoints: definitions + name: string + } +} /** * Defines a Tempo instance. @@ -217,15 +341,31 @@ export const tempoZone = Instance.define( } }, }, + endpoints: { + default: { + host: args.host ?? 'localhost', + port: args.port ?? 9545, + protocol: 'http' as const, + }, + l1: undefined as Instance.Endpoint<'ws'> | undefined, + privateRpc: { + host: args.host ?? 'localhost', + port: + (args['privateRpc'] as { port?: number } | undefined)?.port ?? + (args.port ?? 9545) + 3, + protocol: 'http' as const, + }, + }, host: args.host ?? 'localhost', name, port: args.port ?? 9545, async start({ port = args.port }, { emitter, setEndpoint }) { const containerPort = port ?? 9545 // Mirrors the `zoneCommand` default; serves authenticated `eth_*` + `zone_*`. - privateRpcPort = + const effectivePrivateRpcPort = (args['privateRpc'] as { port?: number } | undefined)?.port ?? containerPort + 3 + privateRpcPort = effectivePrivateRpcPort const timeout = startupTimeout ?? tempoZoneStartupTimeout const includeL1Log = (message: string) => { if (log !== 'warn' && log !== 'error') return true @@ -298,7 +438,7 @@ export const tempoZone = Instance.define( .withPullPolicy(PullPolicy.alwaysPull()) // The public image currently publishes only linux/amd64. .withPlatform('linux/amd64') - .withExposedPorts(containerPort, privateRpcPort) + .withExposedPorts(containerPort, effectivePrivateRpcPort) .withExtraHosts([ { host: 'host.docker.internal', ipAddress: 'host-gateway' }, ]) @@ -317,7 +457,7 @@ export const tempoZone = Instance.define( }), ) .withWaitStrategy( - Wait.forHttp('/', privateRpcPort, { + Wait.forHttp('/', effectivePrivateRpcPort, { abortOnContainerExit: true, }) .withMethod('POST') @@ -330,10 +470,27 @@ export const tempoZone = Instance.define( c.start() .then((started) => { container = started - setEndpoint?.({ - host: started.getHost(), + const host = started.getHost() + const defaultEndpoint = { + host, port: started.getMappedPort(containerPort), - }) + } + const privateRpcEndpoint = { + host, + port: started.getMappedPort(effectivePrivateRpcPort), + protocol: 'http' as const, + } + const l1Endpoint = l1Container + ? { + host: l1Container.getHost(), + port: l1Container.getMappedPort(l1WsPort), + protocol: 'ws' as const, + } + : undefined + + setEndpoint?.(defaultEndpoint) + setEndpoint?.('privateRpc', privateRpcEndpoint) + if (l1Endpoint) setEndpoint?.('l1', l1Endpoint) promise.resolve() }) .catch(promise.reject) diff --git a/src/testcontainers/testcontainer.test.ts b/src/testcontainers/testcontainer.test.ts new file mode 100644 index 0000000..043c7f8 --- /dev/null +++ b/src/testcontainers/testcontainer.test.ts @@ -0,0 +1,186 @@ +import { Instance } from 'prool/testcontainers' +import type { TestContainer } from 'testcontainers' +import { expect, expectTypeOf, test, vi } from 'vitest' + +type StartedContainer = { + getHost(): string + getMappedPort(port: number): number + stop(): Promise +} + +function container( + started: StartedContainer, + expose: (ports: number[]) => void = () => {}, +): TestContainer { + const value = { + async start() { + return started + }, + withExposedPorts(...ports: number[]) { + expose(ports) + return value + }, + } + return value as unknown as TestContainer +} + +test('maps named endpoints and creates a fresh container on restart', async () => { + const exposures: number[][] = [] + const stops: ReturnType[] = [] + const createContainer = vi.fn(() => { + const start = createContainer.mock.calls.length + const stop = vi.fn(async () => {}) + stops.push(stop) + return container( + { + getHost: () => '127.0.0.1', + getMappedPort: (port) => start * 10_000 + port, + stop, + }, + (ports) => exposures.push(ports), + ) + }) + const instance = Instance.testcontainer({ + name: 'service', + container: createContainer, + endpoints: { + default: { port: 8080, protocol: 'http' }, + metrics: { port: 9090, protocol: 'tcp' }, + }, + }) + + expect(instance.endpoints.default).toEqual({ + host: 'localhost', + port: 8080, + protocol: 'http', + }) + expect(instance.endpoints.metrics).toEqual({ + host: 'localhost', + port: 9090, + protocol: 'tcp', + }) + expectTypeOf(instance.endpoints.default).toEqualTypeOf< + Instance.Endpoint<'http'> + >() + expectTypeOf(instance.endpoints.metrics).toEqualTypeOf< + Instance.Endpoint<'tcp'> + >() + + await instance.start() + + expect(exposures).toEqual([[8080, 9090]]) + expect(instance.host).toBe('127.0.0.1') + expect(instance.port).toBe(18_080) + expect(instance.endpoints.metrics).toEqual({ + host: '127.0.0.1', + port: 19_090, + protocol: 'tcp', + }) + + await instance.restart() + + expect(createContainer).toHaveBeenCalledTimes(2) + expect(exposures).toEqual([ + [8080, 9090], + [8080, 9090], + ]) + expect(stops[0]).toHaveBeenCalledOnce() + expect(instance.port).toBe(28_080) + + await instance.stop() + expect(stops[1]).toHaveBeenCalledOnce() +}) + +test('stops a container when endpoint mapping fails', async () => { + const stop = vi.fn(async () => {}) + const instance = Instance.testcontainer({ + name: 'service', + container: () => + container({ + getHost: () => '127.0.0.1', + getMappedPort(port) { + if (port === 9090) throw new Error('missing port') + return port + }, + stop, + }), + endpoints: { + default: { port: 8080, protocol: 'http' }, + metrics: { port: 9090, protocol: 'http' }, + }, + }) + + await expect(instance.start()).rejects.toThrow('missing port') + expect(stop).toHaveBeenCalledOnce() + expect(instance.status).toBe('idle') +}) + +test('cleans a retained container before retrying start', async () => { + const retainedStop = vi + .fn() + .mockRejectedValueOnce(new Error('stop failed')) + .mockResolvedValueOnce(undefined) + const replacementStop = vi.fn(async () => {}) + let starts = 0 + const createContainer = vi.fn(() => { + starts++ + if (starts === 1) + return container({ + getHost: () => '127.0.0.1', + getMappedPort(port) { + if (port === 9090) throw new Error('missing port') + return port + }, + stop: retainedStop, + }) + return container({ + getHost: () => '127.0.0.1', + getMappedPort: (port) => port, + stop: replacementStop, + }) + }) + const instance = Instance.testcontainer({ + name: 'service', + container: createContainer, + endpoints: { + default: { port: 8080, protocol: 'http' }, + metrics: { port: 9090, protocol: 'http' }, + }, + }) + + await expect(instance.start()).rejects.toThrow('missing port') + await instance.start() + + expect(createContainer).toHaveBeenCalledTimes(2) + expect(retainedStop).toHaveBeenCalledTimes(2) + expect(instance.status).toBe('started') + + await instance.stop() + expect(replacementStop).toHaveBeenCalledOnce() +}) + +test('retains the container when stopping fails', async () => { + const stop = vi + .fn() + .mockRejectedValueOnce(new Error('stop failed')) + .mockResolvedValueOnce(undefined) + const instance = Instance.testcontainer({ + name: 'service', + container: () => + container({ + getHost: () => '127.0.0.1', + getMappedPort: (port) => port, + stop, + }), + endpoints: { + default: { port: 8080, protocol: 'http' }, + }, + }) + + await instance.start() + await expect(instance.stop()).rejects.toThrow('stop failed') + expect(instance.status).toBe('started') + + await instance.stop() + expect(stop).toHaveBeenCalledTimes(2) +})