prool 0.1.0 → 0.2.0

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.
@@ -0,0 +1,83 @@
1
+ import { GenericContainer, Wait, } from 'testcontainers';
2
+ import * as Instance from '../Instance.js';
3
+ import { command } from '../instances/tempo.js';
4
+ /**
5
+ * Defines a Tempo instance.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * const instance = Instance.tempo({ port: 8545 })
10
+ * await instance.start()
11
+ * // ...
12
+ * await instance.stop()
13
+ * ```
14
+ */
15
+ export const tempo = Instance.define((parameters) => {
16
+ const { containerName = `tempo.${crypto.randomUUID()}`, image = 'ghcr.io/tempoxyz/tempo:latest', log: log_, ...args } = parameters || {};
17
+ const log = (() => {
18
+ try {
19
+ return JSON.parse(log_);
20
+ }
21
+ catch {
22
+ return log_;
23
+ }
24
+ })();
25
+ const RUST_LOG = log && typeof log !== 'boolean' ? log : '';
26
+ const name = 'tempo';
27
+ let container;
28
+ return {
29
+ _internal: {
30
+ args,
31
+ },
32
+ host: args.host ?? 'localhost',
33
+ name,
34
+ port: args.port ?? 8545,
35
+ async start({ port = args.port }, { emitter }) {
36
+ const promise = Promise.withResolvers();
37
+ const c = new GenericContainer(image)
38
+ .withPlatform('linux/x86_64')
39
+ .withNetworkMode('host')
40
+ .withExtraHosts([
41
+ { host: 'host.docker.internal', ipAddress: 'host-gateway' },
42
+ { host: 'localhost', ipAddress: 'host-gateway' },
43
+ ])
44
+ .withName(containerName)
45
+ .withEnvironment({ RUST_LOG })
46
+ .withCommand(command({ ...args, port }))
47
+ .withWaitStrategy(Wait.forLogMessage(/RPC HTTP server started/))
48
+ .withLogConsumer((stream) => {
49
+ stream.on('data', (data) => {
50
+ const message = data.toString();
51
+ emitter.emit('message', message);
52
+ emitter.emit('stdout', message);
53
+ if (log)
54
+ console.log(message);
55
+ if (message.includes('shutting down'))
56
+ promise.reject(new Error(`Failed to start: ${message}`));
57
+ });
58
+ stream.on('error', (error) => {
59
+ if (log)
60
+ console.error(error.message);
61
+ emitter.emit('message', error.message);
62
+ emitter.emit('stderr', error.message);
63
+ promise.reject(new Error(`Failed to start: ${error.message}`));
64
+ });
65
+ })
66
+ .withStartupTimeout(10_000);
67
+ c.start()
68
+ .then((c) => {
69
+ container = c;
70
+ promise.resolve();
71
+ })
72
+ .catch(promise.reject);
73
+ return promise.promise;
74
+ },
75
+ async stop() {
76
+ if (!container)
77
+ return;
78
+ await container.stop();
79
+ container = undefined;
80
+ },
81
+ };
82
+ });
83
+ //# sourceMappingURL=Instance.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Instance.js","sourceRoot":"","sources":["../../src/testcontainers/Instance.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export * as Instance from './Instance.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/testcontainers/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,QAAQ,MAAM,eAAe,CAAA"}
@@ -0,0 +1,2 @@
1
+ export * as Instance from './Instance.js';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/testcontainers/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,QAAQ,MAAM,eAAe,CAAA"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "prool",
3
3
  "description": "HTTP testing instances for Ethereum",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "files": [
@@ -16,6 +16,11 @@
16
16
  "src": "./src/index.ts",
17
17
  "types": "./dist/index.d.ts",
18
18
  "default": "./dist/index.js"
19
+ },
20
+ "./testcontainers": {
21
+ "src": "./src/testcontainers/index.ts",
22
+ "types": "./dist/testcontainers/index.d.ts",
23
+ "default": "./dist/testcontainers/index.js"
19
24
  }
20
25
  },
21
26
  "dependencies": {
package/src/Instance.ts CHANGED
@@ -2,7 +2,7 @@ import { EventEmitter } from 'eventemitter3'
2
2
 
3
3
  export { alto } from './instances/alto.js'
4
4
  export { anvil } from './instances/anvil.js'
5
- export { tempo, tempoDocker } from './instances/tempo.js'
5
+ export { tempo } from './instances/tempo.js'
6
6
 
7
7
  type EventTypes = {
8
8
  exit: [code: number | null, signal: NodeJS.Signals | null]
@@ -1,48 +1,60 @@
1
1
  import * as os from 'node:os'
2
2
  import * as path from 'node:path'
3
- import {
4
- GenericContainer,
5
- type StartedTestContainer,
6
- Wait,
7
- } from 'testcontainers'
8
3
  import * as Instance from '../Instance.js'
4
+ import { deepAssign, toArgs } from '../internal/utils.js'
9
5
  import { execa } from '../processes/execa.js'
10
6
 
11
7
  export function command(parameters: tempo.Parameters): string[] {
12
- const { faucet, port } = parameters
13
- const dataDir = path.join(os.tmpdir(), '.prool', `tempo.${port}`)
8
+ const { blockMaxTransactions, blockTime, mnemonic, port, ...rest } =
9
+ parameters
10
+
11
+ const datadir = path.join(os.tmpdir(), '.prool', `tempo.${port}`)
12
+ const defaultParameters = {
13
+ authrpc: {
14
+ port: port! + 30,
15
+ },
16
+ datadir,
17
+ dev: [
18
+ true,
19
+ {
20
+ blockTime: blockTime ?? '50ms',
21
+ ...(blockMaxTransactions ? { blockMaxTransactions } : {}),
22
+ ...(mnemonic ? { mnemonic } : {}),
23
+ },
24
+ ],
25
+ engine: {
26
+ disablePrecompileCache: true,
27
+ legacyStateRoot: true,
28
+ },
29
+ faucet: {
30
+ address: [
31
+ '0x20c0000000000000000000000000000000000000',
32
+ '0x20c0000000000000000000000000000000000001',
33
+ '0x20c0000000000000000000000000000000000002',
34
+ '0x20c0000000000000000000000000000000000003',
35
+ ],
36
+ amount: '1000000000000',
37
+ enabled: true,
38
+ privateKey:
39
+ '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
40
+ },
41
+ http: {
42
+ addr: '0.0.0.0',
43
+ api: 'all',
44
+ corsdomain: '*',
45
+ port: port!,
46
+ },
47
+ port: port! + 10,
48
+ ws: {
49
+ port: port! + 20,
50
+ },
51
+ }
52
+
14
53
  return [
15
54
  'node',
16
- `--authrpc.port=${port! + 30}`,
17
- `--datadir=${dataDir}`,
18
- '--dev',
19
- `--dev.block-time=${parameters?.blockTime ?? '50ms'}`,
20
- '--engine.disable-precompile-cache',
21
- '--engine.legacy-state-root',
22
- '--faucet.address',
23
- ...(faucet?.addresses ?? [
24
- '0x20c0000000000000000000000000000000000000',
25
- '0x20c0000000000000000000000000000000000001',
26
- '0x20c0000000000000000000000000000000000002',
27
- '0x20c0000000000000000000000000000000000003',
28
- ]),
29
- `--faucet.amount=${faucet?.amount ?? '1000000000000'}`,
30
- '--faucet.enabled',
31
- `--faucet.private-key=${faucet?.privateKey ?? '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'}`,
32
- '--http',
33
- '--http.addr=0.0.0.0',
34
- '--http.api=all',
35
- '--http.corsdomain=*',
36
- `--http.port=${port!}`,
37
- `--port=${port! + 10}`,
38
- '--txpool.basefee-max-count=10000000000000',
39
- '--txpool.basefee-max-size=10000',
40
- '--txpool.max-account-slots=500000',
41
- '--txpool.pending-max-count=10000000000000',
42
- '--txpool.pending-max-size=10000',
43
- '--txpool.queued-max-count=10000000000000',
44
- '--txpool.queued-max-size=10000',
45
- `--ws.port=${port! + 20}`,
55
+ ...toArgs(deepAssign(defaultParameters, rest), {
56
+ arraySeparator: null,
57
+ }),
46
58
  ]
47
59
  }
48
60
 
@@ -89,7 +101,7 @@ export const tempo = Instance.define((parameters?: tempo.Parameters) => {
89
101
  env: {
90
102
  RUST_LOG,
91
103
  },
92
- })`${[binary, ...command({ ...parameters, port })]}`,
104
+ })`${[binary, ...command({ ...args, port })]}`,
93
105
  {
94
106
  ...options,
95
107
  // Resolve when the process is listening via "RPC HTTP server started" message.
@@ -124,6 +136,28 @@ export declare namespace tempo {
124
136
  * Interval between blocks.
125
137
  */
126
138
  blockTime?: string | undefined
139
+ /**
140
+ * How many transactions to mine per block
141
+ */
142
+ blockMaxTransactions?: number | undefined
143
+ /**
144
+ * Path to a configuration file.
145
+ */
146
+ config?: string | undefined
147
+ /**
148
+ * The chain this node is running.
149
+ * Possible values are either a built-in chain or the path to a chain specification file.
150
+ *
151
+ * Built-in chains:
152
+ * - testnet
153
+ *
154
+ * @default "testnet"
155
+ */
156
+ chain?: string | undefined
157
+ /**
158
+ * The path to the data dir for all reth files and subdirectories.
159
+ */
160
+ datadir?: string | undefined
127
161
  /**
128
162
  * Faucet options.
129
163
  */
@@ -161,118 +195,13 @@ export declare namespace tempo {
161
195
  */
162
196
  host?: string | undefined
163
197
  /**
164
- * Port the server will listen on.
165
- */
166
- port?: number | undefined
167
- }
168
- }
169
-
170
- /**
171
- * Defines a Tempo instance.
172
- *
173
- * @example
174
- * ```ts
175
- * const instance = Instance.tempoDocker({ port: 8545 })
176
- * await instance.start()
177
- * // ...
178
- * await instance.stop()
179
- * ```
180
- */
181
- export const tempoDocker = Instance.define(
182
- (parameters?: tempoDocker.Parameters) => {
183
- const {
184
- containerName = `tempo.${crypto.randomUUID()}`,
185
- image = 'ghcr.io/tempoxyz/tempo:latest',
186
- log: log_,
187
- ...args
188
- } = parameters || {}
189
-
190
- const log = (() => {
191
- try {
192
- return JSON.parse(log_ as string)
193
- } catch {
194
- return log_
195
- }
196
- })()
197
- const RUST_LOG = log && typeof log !== 'boolean' ? log : ''
198
-
199
- const name = 'tempo'
200
- let container: StartedTestContainer | undefined
201
-
202
- return {
203
- _internal: {
204
- args,
205
- },
206
- host: args.host ?? 'localhost',
207
- name,
208
- port: args.port ?? 8545,
209
- async start({ port = args.port }, { emitter }) {
210
- const promise = Promise.withResolvers<void>()
211
-
212
- const c = new GenericContainer(image)
213
- .withPlatform('linux/x86_64')
214
- .withNetworkMode('host')
215
- .withExtraHosts([
216
- { host: 'host.docker.internal', ipAddress: 'host-gateway' },
217
- { host: 'localhost', ipAddress: 'host-gateway' },
218
- ])
219
- .withName(containerName)
220
- .withEnvironment({ RUST_LOG })
221
- .withCommand(command({ ...parameters, port }))
222
- .withWaitStrategy(Wait.forLogMessage(/RPC HTTP server started/))
223
- .withLogConsumer((stream) => {
224
- stream.on('data', (data) => {
225
- const message = data.toString()
226
- emitter.emit('message', message)
227
- emitter.emit('stdout', message)
228
- if (log) console.log(message)
229
- if (message.includes('shutting down'))
230
- promise.reject(new Error(`Failed to start: ${message}`))
231
- })
232
- stream.on('error', (error) => {
233
- if (log) console.error(error.message)
234
- emitter.emit('message', error.message)
235
- emitter.emit('stderr', error.message)
236
- promise.reject(new Error(`Failed to start: ${error.message}`))
237
- })
238
- })
239
- .withStartupTimeout(10_000)
240
-
241
- c.start()
242
- .then((c) => {
243
- container = c
244
- promise.resolve()
245
- })
246
- .catch(promise.reject)
247
-
248
- return promise.promise
249
- },
250
- async stop() {
251
- if (!container) return
252
- await container.stop()
253
- container = undefined
254
- },
255
- }
256
- },
257
- )
258
-
259
- export declare namespace tempoDocker {
260
- export type Parameters = Omit<tempo.Parameters, 'binary'> & {
261
- /**
262
- * Name of the container.
198
+ * Derive dev accounts from a fixed mnemonic instead of random ones.
199
+ * @default "test test test test test test test test test test test junk"
263
200
  */
264
- containerName?: string | undefined
265
- /**
266
- * Docker image to use.
267
- */
268
- image?: string | undefined
269
- /**
270
- * Host the server will listen on.
271
- */
272
- host?: string | undefined
201
+ mnemonic?: string | undefined
273
202
  /**
274
203
  * Port the server will listen on.
275
204
  */
276
205
  port?: number | undefined
277
- }
206
+ } & Record<string, unknown>
278
207
  }
@@ -30,10 +30,12 @@ test.each([
30
30
  [[{ foo: 0 }], ['--foo', '0']],
31
31
  [[{ foo: 1 }], ['--foo', '1']],
32
32
  [[{ foo: 1n }], ['--foo', '1']],
33
+ [[{ foo: 1n }], ['--foo', '1']],
33
34
  [[{ foo: 'bar', baz: 1 }], ['--foo', 'bar', '--baz', '1']],
34
35
  [[{ fooBar: 'test' }], ['--foo-bar', 'test']],
35
36
  [[{ foo: ['bar', 'baz'] }], ['--foo', 'bar,baz']],
36
37
  [[{ foo: { barBaz: 'test' } }], ['--foo.bar-baz', 'test']],
38
+ [[{ foo: [true, { barBaz: 'test' }] }], ['--foo', '--foo.bar-baz', 'test']],
37
39
  [[{ foo: { barBaz: ['test', 'test2'] } }], ['--foo.bar-baz', 'test,test2']],
38
40
  [
39
41
  [{ fooBar: 'test' }, { casing: 'snake' }],
@@ -43,6 +45,18 @@ test.each([
43
45
  [{ foo: { barBaz: 'test' } }, { casing: 'snake' }],
44
46
  ['--foo.bar_baz', 'test'],
45
47
  ],
48
+ [
49
+ [{ foo: ['bar', 'baz'] }, { arraySeparator: null }],
50
+ ['--foo', 'bar', 'baz'],
51
+ ],
52
+ [
53
+ [{ foo: { bar: ['baz', 'qux'] } }, { arraySeparator: null }],
54
+ ['--foo.bar', 'baz', 'qux'],
55
+ ],
56
+ [
57
+ [{ foo: ['bar', 'baz'] }, { arraySeparator: null }],
58
+ ['--foo', 'bar', 'baz'],
59
+ ],
46
60
  ] as [Parameters<typeof toArgs>, string[]][])('toArgs(%o) -> %o', ([
47
61
  input,
48
62
  options,
@@ -50,23 +50,26 @@ export function stripColors(message: string) {
50
50
  * @returns The command line arguments.
51
51
  */
52
52
  export function toArgs(
53
- obj: {
54
- [key: string]:
55
- | Record<string, any>
56
- | string
57
- | readonly string[]
58
- | boolean
59
- | number
60
- | bigint
61
- | undefined
62
- },
63
- options: { casing: 'kebab' | 'snake' } = { casing: 'kebab' },
64
- ) {
65
- const { casing } = options
53
+ obj: Record<string, unknown>,
54
+ options: {
55
+ arraySeparator?: string | null | undefined
56
+ casing?: 'kebab' | 'snake' | undefined
57
+ } = {},
58
+ ): string[] {
59
+ const { arraySeparator = ',', casing = 'kebab' } = options
66
60
  return Object.entries(obj).flatMap(([key, value]) => {
67
61
  if (value === undefined) return []
68
62
 
69
- if (Array.isArray(value)) return [toFlagCase(key), value.join(',')]
63
+ if (Array.isArray(value)) {
64
+ if (value[0] === true)
65
+ return [toFlagCase(key), ...toArgs({ [key]: value[1] }, options)]
66
+ const arrayValue = (() => {
67
+ if (arraySeparator === null) return value
68
+ return value.join(arraySeparator)
69
+ })()
70
+
71
+ return [toFlagCase(key), arrayValue].flat()
72
+ }
70
73
 
71
74
  if (typeof value === 'object' && value !== null) {
72
75
  return Object.entries(value).flatMap(([subKey, subValue]) => {
@@ -75,7 +78,7 @@ export function toArgs(
75
78
  `${key}.${subKey}`,
76
79
  casing === 'kebab' ? '-' : '_',
77
80
  )
78
- return [flag, Array.isArray(subValue) ? subValue.join(',') : subValue]
81
+ return toArgs({ [flag.slice(2)]: subValue }, options)
79
82
  })
80
83
  }
81
84
 
@@ -84,7 +87,7 @@ export function toArgs(
84
87
  if (value === false) return [flag, 'false']
85
88
  if (value === true) return [flag]
86
89
 
87
- const stringified = value.toString()
90
+ const stringified = value?.toString() ?? ''
88
91
  if (stringified === '') return [flag]
89
92
 
90
93
  return [flag, stringified]
@@ -106,3 +109,30 @@ export function toFlagCase(str: string, separator = '-') {
106
109
  }
107
110
  return `--${keys.join('.')}`
108
111
  }
112
+
113
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
114
+ return Object.prototype.toString.call(value) === '[object Object]'
115
+ }
116
+
117
+ export function deepAssign(
118
+ target: Record<string, unknown>,
119
+ ...sources: Record<string, unknown>[]
120
+ ): Record<string, unknown> {
121
+ for (const source of sources) {
122
+ if (!isPlainObject(source)) continue
123
+
124
+ for (const key of Reflect.ownKeys(source)) {
125
+ const srcVal = source[key as keyof typeof source]
126
+ const tgtVal = target[key as keyof typeof target]
127
+
128
+ // If both are plain objects, merge recursively
129
+ if (isPlainObject(srcVal) && isPlainObject(tgtVal)) {
130
+ deepAssign(tgtVal, srcVal)
131
+ continue
132
+ }
133
+ // Otherwise, assign a deep clone of the source value
134
+ ;(target as any)[key] = structuredClone(srcVal)
135
+ }
136
+ }
137
+ return target
138
+ }
@@ -0,0 +1,84 @@
1
+ import getPort from 'get-port'
2
+ import { Instance } from 'prool/testcontainers'
3
+ import { afterEach, expect, test } from 'vitest'
4
+
5
+ const instances: Instance.Instance[] = []
6
+
7
+ const port = await getPort()
8
+
9
+ const defineInstance = (parameters: Instance.tempo.Parameters = {}) => {
10
+ const instance = Instance.tempo({ port, ...parameters })
11
+ instances.push(instance)
12
+ return instance
13
+ }
14
+
15
+ afterEach(async () => {
16
+ for (const instance of instances) await instance.stop().catch(() => {})
17
+ })
18
+
19
+ test('default', async () => {
20
+ const messages: string[] = []
21
+ const stdouts: string[] = []
22
+
23
+ const instance = defineInstance()
24
+
25
+ instance.on('message', (m) => messages.push(m))
26
+ instance.on('stdout', (m) => stdouts.push(m))
27
+
28
+ expect(instance.messages.get()).toMatchInlineSnapshot('[]')
29
+
30
+ await instance.start()
31
+ expect(instance.status).toEqual('started')
32
+
33
+ expect(messages.join('')).toBeDefined()
34
+ expect(stdouts.join('')).toBeDefined()
35
+ expect(instance.messages.get().join('')).toBeDefined()
36
+
37
+ await instance.stop()
38
+ expect(instance.status).toEqual('stopped')
39
+
40
+ expect(messages.join('')).toBeDefined()
41
+ expect(stdouts.join('')).toBeDefined()
42
+ expect(instance.messages.get()).toMatchInlineSnapshot('[]')
43
+ })
44
+
45
+ test('behavior: instance errored (duplicate ports)', async () => {
46
+ const instance_1 = defineInstance({ port: 8546 })
47
+ const instance_2 = defineInstance({ port: 8546 })
48
+
49
+ await instance_1.start()
50
+ await expect(() => instance_2.start()).rejects.toThrowError('Failed to start')
51
+ })
52
+
53
+ test('behavior: start and stop multiple times', async () => {
54
+ const instance = defineInstance()
55
+
56
+ await instance.start()
57
+ await instance.stop()
58
+ await instance.start()
59
+ await instance.stop()
60
+ await instance.start()
61
+ await instance.stop()
62
+ await instance.start()
63
+ await instance.stop()
64
+ })
65
+
66
+ test('behavior: can subscribe to stdout', async () => {
67
+ const messages: string[] = []
68
+ const instance = defineInstance()
69
+ instance.on('stdout', (message) => messages.push(message))
70
+
71
+ await instance.start()
72
+ expect(messages.length).toBeGreaterThanOrEqual(1)
73
+ })
74
+
75
+ test('behavior: can subscribe to stderr', async () => {
76
+ const messages: string[] = []
77
+
78
+ const instance_1 = defineInstance({ port: 8546 })
79
+ const instance_2 = defineInstance({ port: 8546 })
80
+
81
+ await instance_1.start()
82
+ instance_2.on('stderr', (message) => messages.push(message))
83
+ await expect(instance_2.start()).rejects.toThrow('Failed to start')
84
+ })