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/slow-tools-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'prool': minor
---

Added a Testcontainers-backed `Instance.tempoLocalnet` for bootstrapped Tempo development networks.
24 changes: 23 additions & 1 deletion .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,28 @@ 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')"
Comment on lines +65 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid loading forked suites when the RPC secret is absent

When VITE_FORK_URL is unavailable, this name filter skips the Alto test bodies but does not prevent their files' module-level setup from running: src/Pool.test.ts and src/Server.test.ts both start a forked Anvil before tests are filtered. Those suites therefore still contact the public fallback RPC and can fail or be rate-limited before the skipped tests are considered, so the no-secret CI path remains externally fork-dependent; make those setup hooks conditional or refactor/exclude the fork-dependent suites themselves.

Useful? React with 👍 / 👎.

fi
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could be nice to setup vitest projects so we can exclude (like alto) or run specifics suite like tempo localnet more easily. (can do some other time so we can get this out.)

env:
TEST_TEMPO_LOCALNET: "true"
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,26 @@ 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: '1ms',
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
Expand Down
116 changes: 116 additions & 0 deletions src/testcontainers/Instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<tempoLocalnet.Parameters, 'bare' | 'blockTime'> = {},
): 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

/**
Expand Down
80 changes: 80 additions & 0 deletions src/testcontainers/tempoLocalnet.test.ts
Original file line number Diff line number Diff line change
@@ -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<Instance.Instance>()
})

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(() => {})
}
},
)
Loading