Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-tempo-zone.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'prool': patch
---

Fixed `tempoZone` argument forwarding and readiness, and enabled public-image integration coverage.
25 changes: 20 additions & 5 deletions src/instances/tempoZone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const redact = (args: string[]) =>

test('command: default', () => {
expect(redact(command({ port: 9545 }))).toMatchInlineSnapshot(
`"dev --datadir <tmpdir>/.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 <tmpdir>/.prool/tempo-zone.9545 --http.addr 0.0.0.0 --http.port 9545 --l1.rpc-url ws://localhost:8546 --private-rpc.port 9548 -- --ipcdisable"`,
)
})

Expand All @@ -23,7 +23,7 @@ test('command: behavior: l1 options', () => {
}),
),
).toMatchInlineSnapshot(
`"dev --datadir <tmpdir>/.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 <tmpdir>/.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"`,
)
})

Expand All @@ -39,22 +39,37 @@ test('command: behavior: dev options', () => {
}),
),
).toMatchInlineSnapshot(
`"dev --datadir <tmpdir>/.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 <tmpdir>/.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"`,
)
})

test('command: behavior: node args', () => {
expect(
redact(command({ nodeArgs: ['--full'], port: 9545 })),
).toMatchInlineSnapshot(
`"dev --datadir <tmpdir>/.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 <tmpdir>/.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"`,
)
})

test('command: behavior: private rpc port override', () => {
expect(
redact(command({ port: 9545, privateRpc: { port: 1337 } })),
).toMatchInlineSnapshot(
`"dev --datadir <tmpdir>/.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 <tmpdir>/.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 <tmpdir>/.prool/tempo-zone.9545 --http.addr 0.0.0.0 --http.port 9545 --l1.rpc-url ws://localhost:8546 --private-rpc.port 9548 -- --ipcdisable"`,
)
})
27 changes: 16 additions & 11 deletions src/instances/tempoZone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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) ?? []),
]
Expand All @@ -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
Expand All @@ -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 })
Expand All @@ -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) => {
Expand All @@ -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}`)
})
Expand Down Expand Up @@ -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
Expand Down
21 changes: 11 additions & 10 deletions src/testcontainers/Instance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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(
Expand All @@ -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')
Expand Down
49 changes: 36 additions & 13 deletions src/testcontainers/Instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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()
Expand All @@ -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' },
Expand All @@ -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)
Expand Down Expand Up @@ -337,15 +353,21 @@ export const tempoZone = Instance.define(

export declare namespace tempoZone {
export type Parameters = Omit<core_tempoZone.Parameters, 'binary' | 'l1'> &
ContainerOptions.Parameters & {
Omit<ContainerOptions.Parameters, 'startupTimeout'> & {
/**
* 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.
*/
Expand All @@ -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
})
Expand Down
Loading