Skip to content

Commit 3fa6c19

Browse files
committed
feat: prool/testcontainers
1 parent ce4e4b4 commit 3fa6c19

File tree

8 files changed

+224
-118
lines changed

8 files changed

+224
-118
lines changed

.changeset/shaggy-eels-lead.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"prool": minor
3+
---
4+
5+
**Breaking:** Moved `Instance.tempoDocker` into `prool/testcontainers` entrypoint.
6+
7+
```diff
8+
- import { Instance } from 'prool'
9+
+ import { Instance } from 'prool/testcontainers'
10+
11+
- Instance.tempoDocker
12+
+ Instance.tempo
13+
```

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"check:types": "tsc",
88
"dev": "zile dev",
99
"postinstall": "pnpm dev",
10-
"test": "vitest --testTimeout=100000"
10+
"test": "vitest --testTimeout=10000 --no-file-parallelism"
1111
},
1212
"devDependencies": {
1313
"@biomejs/biome": "^2.3.8",
@@ -43,6 +43,11 @@
4343
"src": "./src/index.ts",
4444
"types": "./dist/index.d.ts",
4545
"default": "./dist/index.js"
46+
},
47+
"./testcontainers": {
48+
"src": "./src/testcontainers/index.ts",
49+
"types": "./dist/testcontainers/index.d.ts",
50+
"default": "./dist/testcontainers/index.js"
4651
}
4752
},
4853
"dependencies": {

src/Instance.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { EventEmitter } from 'eventemitter3'
22

33
export { alto } from './instances/alto.js'
44
export { anvil } from './instances/anvil.js'
5-
export { tempo, tempoDocker } from './instances/tempo.js'
5+
export { tempo } from './instances/tempo.js'
66

77
type EventTypes = {
88
exit: [code: number | null, signal: NodeJS.Signals | null]

src/instances/tempo.ts

Lines changed: 0 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
import * as os from 'node:os'
22
import * as path from 'node:path'
3-
import {
4-
GenericContainer,
5-
type StartedTestContainer,
6-
Wait,
7-
} from 'testcontainers'
83
import * as Instance from '../Instance.js'
94
import { deepAssign, toArgs } from '../internal/utils.js'
105
import { execa } from '../processes/execa.js'
@@ -210,113 +205,3 @@ export declare namespace tempo {
210205
port?: number | undefined
211206
} & Record<string, unknown>
212207
}
213-
214-
/**
215-
* Defines a Tempo instance.
216-
*
217-
* @example
218-
* ```ts
219-
* const instance = Instance.tempoDocker({ port: 8545 })
220-
* await instance.start()
221-
* // ...
222-
* await instance.stop()
223-
* ```
224-
*/
225-
export const tempoDocker = Instance.define(
226-
(parameters?: tempoDocker.Parameters) => {
227-
const {
228-
containerName = `tempo.${crypto.randomUUID()}`,
229-
image = 'ghcr.io/tempoxyz/tempo:latest',
230-
log: log_,
231-
...args
232-
} = parameters || {}
233-
234-
const log = (() => {
235-
try {
236-
return JSON.parse(log_ as string)
237-
} catch {
238-
return log_
239-
}
240-
})()
241-
const RUST_LOG = log && typeof log !== 'boolean' ? log : ''
242-
243-
const name = 'tempo'
244-
let container: StartedTestContainer | undefined
245-
246-
return {
247-
_internal: {
248-
args,
249-
},
250-
host: args.host ?? 'localhost',
251-
name,
252-
port: args.port ?? 8545,
253-
async start({ port = args.port }, { emitter }) {
254-
const promise = Promise.withResolvers<void>()
255-
256-
const c = new GenericContainer(image)
257-
.withPlatform('linux/x86_64')
258-
.withNetworkMode('host')
259-
.withExtraHosts([
260-
{ host: 'host.docker.internal', ipAddress: 'host-gateway' },
261-
{ host: 'localhost', ipAddress: 'host-gateway' },
262-
])
263-
.withName(containerName)
264-
.withEnvironment({ RUST_LOG })
265-
.withCommand(command({ ...args, port }))
266-
.withWaitStrategy(Wait.forLogMessage(/RPC HTTP server started/))
267-
.withLogConsumer((stream) => {
268-
stream.on('data', (data) => {
269-
const message = data.toString()
270-
emitter.emit('message', message)
271-
emitter.emit('stdout', message)
272-
if (log) console.log(message)
273-
if (message.includes('shutting down'))
274-
promise.reject(new Error(`Failed to start: ${message}`))
275-
})
276-
stream.on('error', (error) => {
277-
if (log) console.error(error.message)
278-
emitter.emit('message', error.message)
279-
emitter.emit('stderr', error.message)
280-
promise.reject(new Error(`Failed to start: ${error.message}`))
281-
})
282-
})
283-
.withStartupTimeout(10_000)
284-
285-
c.start()
286-
.then((c) => {
287-
container = c
288-
promise.resolve()
289-
})
290-
.catch(promise.reject)
291-
292-
return promise.promise
293-
},
294-
async stop() {
295-
if (!container) return
296-
await container.stop()
297-
container = undefined
298-
},
299-
}
300-
},
301-
)
302-
303-
export declare namespace tempoDocker {
304-
export type Parameters = Omit<tempo.Parameters, 'binary'> & {
305-
/**
306-
* Name of the container.
307-
*/
308-
containerName?: string | undefined
309-
/**
310-
* Docker image to use.
311-
*/
312-
image?: string | undefined
313-
/**
314-
* Host the server will listen on.
315-
*/
316-
host?: string | undefined
317-
/**
318-
* Port the server will listen on.
319-
*/
320-
port?: number | undefined
321-
}
322-
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
})

src/testcontainers/Instance.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import {
2+
GenericContainer,
3+
type StartedTestContainer,
4+
Wait,
5+
} from 'testcontainers'
6+
import * as Instance from '../Instance.js'
7+
import { command, type tempo as core_tempo } from '../instances/tempo.js'
8+
9+
export type { Instance, InstanceOptions } from '../Instance.js'
10+
11+
/**
12+
* Defines a Tempo instance.
13+
*
14+
* @example
15+
* ```ts
16+
* const instance = Instance.tempo({ port: 8545 })
17+
* await instance.start()
18+
* // ...
19+
* await instance.stop()
20+
* ```
21+
*/
22+
export const tempo = Instance.define((parameters?: tempo.Parameters) => {
23+
const {
24+
containerName = `tempo.${crypto.randomUUID()}`,
25+
image = 'ghcr.io/tempoxyz/tempo:latest',
26+
log: log_,
27+
...args
28+
} = parameters || {}
29+
30+
const log = (() => {
31+
try {
32+
return JSON.parse(log_ as string)
33+
} catch {
34+
return log_
35+
}
36+
})()
37+
const RUST_LOG = log && typeof log !== 'boolean' ? log : ''
38+
39+
const name = 'tempo'
40+
let container: StartedTestContainer | undefined
41+
42+
return {
43+
_internal: {
44+
args,
45+
},
46+
host: args.host ?? 'localhost',
47+
name,
48+
port: args.port ?? 8545,
49+
async start({ port = args.port }, { emitter }) {
50+
const promise = Promise.withResolvers<void>()
51+
52+
const c = new GenericContainer(image)
53+
.withPlatform('linux/x86_64')
54+
.withNetworkMode('host')
55+
.withExtraHosts([
56+
{ host: 'host.docker.internal', ipAddress: 'host-gateway' },
57+
{ host: 'localhost', ipAddress: 'host-gateway' },
58+
])
59+
.withName(containerName)
60+
.withEnvironment({ RUST_LOG })
61+
.withCommand(command({ ...args, port }))
62+
.withWaitStrategy(Wait.forLogMessage(/RPC HTTP server started/))
63+
.withLogConsumer((stream) => {
64+
stream.on('data', (data) => {
65+
const message = data.toString()
66+
emitter.emit('message', message)
67+
emitter.emit('stdout', message)
68+
if (log) console.log(message)
69+
if (message.includes('shutting down'))
70+
promise.reject(new Error(`Failed to start: ${message}`))
71+
})
72+
stream.on('error', (error) => {
73+
if (log) console.error(error.message)
74+
emitter.emit('message', error.message)
75+
emitter.emit('stderr', error.message)
76+
promise.reject(new Error(`Failed to start: ${error.message}`))
77+
})
78+
})
79+
.withStartupTimeout(10_000)
80+
81+
c.start()
82+
.then((c) => {
83+
container = c
84+
promise.resolve()
85+
})
86+
.catch(promise.reject)
87+
88+
return promise.promise
89+
},
90+
async stop() {
91+
if (!container) return
92+
await container.stop()
93+
container = undefined
94+
},
95+
}
96+
})
97+
98+
export declare namespace tempo {
99+
export type Parameters = Omit<core_tempo.Parameters, 'binary'> & {
100+
/**
101+
* Name of the container.
102+
*/
103+
containerName?: string | undefined
104+
/**
105+
* Docker image to use.
106+
*/
107+
image?: string | undefined
108+
/**
109+
* Host the server will listen on.
110+
*/
111+
host?: string | undefined
112+
/**
113+
* Port the server will listen on.
114+
*/
115+
port?: number | undefined
116+
}
117+
}

src/testcontainers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * as Instance from './Instance.js'

tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"moduleResolution": "nodenext",
88
"target": "esnext",
99
"paths": {
10-
"prool": ["./src/index.ts"]
10+
"prool": ["./src/index.ts"],
11+
"prool/testcontainers": ["./src/testcontainers/index.ts"]
1112
},
1213
"types": ["node", "vitest/globals"],
1314

0 commit comments

Comments
 (0)