prool 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import getPort from 'get-port'
2
2
  import { Instance } from 'prool/testcontainers'
3
- import { afterEach, expect, test } from 'vitest'
3
+ import { afterEach, describe, expect, test } from 'vitest'
4
4
 
5
5
  const instances: Instance.Instance[] = []
6
6
  const slowTestTimeout = 30_000
@@ -125,3 +125,67 @@ test('behavior: faucet funds address', { timeout: 120_000 }, async () => {
125
125
  }
126
126
  expect(balance).toBeGreaterThan(0n)
127
127
  })
128
+
129
+ // Requires a `tempo-zone` image with the `dev` command and GHCR access
130
+ // (the package is private), e.g. `VITE_TEMPO_ZONE_IMAGE=ghcr.io/tempoxyz/tempo-zone:latest`.
131
+ const tempoZoneImage = process.env['VITE_TEMPO_ZONE_IMAGE']
132
+ const zonePort = await getPort()
133
+
134
+ describe.runIf(tempoZoneImage)('tempoZone', () => {
135
+ const defineZoneInstance = (
136
+ parameters: Instance.tempoZone.Parameters = {},
137
+ ) => {
138
+ const instance = Instance.tempoZone({
139
+ image: tempoZoneImage,
140
+ port: zonePort,
141
+ startupTimeout: 120_000,
142
+ ...parameters,
143
+ })
144
+ instances.push(instance)
145
+ return instance
146
+ }
147
+
148
+ test('default', { timeout: 300_000 }, async () => {
149
+ const instance = defineZoneInstance()
150
+
151
+ await instance.start()
152
+ expect(instance.status).toEqual('started')
153
+
154
+ const rpc = async (method: string, params: unknown[]) => {
155
+ const response = await fetch(`http://${instance.host}:${instance.port}`, {
156
+ body: JSON.stringify({ id: 0, jsonrpc: '2.0', method, params }),
157
+ headers: { 'Content-Type': 'application/json' },
158
+ method: 'POST',
159
+ })
160
+ return await response.json()
161
+ }
162
+
163
+ // Zone chain IDs are derived: ZONE_CHAIN_ID_BASE + (zone_id % range).
164
+ const { result: chainId } = await rpc('eth_chainId', [])
165
+ expect(BigInt(chainId)).toBeGreaterThanOrEqual(421_700_000n)
166
+
167
+ const { result: blockNumber } = await rpc('eth_blockNumber', [])
168
+ expect(blockNumber).toBeDefined()
169
+
170
+ // Private RPC (auth-gated `eth_*` + `zone_*`) is exposed; reachability only (no token).
171
+ const { privateRpc } = instance._internal
172
+ expect(privateRpc).toBeDefined()
173
+ const response = await fetch(
174
+ `http://${privateRpc!.host}:${privateRpc!.port}`,
175
+ {
176
+ body: JSON.stringify({
177
+ id: 0,
178
+ jsonrpc: '2.0',
179
+ method: 'zone_getZoneInfo',
180
+ params: [],
181
+ }),
182
+ headers: { 'Content-Type': 'application/json' },
183
+ method: 'POST',
184
+ },
185
+ )
186
+ expect(response.status).toBeDefined()
187
+
188
+ await instance.stop()
189
+ expect(instance.status).toEqual('stopped')
190
+ })
191
+ })
@@ -1,11 +1,17 @@
1
1
  import {
2
2
  GenericContainer,
3
+ Network,
3
4
  PullPolicy,
5
+ type StartedNetwork,
4
6
  type StartedTestContainer,
5
7
  Wait,
6
8
  } from 'testcontainers'
7
9
  import * as Instance from '../Instance.js'
8
10
  import { command, type tempo as core_tempo } from '../instances/tempo.js'
11
+ import {
12
+ type tempoZone as core_tempoZone,
13
+ command as zoneCommand,
14
+ } from '../instances/tempoZone.js'
9
15
  import * as ContainerOptions from './containerOptions.js'
10
16
 
11
17
  export type { Instance, InstanceOptions } from '../Instance.js'
@@ -131,3 +137,239 @@ export declare namespace tempo {
131
137
  port?: number | undefined
132
138
  }
133
139
  }
140
+
141
+ /**
142
+ * Defines a Tempo Zone instance.
143
+ *
144
+ * Starts a Tempo dev L1 container and a zone container (`tempo-zone dev`) on a
145
+ * shared network, provisioning a fresh zone against the L1. Pass `l1.rpcUrl`
146
+ * to attach to an existing L1 instead (the URL must be reachable from inside
147
+ * the container, e.g. `ws://host.docker.internal:8546`).
148
+ *
149
+ * @example
150
+ * ```ts
151
+ * const instance = Instance.tempoZone({ port: 9545 })
152
+ * await instance.start()
153
+ * // ...
154
+ * await instance.stop()
155
+ * ```
156
+ */
157
+ export const tempoZone = Instance.define(
158
+ (parameters?: tempoZone.Parameters) => {
159
+ const {
160
+ containerName = `tempo-zone.${crypto.randomUUID()}`,
161
+ image = 'ghcr.io/tempoxyz/tempo-zone:latest',
162
+ l1: l1Parameters,
163
+ log: log_,
164
+ startupTimeout,
165
+ ...args
166
+ } = parameters || {}
167
+
168
+ const log = (() => {
169
+ try {
170
+ return JSON.parse(log_ as string)
171
+ } catch {
172
+ return log_
173
+ }
174
+ })()
175
+ const RUST_LOG = log && typeof log !== 'boolean' ? log : ''
176
+
177
+ // L1 ports inside the container (only reachable over the shared network).
178
+ const l1HttpPort = 8545
179
+ const l1WsPort = 8546
180
+
181
+ const name = 'tempo-zone'
182
+ let container: StartedTestContainer | undefined
183
+ let l1Container: StartedTestContainer | undefined
184
+ let network: StartedNetwork | undefined
185
+ let privateRpcPort: number | undefined
186
+
187
+ async function teardown() {
188
+ if (container) await container.stop().catch(() => {})
189
+ if (l1Container) await l1Container.stop().catch(() => {})
190
+ if (network) await network.stop().catch(() => {})
191
+ container = undefined
192
+ l1Container = undefined
193
+ network = undefined
194
+ privateRpcPort = undefined
195
+ }
196
+
197
+ return {
198
+ _internal: {
199
+ args,
200
+ get l1() {
201
+ if (!l1Container) return undefined
202
+ return {
203
+ host: l1Container.getHost(),
204
+ port: l1Container.getMappedPort(l1HttpPort),
205
+ wsPort: l1Container.getMappedPort(l1WsPort),
206
+ }
207
+ },
208
+ get privateRpc() {
209
+ if (!container || !privateRpcPort) return undefined
210
+ return {
211
+ host: container.getHost(),
212
+ port: container.getMappedPort(privateRpcPort),
213
+ }
214
+ },
215
+ },
216
+ host: args.host ?? 'localhost',
217
+ name,
218
+ port: args.port ?? 9545,
219
+ async start({ port = args.port }, { emitter, setEndpoint }) {
220
+ const containerPort = port ?? 9545
221
+ // Mirrors the `zoneCommand` default; serves authenticated `eth_*` + `zone_*`.
222
+ privateRpcPort =
223
+ (args['privateRpc'] as { port?: number } | undefined)?.port ??
224
+ containerPort + 3
225
+ const timeout = ContainerOptions.resolveStartupTimeout(startupTimeout)
226
+
227
+ const logConsumer =
228
+ (reject: (error: Error) => void) =>
229
+ (stream: NodeJS.ReadableStream) => {
230
+ stream.on('data', (data) => {
231
+ const message = data.toString()
232
+ emitter.emit('message', message)
233
+ emitter.emit('stdout', message)
234
+ if (log) console.log(message)
235
+ if (message.includes('shutting down'))
236
+ reject(new Error(`Failed to start: ${message}`))
237
+ })
238
+ stream.on('error', (error) => {
239
+ if (log) console.error(error.message)
240
+ emitter.emit('message', error.message)
241
+ emitter.emit('stderr', error.message)
242
+ reject(new Error(`Failed to start: ${error.message}`))
243
+ })
244
+ }
245
+
246
+ try {
247
+ // Start a dev L1 on a shared network unless attaching to an existing one.
248
+ let l1RpcUrl = l1Parameters?.rpcUrl
249
+ if (!l1RpcUrl) {
250
+ network = await new Network().start()
251
+ l1Container = await new GenericContainer(
252
+ l1Parameters?.image ?? 'ghcr.io/tempoxyz/tempo:latest',
253
+ )
254
+ .withPullPolicy(PullPolicy.alwaysPull())
255
+ .withPlatform('linux/x86_64')
256
+ .withNetwork(network)
257
+ .withNetworkAliases('l1')
258
+ .withExposedPorts(l1HttpPort, l1WsPort)
259
+ .withName(`${containerName}.l1`)
260
+ .withEnvironment({ RUST_LOG })
261
+ .withCommand(
262
+ command({
263
+ port: l1HttpPort,
264
+ // The zone anchors to the L1 over WebSocket.
265
+ ws: [true, { addr: '0.0.0.0', api: 'all', port: l1WsPort }],
266
+ }),
267
+ )
268
+ .withWaitStrategy(
269
+ Wait.forLogMessage(
270
+ /Received (block|new payload) from consensus engine/,
271
+ ),
272
+ )
273
+ .withLogConsumer(
274
+ logConsumer(() => {
275
+ // L1 shutdown surfaces via the zone container failing to start.
276
+ }),
277
+ )
278
+ .withStartupTimeout(timeout)
279
+ .start()
280
+ l1RpcUrl = `ws://l1:${l1WsPort}`
281
+ }
282
+
283
+ const promise = Promise.withResolvers<void>()
284
+
285
+ let c = new GenericContainer(image)
286
+ .withPullPolicy(PullPolicy.alwaysPull())
287
+ .withPlatform('linux/x86_64')
288
+ .withExposedPorts(containerPort, privateRpcPort)
289
+ .withExtraHosts([
290
+ { host: 'host.docker.internal', ipAddress: 'host-gateway' },
291
+ ])
292
+ .withName(containerName)
293
+ .withEnvironment({ RUST_LOG })
294
+ .withCommand(
295
+ zoneCommand({
296
+ ...args,
297
+ l1: {
298
+ ...(l1Parameters?.factoryAddress
299
+ ? { factoryAddress: l1Parameters.factoryAddress }
300
+ : {}),
301
+ rpcUrl: l1RpcUrl,
302
+ },
303
+ port: containerPort,
304
+ }),
305
+ )
306
+ // Fires after the public RPC; last server to start.
307
+ .withWaitStrategy(
308
+ Wait.forLogMessage('Private zone RPC server started'),
309
+ )
310
+ .withLogConsumer(logConsumer(promise.reject))
311
+ .withStartupTimeout(timeout)
312
+ if (network) c = c.withNetwork(network)
313
+
314
+ c.start()
315
+ .then((started) => {
316
+ container = started
317
+ setEndpoint?.({
318
+ host: started.getHost(),
319
+ port: started.getMappedPort(containerPort),
320
+ })
321
+ promise.resolve()
322
+ })
323
+ .catch(promise.reject)
324
+
325
+ await promise.promise
326
+ } catch (error) {
327
+ await teardown()
328
+ throw error
329
+ }
330
+ },
331
+ async stop() {
332
+ await teardown()
333
+ },
334
+ }
335
+ },
336
+ )
337
+
338
+ export declare namespace tempoZone {
339
+ export type Parameters = Omit<core_tempoZone.Parameters, 'binary' | 'l1'> &
340
+ ContainerOptions.Parameters & {
341
+ /**
342
+ * Name of the container.
343
+ */
344
+ containerName?: string | undefined
345
+ /**
346
+ * Docker image to use for the zone node.
347
+ */
348
+ image?: string | undefined
349
+ /**
350
+ * Host the server will listen on.
351
+ */
352
+ host?: string | undefined
353
+ /**
354
+ * Tempo L1 options.
355
+ */
356
+ l1?:
357
+ | (NonNullable<core_tempoZone.Parameters['l1']> & {
358
+ /**
359
+ * Docker image to use for the L1 node (when no `rpcUrl` is provided).
360
+ */
361
+ image?: string | undefined
362
+ /**
363
+ * Existing Tempo L1 WebSocket RPC URL, reachable from inside the
364
+ * container (e.g. `ws://host.docker.internal:8546`). Starts a
365
+ * dev L1 container when omitted.
366
+ */
367
+ rpcUrl?: string | undefined
368
+ })
369
+ | undefined
370
+ /**
371
+ * Port the server will listen on.
372
+ */
373
+ port?: number | undefined
374
+ }
375
+ }