diff --git a/.changeset/execa-stop-exit.md b/.changeset/execa-stop-exit.md new file mode 100644 index 0000000..82c8c80 --- /dev/null +++ b/.changeset/execa-stop-exit.md @@ -0,0 +1,5 @@ +--- +"prool": patch +--- + +Fixed process `stop()` hanging when the process had already exited. diff --git a/.changeset/tempo-faucet-node-address.md b/.changeset/tempo-faucet-node-address.md new file mode 100644 index 0000000..fdabd28 --- /dev/null +++ b/.changeset/tempo-faucet-node-address.md @@ -0,0 +1,5 @@ +--- +"prool": patch +--- + +Fixed Tempo faucet submitting funding transactions to the node default `http://localhost:8545` instead of the resolved `--http.port`. diff --git a/src/instances/tempo.test.ts b/src/instances/tempo.test.ts index 4418799..01ca84c 100644 --- a/src/instances/tempo.test.ts +++ b/src/instances/tempo.test.ts @@ -1,6 +1,8 @@ +import * as os from 'node:os' import getPort from 'get-port' import { Instance } from 'prool' import { afterEach, expect, test } from 'vitest' +import { command } from './tempo.js' const instances: Instance.Instance[] = [] const slowTestTimeout = 30_000 @@ -87,3 +89,88 @@ test('behavior: can subscribe to stderr', async () => { instance_2.on('stderr', (message) => messages.push(message)) await expect(instance_2.start()).rejects.toThrow('Failed to start') }) + +test( + 'behavior: faucet funds address', + { timeout: slowTestTimeout }, + async () => { + const instance = defineInstance() + await instance.start() + + const rpc = async (method: string, params: unknown[]) => { + const response = await fetch(`http://localhost:${port}`, { + body: JSON.stringify({ id: 0, jsonrpc: '2.0', method, params }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }) + return await response.json() + } + + // Funding races the first wall-clock block; expiring-nonce validation rejects until one exists. + let json: { result?: string[] } = {} + for (let i = 0; i < 50; i++) { + json = await rpc('tempo_fundAddress', [ + '0x000000000000000000000000000000000000beef', + ]) + if (json.result) break + await new Promise((resolve) => setTimeout(resolve, 200)) + } + + expect( + JSON.stringify(json).replaceAll(/0x[0-9a-f]{64}/g, ''), + ).toMatchInlineSnapshot( + `"{"jsonrpc":"2.0","id":0,"result":["","","",""]}"`, + ) + + // balanceOf(0x...beef) on the first faucet token. + let balance = 0n + for (let i = 0; i < 50; i++) { + const { result } = await rpc('eth_call', [ + { + data: '0x70a08231000000000000000000000000000000000000000000000000000000000000beef', + to: '0x20c0000000000000000000000000000000000000', + }, + 'latest', + ]) + balance = BigInt(result ?? 0) + if (balance > 0n) break + await new Promise((resolve) => setTimeout(resolve, 200)) + } + expect(balance).toBeGreaterThan(0n) + }, +) + +const redact = (args: string[]) => + args.join(' ').replaceAll(os.tmpdir(), '') + +test('command: default', () => { + expect(redact(command({ port: 8545 }))).toMatchInlineSnapshot( + `"node --authrpc.port 8575 --datadir /.prool/tempo.8545 --dev --dev.block-time 50ms --engine.disable-precompile-cache --engine.legacy-state-root --faucet.address 0x20c0000000000000000000000000000000000000 0x20c0000000000000000000000000000000000001 0x20c0000000000000000000000000000000000002 0x20c0000000000000000000000000000000000003 --faucet.amount 1000000000000 --faucet.enabled --faucet.private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 --faucet.node-address http://localhost:8545 --http.addr 0.0.0.0 --http.api all --http.corsdomain * --http.port 8545 --port 8555 --ws.port 8565"`, + ) +}) + +test('command: behavior: faucet node address', () => { + expect( + redact( + command({ faucet: { nodeAddress: 'http://localhost:1337' }, port: 8545 }), + ), + ).toMatchInlineSnapshot( + `"node --authrpc.port 8575 --datadir /.prool/tempo.8545 --dev --dev.block-time 50ms --engine.disable-precompile-cache --engine.legacy-state-root --faucet.address 0x20c0000000000000000000000000000000000000 0x20c0000000000000000000000000000000000001 0x20c0000000000000000000000000000000000002 0x20c0000000000000000000000000000000000003 --faucet.amount 1000000000000 --faucet.enabled --faucet.private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 --faucet.node-address http://localhost:1337 --http.addr 0.0.0.0 --http.api all --http.corsdomain * --http.port 8545 --port 8555 --ws.port 8565"`, + ) +}) + +test('command: behavior: faucet disabled', () => { + expect( + redact(command({ faucet: { enabled: false }, port: 8545 })), + ).toMatchInlineSnapshot( + `"node --authrpc.port 8575 --datadir /.prool/tempo.8545 --dev --dev.block-time 50ms --engine.disable-precompile-cache --engine.legacy-state-root --faucet.address 0x20c0000000000000000000000000000000000000 0x20c0000000000000000000000000000000000001 0x20c0000000000000000000000000000000000002 0x20c0000000000000000000000000000000000003 --faucet.amount 1000000000000 --faucet.enabled false --faucet.private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 --http.addr 0.0.0.0 --http.api all --http.corsdomain * --http.port 8545 --port 8555 --ws.port 8565"`, + ) +}) + +test('command: behavior: http port override', () => { + expect( + redact(command({ http: { port: 1337 }, port: 8545 })), + ).toMatchInlineSnapshot( + `"node --authrpc.port 8575 --datadir /.prool/tempo.8545 --dev --dev.block-time 50ms --engine.disable-precompile-cache --engine.legacy-state-root --faucet.address 0x20c0000000000000000000000000000000000000 0x20c0000000000000000000000000000000000001 0x20c0000000000000000000000000000000000002 0x20c0000000000000000000000000000000000003 --faucet.amount 1000000000000 --faucet.enabled --faucet.private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 --faucet.node-address http://localhost:1337 --http.addr 0.0.0.0 --http.api all --http.corsdomain * --http.port 1337 --port 8555 --ws.port 8565"`, + ) +}) diff --git a/src/instances/tempo.ts b/src/instances/tempo.ts index 39e5079..8b60afd 100644 --- a/src/instances/tempo.ts +++ b/src/instances/tempo.ts @@ -50,9 +50,17 @@ export function command(parameters: tempo.Parameters): string[] { }, } + const args = deepAssign(defaultParameters, rest) + + // Faucet submits funding transactions over RPC; point it at the resolved http port (node default: 8545). + const faucet = args['faucet'] as tempo.Parameters['faucet'] + const http = args['http'] as { port: number } + if (faucet?.enabled && !faucet.nodeAddress) + faucet.nodeAddress = `http://localhost:${http.port}` + return [ 'node', - ...toArgs(deepAssign(defaultParameters, rest), { + ...toArgs(args, { arraySeparator: null, }), ] @@ -177,6 +185,16 @@ export declare namespace tempo { * Amount for each faucet funding transaction */ amount?: bigint | undefined + /** + * Whether the faucet is enabled + * @default true + */ + enabled?: boolean | undefined + /** + * Node RPC address the faucet submits funding transactions to + * @default `http://localhost:${port}` + */ + nodeAddress?: string | undefined /** * Faucet funding mnemonic */ diff --git a/src/processes/execa.ts b/src/processes/execa.ts index 7be5b74..761b3eb 100644 --- a/src/processes/execa.ts +++ b/src/processes/execa.ts @@ -33,9 +33,10 @@ export function execa(parameters: execa.Parameters): execa.ReturnType { let process: Process_internal async function stop(signal?: keyof SignalConstants | number) { - const killed = process.kill(signal) - if (!killed) return - return new Promise((resolve) => process.on('close', resolve)) + process.kill(signal) + // Process may have exited on its own; 'exit' will not re-fire. + if (process.exitCode !== null || process.signalCode !== null) return + return new Promise((resolve) => process.once('exit', resolve)) } return { diff --git a/src/testcontainers/Instance.test.ts b/src/testcontainers/Instance.test.ts index 3414384..cf6f136 100644 --- a/src/testcontainers/Instance.test.ts +++ b/src/testcontainers/Instance.test.ts @@ -79,3 +79,49 @@ test('behavior: can subscribe to stdout', async () => { }) test.skip('behavior: can subscribe to stderr', () => {}) + +test('behavior: faucet funds address', { timeout: 120_000 }, async () => { + const instance = defineInstance({ startupTimeout: 60_000 }) + await instance.start() + + const rpc = async (method: string, params: unknown[]) => { + const response = await fetch(`http://${instance.host}:${instance.port}`, { + body: JSON.stringify({ id: 0, jsonrpc: '2.0', method, params }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }) + return await response.json() + } + + // Funding races the first wall-clock block; expiring-nonce validation rejects until one exists. + let json: { result?: string[] } = {} + for (let i = 0; i < 100; i++) { + json = await rpc('tempo_fundAddress', [ + '0x000000000000000000000000000000000000beef', + ]) + if (json.result) break + await new Promise((resolve) => setTimeout(resolve, 500)) + } + + expect( + JSON.stringify(json).replaceAll(/0x[0-9a-f]{64}/g, ''), + ).toMatchInlineSnapshot( + `"{"jsonrpc":"2.0","id":0,"result":["","","",""]}"`, + ) + + // balanceOf(0x...beef) on the first faucet token. + let balance = 0n + for (let i = 0; i < 100; i++) { + const { result } = await rpc('eth_call', [ + { + data: '0x70a08231000000000000000000000000000000000000000000000000000000000000beef', + to: '0x20c0000000000000000000000000000000000000', + }, + 'latest', + ]) + balance = BigInt(result ?? 0) + if (balance > 0n) break + await new Promise((resolve) => setTimeout(resolve, 500)) + } + expect(balance).toBeGreaterThan(0n) +})