diff --git a/core/test/code_executors/unsafe_local_code_executor_test.ts b/core/test/code_executors/unsafe_local_code_executor_test.ts index 82a0d2d01..93d960007 100644 --- a/core/test/code_executors/unsafe_local_code_executor_test.ts +++ b/core/test/code_executors/unsafe_local_code_executor_test.ts @@ -45,6 +45,19 @@ const EXPECTED_POWERSHELL_ARGS = [ expect.stringMatching(/script\.ps1$/), ]; +/** + * `it()` budget (ms) for the cases that spawn a real interpreter rather than + * the mocked `spawn` used by the `spawn arguments` suite below. The `unit:core` + * project sets no `testTimeout`, so these inherit Vitest's 5000 ms default, + * which is not a process-start budget: on Windows the default shell is Windows + * PowerShell, whose cold start under V8 coverage instrumentation on a loaded CI + * runner does not reliably fit. The value is above `UnsafeLocalCodeExecutor`'s + * own 30 s default execution timeout on purpose, so a genuinely stuck + * interpreter fails with the executor's timeout message instead of an opaque + * Vitest timeout. + */ +const REAL_INTERPRETER_TIMEOUT_MS = 40000; + function createMockInvocationContext(): InvocationContext { const agent = new LlmAgent({ name: 'test_agent', @@ -158,21 +171,25 @@ describe('UnsafeLocalCodeExecutor', () => { expect(result.stderr).toBe(''); }); - it('should execute shell code and return stdout', async () => { - const params: ExecuteCodeParams = { - invocationContext, - codeExecutionInput: { - code: 'echo "Hello, Shell!"', - language: CodeExecutionLanguage.SHELL, - inputFiles: [], - }, - }; + it( + 'should execute shell code and return stdout', + async () => { + const params: ExecuteCodeParams = { + invocationContext, + codeExecutionInput: { + code: 'echo "Hello, Shell!"', + language: CodeExecutionLanguage.SHELL, + inputFiles: [], + }, + }; - const result = await executor.executeCode(params); + const result = await executor.executeCode(params); - expect(result.stdout).toContain('Hello, Shell!'); - expect(result.stderr).toBe(''); - }); + expect(result.stdout).toContain('Hello, Shell!'); + expect(result.stderr).toBe(''); + }, + REAL_INTERPRETER_TIMEOUT_MS, + ); it('should return error for unsupported language', async () => { const params: ExecuteCodeParams = { diff --git a/tests/cross_language/a2a/ts_go/go_backend/go_server.ts b/tests/cross_language/a2a/ts_go/go_backend/go_server.ts index cf25a2751..4e1688c22 100644 --- a/tests/cross_language/a2a/ts_go/go_backend/go_server.ts +++ b/tests/cross_language/a2a/ts_go/go_backend/go_server.ts @@ -58,7 +58,6 @@ export class AdkGoServer extends BaseTestServer { }); }, startMessage: 'A2A Server started on', - successLogMessage: `Test Go Server started at ${this.url}`, serverName: 'Go Server', timeoutMs: this.params.startFailureTimeout || DEFAULT_TIMEOUT, }); diff --git a/tests/integration/test_api_server.ts b/tests/integration/test_api_server.ts index 8b6337d40..4ff63bee4 100644 --- a/tests/integration/test_api_server.ts +++ b/tests/integration/test_api_server.ts @@ -47,7 +47,6 @@ export class AdkTsApiServer extends BaseTestServer { }); }, startMessage: 'ADK API Server started', - successLogMessage: `Test ADK API Server started`, serverName: 'CLI', timeoutMs: this.params.startFailureTimeout || DEFAULT_TIMEOUT, }); diff --git a/tests/integration/test_case_utils.ts b/tests/integration/test_case_utils.ts index ad5088fc0..580ed05df 100644 --- a/tests/integration/test_case_utils.ts +++ b/tests/integration/test_case_utils.ts @@ -19,6 +19,8 @@ import { GoogleGenAI, } from '@google/genai'; import {ChildProcessWithoutNullStreams} from 'node:child_process'; +import {once} from 'node:events'; +import {createServer} from 'node:net'; import {expect} from 'vitest'; /** @@ -257,108 +259,209 @@ export async function runTestCase(testCase: TestCase) { } } +/** Maximum characters retained from each captured stream in a failure. */ +const OUTPUT_EXCERPT_CHARS = 4000; + +/** How long `stop()` waits for a clean exit before escalating to SIGKILL. */ +const PROCESS_EXIT_TIMEOUT_MS = 5000; + +/** + * Returns a TCP port on `host` that the OS has just confirmed is free, by + * binding port 0, reading the assignment back and releasing it. + * + * Guessing a port instead is what makes a spawned server die at start-up with + * exit code 1: the guess can already be held by another concurrently-starting + * test worker drawing from the same range, or fall inside one of the TCP ranges + * Windows reserves for Hyper-V/WinNAT, and a failed bind is fatal to the child. + */ +export async function reserveFreePort(host: string): Promise { + const probe = createServer(); + + try { + const listening = once(probe, 'listening'); + probe.listen({host, port: 0}); + await listening; + + const address = probe.address(); + // `address()` is typed `AddressInfo | string | null`; the string form is + // for IPC servers, which this never is. + if (address === null || typeof address === 'string') { + throw new Error(`Expected a TCP address on ${host}, got ${address}`); + } + return address.port; + } finally { + await new Promise((resolve) => probe.close(() => resolve())); + } +} + +/** + * Keeps only the last {@link OUTPUT_EXCERPT_CHARS} characters of a captured + * stream: a child that dies noisily writes far more than is useful, and the + * bytes it wrote last are the ones explaining why. + */ +function excerpt(output: string): string { + return output.slice(-OUTPUT_EXCERPT_CHARS) || '(no output captured)'; +} + +function formatCapturedOutput(stdout: string, stderr: string): string { + return `\nstdout:\n${excerpt(stdout)}\nstderr:\n${excerpt(stderr)}`; +} + /** * Base class for test servers. */ export abstract class BaseTestServer { host: string; port: number; - url: string; protected serverProcess?: ChildProcessWithoutNullStreams; constructor(host: string, port?: number) { this.host = host; - this.port = port || BaseTestServer.getRandomPort(); - this.url = `http://${this.host}:${this.port}`; + // 0 means "allocate at start"; `startProcess` resolves it before spawning. + this.port = port ?? 0; } - static getRandomPort(): number { - return 40000 + Math.floor(Math.random() * 10000); + get url(): string { + return `http://${this.host}:${this.port}`; } protected async startProcess({ spawnProcess, startMessage, - successLogMessage, serverName, timeoutMs, }: { spawnProcess: () => ChildProcessWithoutNullStreams; startMessage: string; - successLogMessage: string; serverName: string; timeoutMs: number; }): Promise { - this.serverProcess = spawnProcess(); + // Both subclasses read `this.port` from inside `spawnProcess` -- for + // `--port` and for TEST_API_SERVER_PORT -- so it has to be a real port + // before the child starts, not after its banner is parsed. + if (!this.port) { + this.port = await reserveFreePort(this.host); + } - await new Promise((resolve, reject) => { - let started = false; - const stdoutChunks: string[] = []; - - this.serverProcess!.stdout.on('data', (data) => { - const message = data.toString(); - stdoutChunks.push(message); - - // Find URL like http://localhost:12345 - const urlMatch = message.match(/http:\/\/localhost:([0-9]+)/i); - if (urlMatch && urlMatch[1]) { - const parsedPort = parseInt(urlMatch[1], 10); - if (parsedPort > 0) { - this.port = parsedPort; - this.url = `http://${this.host}:${this.port}`; - } - } + const child = spawnProcess(); + this.serverProcess = child; - if (message.includes(startMessage)) { - started = true; - console.log(successLogMessage); + // Outlive the handshake: an 'error' event with no listener is rethrown by + // EventEmitter, and a server that dies mid-test is still worth reporting. + child.on('error', (error: Error) => { + console.error(`${serverName} Error: ${error.message}`); + }); + // 'exit' rather than 'close': a child that leaves a grandchild holding the + // inherited stdio pipes never emits 'close'. + child.on('exit', (code: number | null) => { + console.error(`${serverName} exited with code ${code}`); + }); + + await new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + // Gates retention only. The 'data' listeners stay attached for the life + // of the child so its pipes keep draining; a chatty server that filled + // the 64 KB pipe buffer would otherwise block. + let capturing = true; + + const settle = (error?: Error) => { + clearTimeout(timer); + capturing = false; + child.off('error', onError); + child.off('close', onClose); + if (error) { + reject(error); + } else { resolve(); } - }); + }; - this.serverProcess!.stderr.on('data', (data) => { - console.error(`${serverName} Stderr: ${data.toString()}`); - }); + const onStdout = (data: Buffer) => { + if (!capturing) return; + stdout += data.toString(); - this.serverProcess!.on('error', (error) => { - console.error(`${serverName} Error: ${error.message}`); + // Matched against the accumulated output, not this chunk: a banner + // split across two writes must still complete the handshake. + if (stdout.includes(startMessage)) { + settle(); + } + }; - reject( + const onStderr = (data: Buffer) => { + if (!capturing) return; + stderr += data.toString(); + }; + + const onError = (error: Error) => { + settle( new Error( - `Failed to start ${serverName.toLowerCase()}: ${error.message}`, + `Failed to start ${serverName.toLowerCase()}: ${error.message}` + + formatCapturedOutput(stdout, stderr), ), ); - }); - - this.serverProcess!.on('exit', (code) => { - console.error(`${serverName} exited with code ${code}`); - - if (!started) { - console.error( - `${serverName} Captured stdout before premature exit:\n${stdoutChunks.join('')}`, - ); - reject( - new Error(`${serverName} exited prematurely with code ${code}`), - ); - } - }); + }; + + // 'close' rather than 'exit': it fires only once the stdio pipes have + // been drained, so the child's last words -- the actual reason it refused + // to start -- are already captured when the error is built. + const onClose = ( + code: number | null, + signal: ChildProcessWithoutNullStreams['signalCode'], + ) => { + settle( + new Error( + `${serverName} exited prematurely with code ${code}` + + (signal ? ` (signal ${signal})` : '') + + formatCapturedOutput(stdout, stderr), + ), + ); + }; - setTimeout(() => { - if (!started) { - reject( - new Error( - `Timeout waiting for ${serverName.toLowerCase()} to start.`, - ), - ); - } + const timer = setTimeout(() => { + settle( + new Error( + `Timeout waiting for ${serverName.toLowerCase()} to start.` + + formatCapturedOutput(stdout, stderr), + ), + ); }, timeoutMs); + + child.stdout.on('data', onStdout); + child.stderr.on('data', onStderr); + child.on('error', onError); + child.on('close', onClose); }); + + console.log(`${serverName} started at ${this.url}`); } async stop(): Promise { - if (this.serverProcess) { - this.serverProcess.kill('SIGINT'); - await new Promise((resolve) => setTimeout(resolve, 500)); + const child = this.serverProcess; + if (!child) return; + this.serverProcess = undefined; + + // 'close' never fires again for a child that is already gone, so waiting on + // it would hang until the suite timeout. + if (child.exitCode !== null || child.signalCode !== null) return; + + // 'exit' rather than 'close': `go run` leaves a grandchild holding the + // inherited stdio pipes, and 'close' waits for those to be released, so it + // can outlive the process this is trying to reap. + // Subscribed before the kill so a fast exit cannot be missed. + const exited = once(child, 'exit'); + child.kill('SIGINT'); + // Windows emulates SIGINT as unconditional termination and a wedged child + // may ignore it outright, so the wait is bounded rather than open-ended. + const escalation = setTimeout( + () => child.kill('SIGKILL'), + PROCESS_EXIT_TIMEOUT_MS, + ); + + try { + await exited; + } finally { + clearTimeout(escalation); } } } diff --git a/tests/integration/test_case_utils_test.ts b/tests/integration/test_case_utils_test.ts new file mode 100644 index 000000000..c908cd67f --- /dev/null +++ b/tests/integration/test_case_utils_test.ts @@ -0,0 +1,400 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ChildProcessWithoutNullStreams} from 'node:child_process'; +import {spawn} from 'node:child_process'; +import {mkdtempSync, readFileSync} from 'node:fs'; +import {createServer, type Server} from 'node:net'; +import {platform, tmpdir} from 'node:os'; +import * as path from 'node:path'; +import {afterEach, describe, expect, it} from 'vitest'; +import {BaseTestServer, reserveFreePort} from './test_case_utils.js'; + +const HOST = 'localhost'; +const START_MESSAGE = 'SCRIPTED SERVER STARTED'; +/** Comfortably longer than a `node -e` child needs to print its banner. */ +const START_TIMEOUT_MS = 15000; +/** Keeps a scripted child alive until the test tears it down. */ +const STAY_ALIVE = 'setInterval(() => {}, 1000);'; +/** Mirrors `PROCESS_EXIT_TIMEOUT_MS` in the harness under test. */ +const PROCESS_EXIT_TIMEOUT_MS = 5000; +const IS_WINDOWS = platform() === 'win32'; + +const spawned: ChildProcessWithoutNullStreams[] = []; +const listeners: Server[] = []; +/** Files holding the pid of a grandchild that teardown must reap. */ +const grandchildPidFiles: string[] = []; + +/** Spawns a `node -e` child; portable and needs no shell quoting. */ +function nodeScript(script: string): () => ChildProcessWithoutNullStreams { + return () => { + const child = spawn(process.execPath, ['-e', script]); + spawned.push(child); + return child; + }; +} + +/** + * A {@link BaseTestServer} driven by a `node -e` script instead of the built + * ADK CLI, so the harness itself can be tested without a build. + */ +class ScriptedTestServer extends BaseTestServer { + child?: ChildProcessWithoutNullStreams; + /** `this.port` as observed from inside the spawn closure. */ + portAtSpawn = 0; + + constructor( + private readonly spawnChild: () => ChildProcessWithoutNullStreams, + port?: number, + ) { + super(HOST, port); + } + + start(timeoutMs = START_TIMEOUT_MS): Promise { + return this.startProcess({ + spawnProcess: () => { + this.portAtSpawn = this.port; + this.child = this.spawnChild(); + return this.child; + }, + startMessage: START_MESSAGE, + serverName: 'Scripted', + timeoutMs, + }); + } +} + +function hasExited(child: ChildProcessWithoutNullStreams): boolean { + return child.exitCode !== null || child.signalCode !== null; +} + +/** + * Waits for the harness to reach its spawn closure. `startProcess` awaits the + * port reservation first, so the child handle is not available synchronously. + */ +async function waitForChild( + server: ScriptedTestServer, +): Promise { + for (let i = 0; i < 500 && !server.child; i++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + if (!server.child) { + expect.fail('the server never reached its spawn closure'); + } + return server.child; +} + +/** True for the Node error raised when a pid no longer exists. */ +function isNoSuchProcess(error: unknown): boolean { + return ( + error instanceof Error && + 'code' in error && + typeof error.code === 'string' && + error.code === 'ESRCH' + ); +} + +/** Binds `port` for the rest of the test, proving it was bindable. */ +function listenOn(port: number): Promise { + const socket = createServer(); + listeners.push(socket); + + return new Promise((resolve, reject) => { + socket.once('error', reject); + socket.listen({host: HOST, port}, () => resolve()); + }); +} + +afterEach(async () => { + for (const child of spawned.splice(0)) { + child.kill('SIGKILL'); + } + for (const pidFile of grandchildPidFiles.splice(0)) { + try { + process.kill(Number(readFileSync(pidFile, 'utf8')), 'SIGKILL'); + } catch (error: unknown) { + // Windows tears the grandchild down with its parent, so by here it is + // already gone. Any other failure is real and must surface. + if (!isNoSuchProcess(error)) throw error; + } + } + await Promise.all( + listeners + .splice(0) + .map( + (socket) => + new Promise((resolve) => socket.close(() => resolve())), + ), + ); +}); + +describe('reserveFreePort', () => { + it('returns a port that can immediately be bound', async () => { + const port = await reserveFreePort(HOST); + + expect(Number.isInteger(port)).toBe(true); + expect(port).toBeGreaterThan(0); + await expect(listenOn(port)).resolves.toBeUndefined(); + }); + + it('never hands back a port that is currently bound', async () => { + const held = await reserveFreePort(HOST); + await listenOn(held); + + for (let i = 0; i < 20; i++) { + expect(await reserveFreePort(HOST)).not.toBe(held); + } + }); + + it('rejects when the probe socket cannot bind the host', async () => { + await expect(reserveFreePort('256.256.256.256')).rejects.toThrow(); + }); +}); + +describe('BaseTestServer.startProcess', () => { + it('reserves the port before the child is spawned', async () => { + const server = new ScriptedTestServer( + nodeScript(`process.stdout.write('${START_MESSAGE}\\n');${STAY_ALIVE}`), + ); + + await server.start(); + + // The child reads `this.port` for --port and TEST_API_SERVER_PORT, so a + // port assigned after the spawn would be silently ignored. + expect(server.portAtSpawn).toBeGreaterThan(0); + expect(server.portAtSpawn).toBe(server.port); + expect(server.url).toBe(`http://${HOST}:${server.port}`); + }); + + it('honours an explicitly requested port instead of reserving one', async () => { + const requested = await reserveFreePort(HOST); + const server = new ScriptedTestServer( + nodeScript(`process.stdout.write('${START_MESSAGE}\\n');${STAY_ALIVE}`), + requested, + ); + + await server.start(); + + expect(server.portAtSpawn).toBe(requested); + expect(server.port).toBe(requested); + }); + + it('completes the handshake when the banner is split across writes', async () => { + const [head, tail] = [START_MESSAGE.slice(0, 9), START_MESSAGE.slice(9)]; + const server = new ScriptedTestServer( + nodeScript( + `process.stdout.write('${head}');` + + `setTimeout(() => process.stdout.write('${tail}\\n'), 50);` + + STAY_ALIVE, + ), + ); + + await expect(server.start()).resolves.toBeUndefined(); + }); + + it('surfaces both captured streams when the child exits prematurely', async () => { + const server = new ScriptedTestServer( + nodeScript( + "process.stderr.write('STDERR-REASON\\n', () => " + + "process.stdout.write('STDOUT-REASON\\n', () => process.exit(1)));", + ), + ); + + const attempt = server.start(); + + await expect(attempt).rejects.toThrow( + 'Scripted exited prematurely with code 1', + ); + await expect(attempt).rejects.toThrow('STDERR-REASON'); + await expect(attempt).rejects.toThrow('STDOUT-REASON'); + }); + + it('reports both streams as empty when the child exits silently', async () => { + const server = new ScriptedTestServer(nodeScript('process.exit(1);')); + + const error = await server.start().catch((e: unknown) => e); + + expect(error).toBeInstanceOf(Error); + if (!(error instanceof Error)) { + expect.fail('expected start() to reject with an Error'); + } + expect(error.message).toContain('stdout:\n(no output captured)'); + expect(error.message).toContain('stderr:\n(no output captured)'); + }); + + it('names the signal when the child is terminated', async () => { + // Killed from here rather than by the child itself: Windows reports the + // signal it was asked to terminate with, but a self-termination surfaces + // only as exit code 1. + const server = new ScriptedTestServer(nodeScript(STAY_ALIVE)); + + const attempt = server.start(); + (await waitForChild(server)).kill('SIGKILL'); + + await expect(attempt).rejects.toThrow('(signal SIGKILL)'); + await expect(attempt).rejects.toThrow('exited prematurely with code null'); + }); + + it('keeps only the tail of a noisy child', async () => { + const server = new ScriptedTestServer( + nodeScript( + "process.stdout.write('HEAD-MARKER' + 'x'.repeat(9000) + " + + "'TAIL-MARKER\\n', () => process.exit(1));", + ), + ); + + const error = await server.start().catch((e: unknown) => e); + + expect(error).toBeInstanceOf(Error); + if (!(error instanceof Error)) { + expect.fail('expected start() to reject with an Error'); + } + expect(error.message).toContain('TAIL-MARKER'); + expect(error.message).not.toContain('HEAD-MARKER'); + }); + + it('reports a spawn failure with the captured output', async () => { + const server = new ScriptedTestServer(() => { + const child = spawn('adk-binary-that-does-not-exist', []); + spawned.push(child); + return child; + }); + + const attempt = server.start(); + + await expect(attempt).rejects.toThrow('Failed to start scripted'); + await expect(attempt).rejects.toThrow('(no output captured)'); + }); + + it('surfaces the captured output on a start-up timeout', async () => { + const server = new ScriptedTestServer( + nodeScript( + `process.stdout.write('NOISE-BEFORE-TIMEOUT\\n');${STAY_ALIVE}`, + ), + ); + + const attempt = server.start(300); + + await expect(attempt).rejects.toThrow('Timeout waiting for scripted'); + await expect(attempt).rejects.toThrow('NOISE-BEFORE-TIMEOUT'); + + await server.stop(); + expect(server.child).toBeDefined(); + expect(hasExited(server.child!)).toBe(true); + }); + + it('keeps draining the pipes after the handshake settles', async () => { + // Far beyond the 64 KB pipe buffer on both streams: if the handshake left + // either unread, the child would block on write and never reach exit(0). + const server = new ScriptedTestServer( + nodeScript( + `process.stdout.write('${START_MESSAGE}\\n');` + + "setTimeout(() => process.stderr.write('e'.repeat(500000), " + + "() => process.stdout.write('y'.repeat(500000), " + + '() => process.exit(0))), 10);', + ), + ); + + await server.start(); + const child = server.child; + expect(child).toBeDefined(); + + await new Promise((resolve) => child!.once('close', () => resolve())); + expect(child!.exitCode).toBe(0); + }); +}); + +describe('BaseTestServer.stop', () => { + it('returns only once the child has actually exited', async () => { + // The child delays its exit past the 500 ms the harness used to sleep for, + // so a fixed sleep would return while it was still running. + const server = new ScriptedTestServer( + nodeScript( + "process.on('SIGINT', () => setTimeout(() => process.exit(0), 800));" + + `process.stdout.write('${START_MESSAGE}\\n');${STAY_ALIVE}`, + ), + ); + await server.start(); + const child = server.child; + expect(child).toBeDefined(); + + await server.stop(); + + expect(hasExited(child!)).toBe(true); + }); + + it('escalates to SIGKILL when the child ignores SIGINT', async () => { + const server = new ScriptedTestServer( + nodeScript( + "process.on('SIGINT', () => {});" + + `process.stdout.write('${START_MESSAGE}\\n');${STAY_ALIVE}`, + ), + ); + await server.start(); + const child = server.child; + expect(child).toBeDefined(); + + await server.stop(); + + // Windows emulates SIGINT as unconditional termination, so the child cannot + // ignore it and the escalation never arms; POSIX reaches the SIGKILL path. + expect(child!.signalCode).toBe(IS_WINDOWS ? 'SIGINT' : 'SIGKILL'); + }, 20000); + + it('returns when a grandchild still holds the inherited stdio pipes', async () => { + // `go run` behaves this way: killing the wrapper leaves the built binary + // holding the pipes, so the wrapper emits 'exit' but never 'close'. The + // grandchild here outlives the assertion window on purpose -- teardown + // reaps it -- so waiting on 'close' cannot pass by simply outlasting it. + const pidFile = path.join( + mkdtempSync(path.join(tmpdir(), 'adk-harness-')), + 'grandchild.pid', + ); + grandchildPidFiles.push(pidFile); + const server = new ScriptedTestServer( + nodeScript( + "const gc = require('node:child_process').spawn(process.execPath, " + + "['-e', 'setTimeout(() => {}, 60000)'], {stdio: 'inherit'});" + + // Recorded by the parent, which knows the pid the moment it spawns, + // so teardown cannot race the grandchild's own start-up. + `require('node:fs').writeFileSync(${JSON.stringify(pidFile)}, ` + + 'String(gc.pid));' + + `process.stdout.write('${START_MESSAGE}\\n');${STAY_ALIVE}`, + ), + ); + await server.start(); + const child = server.child; + expect(child).toBeDefined(); + + const startedAt = Date.now(); + await server.stop(); + + expect(hasExited(child!)).toBe(true); + expect(Date.now() - startedAt).toBeLessThan(PROCESS_EXIT_TIMEOUT_MS); + }); + + it('is a no-op on a server that was never started', async () => { + const server = new ScriptedTestServer(nodeScript(STAY_ALIVE)); + + await expect(server.stop()).resolves.toBeUndefined(); + }); + + it('is safe to call twice, and on a child that already exited', async () => { + const server = new ScriptedTestServer( + nodeScript( + `process.stdout.write('${START_MESSAGE}\\n');` + + 'setTimeout(() => process.exit(0), 10);', + ), + ); + await server.start(); + const child = server.child; + expect(child).toBeDefined(); + await new Promise((resolve) => child!.once('close', () => resolve())); + + await expect(server.stop()).resolves.toBeUndefined(); + await expect(server.stop()).resolves.toBeUndefined(); + }); +});