From a9f112af1491b31be9fca21131db95eb5cd0feb8 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:42:48 -0400 Subject: [PATCH 1/4] feat: add bootstrapped Tempo localnet instance --- .changeset/slow-tools-smile.md | 5 + .github/workflows/verify.yml | 15 +++ README.md | 23 +++++ src/testcontainers/Instance.ts | 116 +++++++++++++++++++++++ src/testcontainers/tempoLocalnet.test.ts | 80 ++++++++++++++++ 5 files changed, 239 insertions(+) create mode 100644 .changeset/slow-tools-smile.md create mode 100644 src/testcontainers/tempoLocalnet.test.ts diff --git a/.changeset/slow-tools-smile.md b/.changeset/slow-tools-smile.md new file mode 100644 index 0000000..026ec5d --- /dev/null +++ b/.changeset/slow-tools-smile.md @@ -0,0 +1,5 @@ +--- +'prool': minor +--- + +Added a Testcontainers-backed `Instance.tempoLocalnet` for bootstrapped Tempo development networks. diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 9ca06fb..5b8e0f6 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -61,3 +61,18 @@ jobs: run: pnpm test --bail=1 env: VITE_FORK_URL: ${{ secrets.VITE_FORK_URL }} + + tempo-localnet: + name: Tempo localnet + runs-on: ubuntu-latest + steps: + - name: Clone repository + uses: actions/checkout@v4 + + - name: Install dependencies + uses: ./.github/actions/install-dependencies + + - name: Run Tempo localnet tests + run: pnpm test --run src/testcontainers/tempoLocalnet.test.ts + env: + TEST_TEMPO_LOCALNET: "true" diff --git a/README.md b/README.md index eea6471..113a4ff 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,29 @@ await server.start() // "http://localhost:8545/n" ``` +#### Bootstrapped localnet container + +Use the Tempo localnet image when tests need deterministic accounts, the +canonical TIP-20 faucet, Fee AMM pools, and Stablecoin DEX liquidity. The +instance waits for the image health check before accepting requests. + +```ts +import { Server } from 'prool' +import { Instance } from 'prool/testcontainers' + +const server = Server.create({ + instance: Instance.tempoLocalnet({ + blockTime: '200ms', + image: 'ghcr.io/tempoxyz/tempo-localnet:latest', + }), +}) + +await server.start() +``` + +Pin `image` to a release tag or digest in CI. Pass `bare: true` for tests that +need an unbootstrapped development node. + ### Alto (Bundler Node) #### Requirements diff --git a/src/testcontainers/Instance.ts b/src/testcontainers/Instance.ts index fa9b5cf..6a6cb01 100644 --- a/src/testcontainers/Instance.ts +++ b/src/testcontainers/Instance.ts @@ -430,6 +430,122 @@ export declare namespace tempo { } } +const tempoLocalnetPort = 8545 +const tempoLocalnetStartupTimeout = 150_000 + +/** Returns arguments accepted by the `tempo-localnet` container entrypoint. */ +function tempoLocalnetCommand( + parameters: Pick = {}, +): string[] { + const args: string[] = [] + if (parameters.bare) args.push('--bare') + if (parameters.blockTime) args.push('--block-time', parameters.blockTime) + return args +} + +/** + * Defines a bootstrapped Tempo localnet instance. + * + * @example + * ```ts + * const instance = Instance.tempoLocalnet({ blockTime: '200ms' }) + * await instance.start() + * // ... + * await instance.stop() + * ``` + */ +export const tempoLocalnet = Instance.define( + (parameters?: tempoLocalnet.Parameters) => { + const { + containerName = `tempo-localnet.${crypto.randomUUID()}`, + image = 'ghcr.io/tempoxyz/tempo-localnet:latest', + log: log_, + startupTimeout = tempoLocalnetStartupTimeout, + ...args + } = parameters || {} + + const log = (() => { + try { + return JSON.parse(log_ as string) + } catch { + return log_ + } + })() + const RUST_LOG = log && typeof log !== 'boolean' ? log : '' + + const name = 'tempo-localnet' + let container: StartedTestContainer | undefined + const command = tempoLocalnetCommand(args) + + return { + _internal: { + args, + command, + }, + host: 'localhost', + name, + port: tempoLocalnetPort, + async start(_, { emitter, setEndpoint }) { + let definition = new GenericContainer(image) + .withPullPolicy(PullPolicy.alwaysPull()) + .withExposedPorts(tempoLocalnetPort) + .withName(containerName) + .withEnvironment({ RUST_LOG }) + .withWaitStrategy(Wait.forHealthCheck()) + .withLogConsumer((stream) => { + stream.on('data', (data) => { + const message = data.toString() + emitter.emit('message', message) + emitter.emit('stdout', message) + if (log) console.log(message) + }) + stream.on('error', (error) => { + if (log) console.error(error.message) + emitter.emit('message', error.message) + emitter.emit('stderr', error.message) + }) + }) + .withStartupTimeout(startupTimeout) + + if (command.length > 0) definition = definition.withCommand(command) + + const started = await definition.start() + container = started + try { + setEndpoint?.({ + host: started.getHost(), + port: started.getMappedPort(tempoLocalnetPort), + }) + } catch (error) { + const [result] = await Promise.allSettled([started.stop()]) + if (result?.status === 'fulfilled') container = undefined + throw error + } + }, + async stop() { + if (!container) return + await container.stop() + container = undefined + }, + } + }, +) + +export declare namespace tempoLocalnet { + export type Parameters = ContainerOptions.Parameters & { + /** Start the node without faucet or liquidity bootstrap. */ + bare?: boolean | undefined + /** Interval between blocks, up to five seconds. */ + blockTime?: string | undefined + /** Name assigned to the container. */ + containerName?: string | undefined + /** Docker image tag or digest. */ + image?: string | undefined + /** Rust log level configuration passed as `RUST_LOG`. */ + log?: core_tempo.Parameters['log'] + } +} + const tempoZoneStartupTimeout = 120_000 /** diff --git a/src/testcontainers/tempoLocalnet.test.ts b/src/testcontainers/tempoLocalnet.test.ts new file mode 100644 index 0000000..a6ff13e --- /dev/null +++ b/src/testcontainers/tempoLocalnet.test.ts @@ -0,0 +1,80 @@ +import { Instance } from 'prool/testcontainers' +import { describe, expect, expectTypeOf, test } from 'vitest' + +describe('tempoLocalnetCommand', () => { + test.each([ + { expected: [], parameters: {} }, + { expected: ['--bare'], parameters: { bare: true } }, + { + expected: ['--block-time', '200ms'], + parameters: { blockTime: '200ms' }, + }, + { + expected: ['--bare', '--block-time', '1s'], + parameters: { bare: true, blockTime: '1s' }, + }, + ])('$expected', ({ expected, parameters }) => { + expect(Instance.tempoLocalnet(parameters)._internal.command).toEqual( + expected, + ) + }) +}) + +test('defines the bootstrapped localnet endpoint', () => { + const instance = Instance.tempoLocalnet({ + bare: true, + blockTime: '200ms', + }) + + expect(instance.name).toBe('tempo-localnet') + expect(instance.host).toBe('localhost') + expect(instance.port).toBe(8545) + expect(instance.url).toBe('http://localhost:8545') + expect(instance._internal.args).toEqual({ + bare: true, + blockTime: '200ms', + }) + expect(instance._internal.command).toEqual([ + '--bare', + '--block-time', + '200ms', + ]) + expectTypeOf(instance).toMatchTypeOf() +}) + +test.skipIf(process.env['TEST_TEMPO_LOCALNET'] !== 'true')( + 'starts a healthy bootstrapped localnet', + { timeout: 240_000 }, + async () => { + const instance = Instance.tempoLocalnet({ + blockTime: '1ms', + image: + 'ghcr.io/tempoxyz/tempo-localnet@sha256:86f199f161be85d3610d990e5bc2653ece29de3ee9c91e8c8e768cf2b8c05c3b', + }) + + try { + await instance.start() + const rpc = async (method: string, params: unknown[] = []) => { + const response = await fetch(instance.url, { + body: JSON.stringify({ id: 1, jsonrpc: '2.0', method, params }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }) + return (await response.json()) as { result: unknown } + } + + expect(await rpc('eth_chainId')).toMatchObject({ result: '0x539' }) + const funded = await rpc('tempo_fundAddress', [ + '0x000000000000000000000000000000000000beef', + ]) + expect(funded.result).toEqual([ + expect.stringMatching(/^0x[0-9a-f]{64}$/), + expect.stringMatching(/^0x[0-9a-f]{64}$/), + expect.stringMatching(/^0x[0-9a-f]{64}$/), + expect.stringMatching(/^0x[0-9a-f]{64}$/), + ]) + } finally { + await instance.stop().catch(() => {}) + } + }, +) From 967d154e7688605199f4d669c07afb74f41e5c4e Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:51:03 -0400 Subject: [PATCH 2/4] docs: use reliable localnet block interval --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 113a4ff..14b228f 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ import { Instance } from 'prool/testcontainers' const server = Server.create({ instance: Instance.tempoLocalnet({ - blockTime: '200ms', + blockTime: '1ms', image: 'ghcr.io/tempoxyz/tempo-localnet:latest', }), }) From 91dba3951fad484211a781a92187f5347b3fe351 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:03:02 -0400 Subject: [PATCH 3/4] ci: skip fork-dependent Alto tests without RPC --- .github/workflows/verify.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 5b8e0f6..9efe202 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -58,7 +58,14 @@ jobs: run: echo "$HOME/.local/bin" >> $GITHUB_PATH - name: Run tests - run: pnpm test --bail=1 + run: | + if [[ -n "$VITE_FORK_URL" ]]; then + pnpm test --bail=1 + else + pnpm test --bail=1 \ + --exclude src/instances/alto.test.ts \ + --testNamePattern="^(?!.*instance: alto)(?!.*instance: 'alto')" + fi env: VITE_FORK_URL: ${{ secrets.VITE_FORK_URL }} From e4dd30eb8ce3f865a3b62c621fd19bade9c03861 Mon Sep 17 00:00:00 2001 From: tmm Date: Mon, 24 Aug 2026 17:37:42 -0400 Subject: [PATCH 4/4] chore: readme wrap --- README.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 14b228f..b82ba86 100644 --- a/README.md +++ b/README.md @@ -125,9 +125,7 @@ await server.start() #### Bootstrapped localnet container -Use the Tempo localnet image when tests need deterministic accounts, the -canonical TIP-20 faucet, Fee AMM pools, and Stablecoin DEX liquidity. The -instance waits for the image health check before accepting requests. +Use the Tempo localnet image when tests need deterministic accounts, the canonical TIP-20 faucet, Fee AMM pools, and Stablecoin DEX liquidity. The instance waits for the image health check before accepting requests. ```ts import { Server } from 'prool' @@ -143,8 +141,7 @@ const server = Server.create({ await server.start() ``` -Pin `image` to a release tag or digest in CI. Pass `bare: true` for tests that -need an unbootstrapped development node. +Pin `image` to a release tag or digest in CI. Pass `bare: true` for tests that need an unbootstrapped development node. ### Alto (Bundler Node)