diff --git a/.changeset/tidy-tempo-zone.md b/.changeset/tidy-tempo-zone.md new file mode 100644 index 0000000..f86091f --- /dev/null +++ b/.changeset/tidy-tempo-zone.md @@ -0,0 +1,5 @@ +--- +'prool': patch +--- + +Fixed `tempoZone` argument forwarding and readiness, and enabled public-image integration coverage. diff --git a/src/instances/tempoZone.test.ts b/src/instances/tempoZone.test.ts index 49ba6d1..1a2f9b2 100644 --- a/src/instances/tempoZone.test.ts +++ b/src/instances/tempoZone.test.ts @@ -7,7 +7,7 @@ const redact = (args: string[]) => test('command: default', () => { expect(redact(command({ port: 9545 }))).toMatchInlineSnapshot( - `"dev --datadir /.prool/tempo-zone.9545 --http.addr 0.0.0.0 --http.port 9545 --l1.rpc-url ws://localhost:8546 --private-rpc.port 9548 -- --authrpc.port 9549 --ipcdisable"`, + `"dev --datadir /.prool/tempo-zone.9545 --http.addr 0.0.0.0 --http.port 9545 --l1.rpc-url ws://localhost:8546 --private-rpc.port 9548 -- --ipcdisable"`, ) }) @@ -23,7 +23,7 @@ test('command: behavior: l1 options', () => { }), ), ).toMatchInlineSnapshot( - `"dev --datadir /.prool/tempo-zone.9545 --http.addr 0.0.0.0 --http.port 9545 --l1.rpc-url ws://l1:8546 --l1.factory-address 0x00000000000000000000000000000000000fac70 --private-rpc.port 9548 -- --authrpc.port 9549 --ipcdisable"`, + `"dev --datadir /.prool/tempo-zone.9545 --http.addr 0.0.0.0 --http.port 9545 --l1.rpc-url ws://l1:8546 --l1.factory-address 0x00000000000000000000000000000000000fac70 --private-rpc.port 9548 -- --ipcdisable"`, ) }) @@ -39,7 +39,7 @@ test('command: behavior: dev options', () => { }), ), ).toMatchInlineSnapshot( - `"dev --datadir /.prool/tempo-zone.9545 --http.addr 0.0.0.0 --http.port 9545 --l1.rpc-url ws://localhost:8546 --private-rpc.port 9548 --dev.key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 --dev.token 0x20c0000000000000000000000000000000000001 -- --authrpc.port 9549 --ipcdisable"`, + `"dev --datadir /.prool/tempo-zone.9545 --http.addr 0.0.0.0 --http.port 9545 --l1.rpc-url ws://localhost:8546 --private-rpc.port 9548 --dev.key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 --dev.token 0x20c0000000000000000000000000000000000001 -- --ipcdisable"`, ) }) @@ -47,7 +47,7 @@ test('command: behavior: node args', () => { expect( redact(command({ nodeArgs: ['--full'], port: 9545 })), ).toMatchInlineSnapshot( - `"dev --datadir /.prool/tempo-zone.9545 --http.addr 0.0.0.0 --http.port 9545 --l1.rpc-url ws://localhost:8546 --private-rpc.port 9548 -- --authrpc.port 9549 --ipcdisable --full"`, + `"dev --datadir /.prool/tempo-zone.9545 --http.addr 0.0.0.0 --http.port 9545 --l1.rpc-url ws://localhost:8546 --private-rpc.port 9548 -- --ipcdisable --full"`, ) }) @@ -55,6 +55,21 @@ test('command: behavior: private rpc port override', () => { expect( redact(command({ port: 9545, privateRpc: { port: 1337 } })), ).toMatchInlineSnapshot( - `"dev --datadir /.prool/tempo-zone.9545 --http.addr 0.0.0.0 --http.port 9545 --l1.rpc-url ws://localhost:8546 --private-rpc.port 1337 -- --authrpc.port 9549 --ipcdisable"`, + `"dev --datadir /.prool/tempo-zone.9545 --http.addr 0.0.0.0 --http.port 9545 --l1.rpc-url ws://localhost:8546 --private-rpc.port 1337 -- --ipcdisable"`, + ) +}) + +test('command: behavior: instance options are not forwarded', () => { + expect( + redact( + command({ + binary: '/usr/local/bin/tempo-zone', + host: '127.0.0.1', + log: 'error', + port: 9545, + }), + ), + ).toMatchInlineSnapshot( + `"dev --datadir /.prool/tempo-zone.9545 --http.addr 0.0.0.0 --http.port 9545 --l1.rpc-url ws://localhost:8546 --private-rpc.port 9548 -- --ipcdisable"`, ) }) diff --git a/src/instances/tempoZone.ts b/src/instances/tempoZone.ts index 8e52805..47bef6f 100644 --- a/src/instances/tempoZone.ts +++ b/src/instances/tempoZone.ts @@ -5,9 +5,15 @@ import { deepAssign, toArgs } from '../internal/utils.js' import { execa } from '../processes/execa.js' // `tempo-zone dev` derives the WS RPC (port + 1) and P2P (port + 2) ports from -// `http.port`. Each instance occupies the compact block [port, port + 4]. +// `http.port`. Each instance occupies the compact block [port, port + 3]. export function command(parameters: tempoZone.Parameters): string[] { - const { nodeArgs, port, ...rest } = parameters + const { nodeArgs, port } = parameters + const options = { ...parameters } + delete options.binary + delete options.host + delete options.log + delete options.nodeArgs + delete options.port const datadir = path.join(os.tmpdir(), '.prool', `tempo-zone.${port}`) const defaultParameters = { @@ -24,17 +30,14 @@ export function command(parameters: tempoZone.Parameters): string[] { }, } - const args = deepAssign(defaultParameters, rest) + const args = deepAssign(defaultParameters, options) return [ 'dev', ...toArgs(args, { arraySeparator: null, }), - // Forwarded to `tempo-zone node`; keeps concurrent instances from colliding. '--', - '--authrpc.port', - String(port! + 4), '--ipcdisable', ...((nodeArgs as string[] | undefined) ?? []), ] @@ -44,7 +47,8 @@ export function command(parameters: tempoZone.Parameters): string[] { * Defines a Tempo Zone instance. * * Provisions a fresh zone against a Tempo dev L1 (`tempo-zone dev`) and runs - * the zone node. Requires a reachable Tempo dev L1 websocket RPC (`l1.rpcUrl`). + * the zone node. The L1 must mine canonical Tempo headers, and the dev key + * must hold pathUSD. * * @example * ```ts @@ -68,7 +72,8 @@ export const tempoZone = Instance.define( return log_ } })() - const RUST_LOG = log && typeof log !== 'boolean' ? log : '' + const RUST_LOG = + log && typeof log !== 'boolean' ? `${log},reth::cli=info` : '' const name = 'tempo-zone' const process = execa({ name }) @@ -84,16 +89,16 @@ export const tempoZone = Instance.define( name, port: args.port ?? 9545, async start({ port = args.port }, options) { + const httpPort = port ?? 9545 return await process.start( ($) => $({ env: { RUST_LOG, }, - })`${[binary, ...command({ ...args, port })]}`, + })`${[binary, ...command({ ...args, port: httpPort })]}`, { ...options, - // Resolve when the private zone RPC is listening (last server to start). resolver({ process, reject, resolve }) { let stderr = '' process.stdout.on('data', (data) => { @@ -108,7 +113,6 @@ export const tempoZone = Instance.define( if (log) console.error(message) stderr += message }) - // Provisioning failures (e.g. unreachable L1) exit non-zero before the RPC starts. process.once('exit', (code) => { if (code) reject(stderr || `exited with code ${code}`) }) @@ -166,6 +170,7 @@ export declare namespace tempoZone { factoryAddress?: string | undefined /** * Tempo L1 WebSocket RPC URL. + * Anvil requires Foundry 1.8 or newer. * @default "ws://localhost:8546" */ rpcUrl?: string | undefined diff --git a/src/testcontainers/Instance.test.ts b/src/testcontainers/Instance.test.ts index d5b6a98..577f712 100644 --- a/src/testcontainers/Instance.test.ts +++ b/src/testcontainers/Instance.test.ts @@ -126,27 +126,22 @@ test('behavior: faucet funds address', { timeout: 120_000 }, async () => { expect(balance).toBeGreaterThan(0n) }) -// Requires a `tempo-zone` image with the `dev` command and GHCR access -// (the package is private), e.g. `VITE_TEMPO_ZONE_IMAGE=ghcr.io/tempoxyz/tempo-zone:latest`. -const tempoZoneImage = process.env['VITE_TEMPO_ZONE_IMAGE'] const zonePort = await getPort() -describe.runIf(tempoZoneImage)('tempoZone', () => { +describe('tempoZone', () => { const defineZoneInstance = ( parameters: Instance.tempoZone.Parameters = {}, ) => { const instance = Instance.tempoZone({ - image: tempoZoneImage, port: zonePort, - startupTimeout: 120_000, ...parameters, }) instances.push(instance) return instance } - test('default', { timeout: 300_000 }, async () => { - const instance = defineZoneInstance() + test('default image with quiet logs', { timeout: 600_000 }, async () => { + const instance = defineZoneInstance({ log: 'warn' }) await instance.start() expect(instance.status).toEqual('started') @@ -167,7 +162,13 @@ describe.runIf(tempoZoneImage)('tempoZone', () => { const { result: blockNumber } = await rpc('eth_blockNumber', []) expect(blockNumber).toBeDefined() - // Private RPC (auth-gated `eth_*` + `zone_*`) is exposed; reachability only (no token). + const { result: pathUsdCode } = await rpc('eth_getCode', [ + '0x20c0000000000000000000000000000000000000', + 'latest', + ]) + expect(pathUsdCode).toMatch(/^0x[0-9a-f]{2,}$/) + + // Private RPC rejects unauthenticated requests after becoming reachable. const { privateRpc } = instance._internal expect(privateRpc).toBeDefined() const response = await fetch( @@ -183,7 +184,7 @@ describe.runIf(tempoZoneImage)('tempoZone', () => { method: 'POST', }, ) - expect(response.status).toBeDefined() + expect(response.status).toBe(401) await instance.stop() expect(instance.status).toEqual('stopped') diff --git a/src/testcontainers/Instance.ts b/src/testcontainers/Instance.ts index e482dcf..de68adb 100644 --- a/src/testcontainers/Instance.ts +++ b/src/testcontainers/Instance.ts @@ -138,13 +138,14 @@ export declare namespace tempo { } } +const tempoZoneStartupTimeout = 120_000 + /** * Defines a Tempo Zone instance. * - * Starts a Tempo dev L1 container and a zone container (`tempo-zone dev`) on a - * shared network, provisioning a fresh zone against the L1. Pass `l1.rpcUrl` - * to attach to an existing L1 instead (the URL must be reachable from inside - * the container, e.g. `ws://host.docker.internal:8546`). + * Starts a Tempo dev L1 and `tempo-zone dev` on a shared network. An external + * `l1.rpcUrl` must be container-reachable, mine canonical headers, and fund + * the configured dev key with pathUSD. * * @example * ```ts @@ -173,6 +174,9 @@ export const tempoZone = Instance.define( } })() const RUST_LOG = log && typeof log !== 'boolean' ? log : '' + const L1_RUST_LOG = RUST_LOG + ? `${RUST_LOG},reth_node_events=info` + : RUST_LOG // L1 ports inside the container (only reachable over the shared network). const l1HttpPort = 8545 @@ -222,13 +226,21 @@ export const tempoZone = Instance.define( privateRpcPort = (args['privateRpc'] as { port?: number } | undefined)?.port ?? containerPort + 3 - const timeout = ContainerOptions.resolveStartupTimeout(startupTimeout) + const timeout = startupTimeout ?? tempoZoneStartupTimeout + const includeL1Log = (message: string) => { + if (log !== 'warn' && log !== 'error') return true + return !/INFO.*reth_node_events::node/.test(message) + } const logConsumer = - (reject: (error: Error) => void) => + ( + reject: (error: Error) => void, + include: (message: string) => boolean = () => true, + ) => (stream: NodeJS.ReadableStream) => { stream.on('data', (data) => { const message = data.toString() + if (!include(message)) return emitter.emit('message', message) emitter.emit('stdout', message) if (log) console.log(message) @@ -252,12 +264,12 @@ export const tempoZone = Instance.define( l1Parameters?.image ?? 'ghcr.io/tempoxyz/tempo:latest', ) .withPullPolicy(PullPolicy.alwaysPull()) - .withPlatform('linux/x86_64') + .withPlatform('linux/amd64') .withNetwork(network) .withNetworkAliases('l1') .withExposedPorts(l1HttpPort, l1WsPort) .withName(`${containerName}.l1`) - .withEnvironment({ RUST_LOG }) + .withEnvironment({ RUST_LOG: L1_RUST_LOG }) .withCommand( command({ port: l1HttpPort, @@ -273,7 +285,7 @@ export const tempoZone = Instance.define( .withLogConsumer( logConsumer(() => { // L1 shutdown surfaces via the zone container failing to start. - }), + }, includeL1Log), ) .withStartupTimeout(timeout) .start() @@ -284,7 +296,8 @@ export const tempoZone = Instance.define( let c = new GenericContainer(image) .withPullPolicy(PullPolicy.alwaysPull()) - .withPlatform('linux/x86_64') + // The public image currently publishes only linux/amd64. + .withPlatform('linux/amd64') .withExposedPorts(containerPort, privateRpcPort) .withExtraHosts([ { host: 'host.docker.internal', ipAddress: 'host-gateway' }, @@ -303,9 +316,12 @@ export const tempoZone = Instance.define( port: containerPort, }), ) - // Fires after the public RPC; last server to start. .withWaitStrategy( - Wait.forLogMessage('Private zone RPC server started'), + Wait.forHttp('/', privateRpcPort, { + abortOnContainerExit: true, + }) + .withMethod('POST') + .forStatusCode(401), ) .withLogConsumer(logConsumer(promise.reject)) .withStartupTimeout(timeout) @@ -337,15 +353,21 @@ export const tempoZone = Instance.define( export declare namespace tempoZone { export type Parameters = Omit & - ContainerOptions.Parameters & { + Omit & { /** * Name of the container. */ containerName?: string | undefined /** * Docker image to use for the zone node. + * @default "ghcr.io/tempoxyz/tempo-zone:latest" */ image?: string | undefined + /** + * Startup timeout for each L1 and zone readiness check, in milliseconds. + * @default 120_000 + */ + startupTimeout?: number | undefined /** * Host the server will listen on. */ @@ -363,6 +385,7 @@ export declare namespace tempoZone { * Existing Tempo L1 WebSocket RPC URL, reachable from inside the * container (e.g. `ws://host.docker.internal:8546`). Starts a * dev L1 container when omitted. + * Anvil requires Foundry 1.8 or newer. */ rpcUrl?: string | undefined })