diff --git a/packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts b/packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts index dbd0b7dd15d..cf29d83b87a 100644 --- a/packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts +++ b/packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts @@ -62,11 +62,13 @@ async function mountResources(php: PHP, mounts: Mount[]) { mount.vfsPath, createNodeFsMountHandler(mount.hostPath) ); - } catch { - output.stderr( - `\x1b[31m\x1b[1mError mounting path ${mount.hostPath} at ${mount.vfsPath}\x1b[0m\n` + } catch (error) { + const errorSummary = + error instanceof Error ? error.message : String(error); + throw new Error( + `Error mounting path ${mount.hostPath} at ${mount.vfsPath}: ${errorSummary}`, + { cause: error } ); - process.exit(1); } } } diff --git a/packages/playground/cli/src/cli.ts b/packages/playground/cli/src/cli.ts index 2972f4b4bdc..45d01b82ad5 100644 --- a/packages/playground/cli/src/cli.ts +++ b/packages/playground/cli/src/cli.ts @@ -6,7 +6,33 @@ function runCLI() { // Dynamic import avoids loading run-cli when we're about to respawn. // Do not await — top-level await is not supported in all environments. import('./run-cli').then(({ parseOptionsAndRunCLI }) => { - parseOptionsAndRunCLI(args); + parseOptionsAndRunCLI(args) + .then((result) => { + if ('exitCode' in result) { + process.exit(result.exitCode); + return; + } + + // A server is running. Clean up and exit on + // SIGINT / SIGTERM. + const cleanUpAndExit = (() => { + let cleaning: PromiseLike; + return async () => { + if (!cleaning) { + cleaning = result[Symbol.asyncDispose](); + } + await cleaning; + process.exit(0); + }; + })(); + process.on('SIGINT', cleanUpAndExit); + process.on('SIGTERM', cleanUpAndExit); + }) + .catch(() => { + // Unexpected error — already logged by + // parseOptionsAndRunCLI. + process.exit(1); + }); }); } diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index cc1c127bed2..21e76f957ee 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -97,12 +97,49 @@ type LogVerbosity = (typeof LogVerbosity)[keyof typeof LogVerbosity]['name']; export type WorkerType = 'v1' | 'v2'; +/** + * Returned by parseOptionsAndRunCLI when the CLI should exit + * without starting a long-running server. + */ +export interface CLIExitResult { + exitCode: number; +} + +/** + * Returned by parseOptionsAndRunCLI when a server was started + * and is still running. + */ +export interface CLIServerResult extends AsyncDisposable { + [internalsKeyForTesting]: { cliServer: RunCLIServer }; +} + +export type ParseCLIResult = CLIExitResult | CLIServerResult; + +/** + * Internal sentinel thrown inside yargs callbacks (which can only + * signal failure by throwing) when validation has already been + * reported to the user. Caught within parseOptionsAndRunCLI and + * converted to a CLIExitResult — never exposed to callers. + */ +class CLIArgsValidationError extends Error { + exitCode: number; + constructor(exitCode: number) { + super(); + this.exitCode = exitCode; + } +} + /** * Parse the CLI args and run the appropriate command. * + * Returns a structured result so the caller can decide how to + * exit. Only throws for truly unexpected errors. + * * @param argsToParse string[] The CLI args to parse. */ -export async function parseOptionsAndRunCLI(argsToParse: string[]) { +export async function parseOptionsAndRunCLI( + argsToParse: string[] +): Promise { try { /** * @TODO This looks similar to Query API args https://wordpress.github.io/wordpress-playground/developers/apis/query-api/ @@ -515,10 +552,10 @@ export async function parseOptionsAndRunCLI(argsToParse: string[]) { if (msg && msg.includes('Please specify a command')) { yargsInstance.showHelp(); console.error('\n' + msg); - process.exit(1); + } else { + console.error(msg); } - console.error(msg); - process.exit(1); + throw new CLIArgsValidationError(1); }) .strictOptions() .check(async (args) => { @@ -647,7 +684,7 @@ export async function parseOptionsAndRunCLI(argsToParse: string[]) { ].includes(command) ) { yargsObject.showHelp(); - process.exit(1); + throw new CLIArgsValidationError(1); } const define = (args['define'] || {}) as Record; @@ -686,43 +723,30 @@ export async function parseOptionsAndRunCLI(argsToParse: string[]) { ], } as RunCLIArgs; - const cliServer = await runCLI(cliArgs); - if (cliServer === undefined) { + const cliResult = await runCLI(cliArgs); + if (typeof cliResult === 'number') { + // A one-shot command (e.g. `php`) finished with an + // explicit exit code. + return { exitCode: cliResult }; + } + if (cliResult === undefined) { // No server was started, so we are done with our work. - process.exit(0); + return { exitCode: 0 }; } - const cleanUpCliAndExit = (() => { - // Remember we are already cleaning up to preclude the possibility - // of multiple, conflicting cleanup attempts. - let promiseToCleanup: Promise; - - return async () => { - if (promiseToCleanup === undefined) { - promiseToCleanup = cliServer[Symbol.asyncDispose](); - } - await promiseToCleanup; - process.exit(0); - }; - })(); - - // Playground CLI server must be killed to exit. From the terminal, - // this may occur via Ctrl+C which sends SIGINT. Let's handle both - // SIGINT and SIGTERM (the default kill signal) to make sure we - // clean up after ourselves even if this process is being killed. - // NOTE: Windows does not support SIGTERM, but Node.js provides some emulation. - process.on('SIGINT', cleanUpCliAndExit); - process.on('SIGTERM', cleanUpCliAndExit); - return { - [Symbol.asyncDispose]: async () => { - process.off('SIGINT', cleanUpCliAndExit); - process.off('SIGTERM', cleanUpCliAndExit); - await cliServer[Symbol.asyncDispose](); - }, - [internalsKeyForTesting]: { cliServer }, + [Symbol.asyncDispose]: () => cliResult[Symbol.asyncDispose](), + [internalsKeyForTesting]: { cliServer: cliResult }, }; } catch (e) { + // Validation errors have already been reported to the + // user (e.g. by the yargs .fail() handler). Convert them + // to a structured result instead of re-throwing. + if (e instanceof CLIArgsValidationError) { + return { exitCode: e.exitCode }; + } + // Unexpected error — log details and re-throw so the + // caller (cli.ts) can exit with a non-zero code. console.error(e); const debug = process.argv.includes('--debug'); if (e instanceof Error) { @@ -736,15 +760,13 @@ export async function parseOptionsAndRunCLI(argsToParse: string[]) { currentError = currentError.cause as Error; } while (currentError instanceof Error); console.error( - '\x1b[1m' + - messageChain.join(' caused by: ') + - '\x1b[0m' + '\x1b[1m' + messageChain.join(' caused by: ') + '\x1b[0m' ); } } else { console.error('\x1b[1m' + describeError(e) + '\x1b[0m'); } - process.exit(1); + throw e; } } @@ -915,16 +937,23 @@ const highlight = (text: string) => export { mergeDefinedConstants } from './defines'; export async function runCLI( - args: RunCLIArgs & { command: 'build-snapshot' | 'run-blueprint' | 'php' } + args: RunCLIArgs & { command: 'build-snapshot' | 'run-blueprint' } ): Promise; +export async function runCLI( + args: RunCLIArgs & { command: 'php' } +): Promise; export async function runCLI( args: RunCLIArgs & { command: 'start' } ): Promise; export async function runCLI( args: RunCLIArgs & { command: 'server' } ): Promise; -export async function runCLI(args: RunCLIArgs): Promise; -export async function runCLI(args: RunCLIArgs): Promise { +export async function runCLI( + args: RunCLIArgs +): Promise; +export async function runCLI( + args: RunCLIArgs +): Promise { let playgroundPool: Pooled; const cookieStore = args.internalCookieStore ? new HttpCookieStore() @@ -1562,10 +1591,11 @@ export async function runCLI(args: RunCLIArgs): Promise { ), ]); await disposeCLI(); - // stdout and stderr streams are drained above, - // but we use process.exit as a hard cut-off to ensure - // Node doesn't hang on open handles. - process.exit(exitCode); + // Return the exit code so the entry-point + // can call process.exit() as a hard cut-off + // to ensure Node doesn't hang on open + // handles. + return exitCode; } } @@ -1674,11 +1704,15 @@ export async function runCLI(args: RunCLIArgs): Promise { return response; }, }).catch((error) => { - cliOutput.printError(describeError(error)); - process.exit(1); + throw error; }); - if (server && args.command === 'start' && !args.skipBrowser) { + if ( + server && + typeof server === 'object' && + args.command === 'start' && + !args.skipBrowser + ) { openInBrowser(server.serverUrl); } return server; @@ -1777,7 +1811,9 @@ function expandStartCommandArgs( console.log( `You may still remove the site's directory manually if you wish.` ); - process.exit(1); + throw new Error( + 'This site is not managed by Playground CLI and cannot be reset.' + ); } } diff --git a/packages/playground/cli/src/start-server.ts b/packages/playground/cli/src/start-server.ts index 6e627f0ff3c..d16fe580ee5 100644 --- a/packages/playground/cli/src/start-server.ts +++ b/packages/playground/cli/src/start-server.ts @@ -14,7 +14,10 @@ const exec = promisify(execCb); export interface ServerOptions { port: number; - onBind: (server: Server, port: number) => Promise; + onBind: ( + server: Server, + port: number + ) => Promise; /** * Handler for requests. Always returns StreamedPHPResponse. */ @@ -36,7 +39,7 @@ export function isPortInUse(port: number): Promise { export async function startServer( options: ServerOptions -): Promise { +): Promise { const app = express(); const server = await new Promise< diff --git a/packages/playground/cli/tests/run-cli.spec.ts b/packages/playground/cli/tests/run-cli.spec.ts index bd354ab3773..853e2b801e1 100644 --- a/packages/playground/cli/tests/run-cli.spec.ts +++ b/packages/playground/cli/tests/run-cli.spec.ts @@ -6,7 +6,12 @@ import { parseOptionsAndRunCLI, internalsKeyForTesting, } from '../src/run-cli'; -import type { RunCLIArgs, RunCLIServer } from '../src/run-cli'; +import type { + RunCLIArgs, + RunCLIServer, + CLIExitResult, + CLIServerResult, +} from '../src/run-cli'; import type { MockInstance } from 'vitest'; import { vi } from 'vitest'; import { mkdtemp, writeFile } from 'node:fs/promises'; @@ -339,10 +344,7 @@ describe.each(blueprintVersions)( const mounts = []; for (let i = 0; i < 5; i++) { - const hostSubDir = path.join( - hostTmpDir, - `migration-${i}` - ); + const hostSubDir = path.join(hostTmpDir, `migration-${i}`); mkdirSync(hostSubDir, { recursive: true }); const hostFilePath = path.join( hostSubDir, @@ -1072,14 +1074,6 @@ describe('start command', () => { }); describe('php command', () => { - class ProcessExitCalled extends Error { - exitCode: number; - constructor(exitCode: number) { - super(`process.exit(${exitCode})`); - this.exitCode = exitCode; - } - } - test('should run a PHP script and capture output', async () => { const stdoutChunks: string[] = []; const stdoutSpy = vi @@ -1092,27 +1086,19 @@ describe('php command', () => { ); return true; }); - const exitSpy = vi - .spyOn(process, 'exit') - .mockImplementation((code?: string | number | null) => { - throw new ProcessExitCalled(Number(code)); - }); try { - await expect( - runCLI({ - command: 'php', - _: ['php', '-r', 'echo "hello from php";'], - wordpressInstallMode: 'do-not-attempt-installing', - skipSqliteSetup: true, - blueprint: undefined, - }) - ).rejects.toThrow(ProcessExitCalled); - expect(exitSpy).toHaveBeenCalledWith(0); + const exitCode = await runCLI({ + command: 'php', + _: ['php', '-r', 'echo "hello from php";'], + wordpressInstallMode: 'do-not-attempt-installing', + skipSqliteSetup: true, + blueprint: undefined, + }); + expect(exitCode).toBe(0); expect(stdoutChunks.join('')).toContain('hello from php'); } finally { stdoutSpy.mockRestore(); - exitSpy.mockRestore(); } }); @@ -1123,27 +1109,19 @@ describe('php command', () => { const stderrSpy = vi .spyOn(process.stderr, 'write') .mockImplementation(() => true); - const exitSpy = vi - .spyOn(process, 'exit') - .mockImplementation((code?: string | number | null) => { - throw new ProcessExitCalled(Number(code)); - }); try { - await expect( - runCLI({ - command: 'php', - _: ['php', '-r', 'exit(42);'], - wordpressInstallMode: 'do-not-attempt-installing', - skipSqliteSetup: true, - blueprint: undefined, - }) - ).rejects.toThrow(ProcessExitCalled); - expect(exitSpy).toHaveBeenCalledWith(42); + const exitCode = await runCLI({ + command: 'php', + _: ['php', '-r', 'exit(42);'], + wordpressInstallMode: 'do-not-attempt-installing', + skipSqliteSetup: true, + blueprint: undefined, + }); + expect(exitCode).toBe(42); } finally { stdoutSpy.mockRestore(); stderrSpy.mockRestore(); - exitSpy.mockRestore(); } }); @@ -1162,28 +1140,20 @@ describe('php command', () => { ); return true; }); - const exitSpy = vi - .spyOn(process, 'exit') - .mockImplementation((code?: string | number | null) => { - throw new ProcessExitCalled(Number(code)); - }); try { - await expect( - runCLI({ - command: 'php', - _: ['php', '-r', 'error_log("test error");'], - wordpressInstallMode: 'do-not-attempt-installing', - skipSqliteSetup: true, - blueprint: undefined, - }) - ).rejects.toThrow(ProcessExitCalled); - expect(exitSpy).toHaveBeenCalledWith(0); + const exitCode = await runCLI({ + command: 'php', + _: ['php', '-r', 'error_log("test error");'], + wordpressInstallMode: 'do-not-attempt-installing', + skipSqliteSetup: true, + blueprint: undefined, + }); + expect(exitCode).toBe(0); expect(stderrChunks.join('')).toContain('test error'); } finally { stdoutSpy.mockRestore(); stderrSpy.mockRestore(); - exitSpy.mockRestore(); } }); @@ -1210,34 +1180,26 @@ describe('php command', () => { const stderrSpy = vi .spyOn(process.stderr, 'write') .mockImplementation(() => true); - const exitSpy = vi - .spyOn(process, 'exit') - .mockImplementation((code?: string | number | null) => { - throw new ProcessExitCalled(Number(code)); - }); try { - await expect( - runCLI({ - command: 'php', - _: ['php', '/tools/wp-cli.phar', '--version'], - wordpressInstallMode: 'do-not-attempt-installing', - skipSqliteSetup: true, - blueprint: undefined, - 'mount-before-install': [ - { - hostPath: tmpDir, - vfsPath: '/tools', - }, - ], - }) - ).rejects.toThrow(ProcessExitCalled); - expect(exitSpy).toHaveBeenCalledWith(0); + const exitCode = await runCLI({ + command: 'php', + _: ['php', '/tools/wp-cli.phar', '--version'], + wordpressInstallMode: 'do-not-attempt-installing', + skipSqliteSetup: true, + blueprint: undefined, + 'mount-before-install': [ + { + hostPath: tmpDir, + vfsPath: '/tools', + }, + ], + }); + expect(exitCode).toBe(0); expect(stdoutChunks.join('')).toMatch(/WP-CLI \d+\.\d+/); } finally { stdoutSpy.mockRestore(); stderrSpy.mockRestore(); - exitSpy.mockRestore(); rmSync(tmpDir, { recursive: true }); } }); @@ -1267,36 +1229,28 @@ describe('php command', () => { const stderrSpy = vi .spyOn(process.stderr, 'write') .mockImplementation(() => true); - const exitSpy = vi - .spyOn(process, 'exit') - .mockImplementation((code?: string | number | null) => { - throw new ProcessExitCalled(Number(code)); - }); try { - await expect( - runCLI({ - command: 'php', - _: ['php', '/tools/composer.phar', '--version'], - wordpressInstallMode: 'do-not-attempt-installing', - skipSqliteSetup: true, - blueprint: undefined, - 'mount-before-install': [ - { - hostPath: tmpDir, - vfsPath: '/tools', - }, - ], - }) - ).rejects.toThrow(ProcessExitCalled); - expect(exitSpy).toHaveBeenCalledWith(0); + const exitCode = await runCLI({ + command: 'php', + _: ['php', '/tools/composer.phar', '--version'], + wordpressInstallMode: 'do-not-attempt-installing', + skipSqliteSetup: true, + blueprint: undefined, + 'mount-before-install': [ + { + hostPath: tmpDir, + vfsPath: '/tools', + }, + ], + }); + expect(exitCode).toBe(0); expect(stdoutChunks.join('')).toMatch( /Composer version \d+\.\d+/ ); } finally { stdoutSpy.mockRestore(); stderrSpy.mockRestore(); - exitSpy.mockRestore(); } } finally { rmSync(tmpDir, { recursive: true }); @@ -1345,9 +1299,6 @@ describe('other run-cli behaviors', () => { const consoleSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}); - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { - throw new Error('process.exit called'); - }); try { await expect( @@ -1359,7 +1310,6 @@ describe('other run-cli behaviors', () => { ).rejects.toThrow(); } finally { consoleSpy.mockRestore(); - exitSpy.mockRestore(); } }); }); @@ -1650,90 +1600,56 @@ describe('other run-cli behaviors', () => { }); }); - describe('signal handling', () => { - test.each(['SIGINT', 'SIGTERM'] as const)( - 'should clean up and exit on %s', - async (signal) => { - const listenersBeforeRunCli = process.listeners(signal).slice(); - - const exitSpy = vi.spyOn(process, 'exit').mockImplementation( - // Stop the test from actually causing the process to exit. - (() => {}) as any - ); - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - await using cliResult = await parseOptionsAndRunCLI([ - 'server', - '--wordpress-install-mode=do-not-attempt-installing', - '--skip-sqlite-setup', - '--verbosity=quiet', - '--port=0', - ]); - const cliServer = cliResult[internalsKeyForTesting].cliServer; - - const asyncDisposeSpy = vi - .spyOn(cliServer, Symbol.asyncDispose) - .mockImplementation((() => {}) as any); - - try { - // process.exit should not have been called during startup - expect(exitSpy).not.toHaveBeenCalled(); - - // Find the handler registered by parseOptionsAndRunCLI - const newListenersAfterRunCli = process - .listeners(signal) - .filter((l) => !listenersBeforeRunCli.includes(l)); - expect(newListenersAfterRunCli).toHaveLength(1); - - // Invoke the handler and await its async cleanup - await Promise.all( - newListenersAfterRunCli - .map((listener) => listener as () => Promise) - .map((listener) => listener()) - ); + describe('async dispose', () => { + test('should clean up the CLI server when disposed', async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + await using cliResult = (await parseOptionsAndRunCLI([ + 'server', + '--wordpress-install-mode=do-not-attempt-installing', + '--skip-sqlite-setup', + '--verbosity=quiet', + '--port=0', + ])) as CLIServerResult; + const cliServer = cliResult[internalsKeyForTesting].cliServer; + + const asyncDisposeSpy = vi + .spyOn(cliServer, Symbol.asyncDispose) + .mockImplementation((() => {}) as any); - expect(asyncDisposeSpy).toHaveBeenCalled(); - expect(exitSpy).toHaveBeenCalledWith(0); - } finally { - exitSpy.mockRestore(); - asyncDisposeSpy.mockRestore(); - } + try { + await cliResult[Symbol.asyncDispose](); + expect(asyncDisposeSpy).toHaveBeenCalled(); + } finally { + asyncDisposeSpy.mockRestore(); } - ); + }); }); describe('WP_DEBUG constants', () => { async function getConstants(cliArgs: string[]) { - const exitSpy = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as any); - try { - await using cliResult = await parseOptionsAndRunCLI([ - 'server', - '--wordpress-install-mode=do-not-attempt-installing', - '--skip-sqlite-setup', - '--verbosity=quiet', - '--port=0', - ...cliArgs, + await using cliResult = (await parseOptionsAndRunCLI([ + 'server', + '--wordpress-install-mode=do-not-attempt-installing', + '--skip-sqlite-setup', + '--verbosity=quiet', + '--port=0', + ...cliArgs, + ])) as CLIServerResult; + const cliServer = cliResult[internalsKeyForTesting].cliServer; + await cliServer.playground.writeFile( + '/wordpress/check-consts.php', + ` defined('WP_DEBUG') ? WP_DEBUG : '__UNDEFINED__', + 'WP_DEBUG_LOG' => defined('WP_DEBUG_LOG') ? WP_DEBUG_LOG : '__UNDEFINED__', + 'WP_DEBUG_DISPLAY' => defined('WP_DEBUG_DISPLAY') ? WP_DEBUG_DISPLAY : '__UNDEFINED__', ]); - const cliServer = cliResult[internalsKeyForTesting].cliServer; - await cliServer.playground.writeFile( - '/wordpress/check-consts.php', - ` defined('WP_DEBUG') ? WP_DEBUG : '__UNDEFINED__', - 'WP_DEBUG_LOG' => defined('WP_DEBUG_LOG') ? WP_DEBUG_LOG : '__UNDEFINED__', - 'WP_DEBUG_DISPLAY' => defined('WP_DEBUG_DISPLAY') ? WP_DEBUG_DISPLAY : '__UNDEFINED__', - ]); - ` - ); - const response = await fetch( - new URL('/check-consts.php', cliServer.serverUrl) - ); - return JSON.parse(await response.text()); - } finally { - exitSpy.mockRestore(); - } + ` + ); + const response = await fetch( + new URL('/check-consts.php', cliServer.serverUrl) + ); + return JSON.parse(await response.text()); } test('should override WP_DEBUG constants via --define-bool', async () => { @@ -1806,19 +1722,174 @@ describe('other run-cli behaviors', () => { }); }); - describe('port in use', () => { - test('should error when explicit port is already in use', async () => { - const stdoutMessages: string[] = []; - const mockStdout = vi + describe('return types', () => { + test('runCLI returns void for run-blueprint command', async () => { + const result = await runCLI({ + command: 'run-blueprint', + wordpressInstallMode: 'do-not-attempt-installing', + skipSqliteSetup: true, + blueprint: undefined, + }); + expect(result).toBeUndefined(); + }); + + test('runCLI returns void for build-snapshot command', async () => { + const tmpDir = await mkdtemp( + path.join(tmpdir(), 'playground-snapshot-test-') + ); + const outfile = path.join(tmpDir, 'snapshot.zip'); + try { + const result = await runCLI({ + command: 'build-snapshot', + blueprint: undefined, + outfile, + }); + expect(result).toBeUndefined(); + expect(existsSync(outfile)).toBe(true); + } finally { + rmSync(tmpDir, { recursive: true }); + } + }, 60_000 /* allow extra time to avoid testing timeouts on Windows */); + + test('runCLI returns RunCLIServer for server command', async () => { + await using result = await runCLI({ + command: 'server', + wordpressInstallMode: 'do-not-attempt-installing', + skipSqliteSetup: true, + blueprint: undefined, + }); + expect(result).toBeDefined(); + expect(result.serverUrl).toMatch(/^http:\/\//); + expect(result[Symbol.asyncDispose]).toBeTypeOf('function'); + }); + + test('runCLI returns exit code for php command', async () => { + const stdoutSpy = vi .spyOn(process.stdout, 'write') - .mockImplementation((chunk) => { - stdoutMessages.push(String(chunk)); - return true; + .mockImplementation(() => true); + try { + const exitCode = await runCLI({ + command: 'php', + _: ['php', '-r', 'echo 1;'], + wordpressInstallMode: 'do-not-attempt-installing', + skipSqliteSetup: true, + blueprint: undefined, }); - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never); + expect(exitCode).toBeTypeOf('number'); + expect(exitCode).toBe(0); + } finally { + stdoutSpy.mockRestore(); + } + }); + + test('parseOptionsAndRunCLI returns CLIExitResult for run-blueprint', async () => { + const result = await parseOptionsAndRunCLI([ + 'run-blueprint', + '--wordpress-install-mode=do-not-attempt-installing', + '--skip-sqlite-setup', + '--verbosity=quiet', + ]); + expect('exitCode' in result).toBe(true); + expect((result as CLIExitResult).exitCode).toBe(0); + }); + + test('parseOptionsAndRunCLI returns CLIServerResult for server', async () => { + await using result = (await parseOptionsAndRunCLI([ + 'server', + '--wordpress-install-mode=do-not-attempt-installing', + '--skip-sqlite-setup', + '--verbosity=quiet', + '--port=0', + ])) as CLIServerResult; + expect(Symbol.asyncDispose in result).toBe(true); + expect('exitCode' in result).toBe(false); + }); + + test('parseOptionsAndRunCLI returns CLIExitResult for build-snapshot', async () => { + const tmpDir = await mkdtemp( + path.join(tmpdir(), 'playground-snapshot-test-') + ); + const outfile = path.join(tmpDir, 'snapshot.zip'); + try { + const result = await parseOptionsAndRunCLI([ + 'build-snapshot', + `--outfile=${outfile}`, + '--verbosity=quiet', + ]); + expect('exitCode' in result).toBe(true); + expect((result as CLIExitResult).exitCode).toBe(0); + } finally { + rmSync(tmpDir, { recursive: true }); + } + }, 60_000 /* allow extra time to avoid testing timeouts on Windows */); + test('parseOptionsAndRunCLI returns CLIExitResult for php command', async () => { + const stdoutSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + try { + const result = await parseOptionsAndRunCLI([ + 'php', + '--wordpress-install-mode=do-not-attempt-installing', + '--skip-sqlite-setup', + '--verbosity=quiet', + '--', + '-r', + 'echo 1;', + ]); + expect('exitCode' in result).toBe(true); + expect((result as CLIExitResult).exitCode).toBe(0); + } finally { + stdoutSpy.mockRestore(); + } + }); + + test('parseOptionsAndRunCLI returns CLIExitResult for invalid command', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + try { + const result = await parseOptionsAndRunCLI([ + 'not-a-real-command', + ]); + expect(result).toHaveProperty('exitCode', 1); + } finally { + consoleSpy.mockRestore(); + } + }); + + test('parseOptionsAndRunCLI returns CLIExitResult for missing command', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + try { + const result = await parseOptionsAndRunCLI([]); + expect(result).toHaveProperty('exitCode', 1); + } finally { + consoleSpy.mockRestore(); + } + }); + + test('parseOptionsAndRunCLI throws for unexpected errors', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + try { + await expect( + parseOptionsAndRunCLI([ + 'server', + '--phpmyadmin', + '--skip-sqlite-setup', + ]) + ).rejects.toThrow(); + } finally { + consoleSpy.mockRestore(); + } + }); + }); + + describe('port in use', () => { + test('should error when explicit port is already in use', async () => { const port = 12345; const blockingServer = http.createServer(); await new Promise((resolve) => { @@ -1826,17 +1897,13 @@ describe('other run-cli behaviors', () => { }); try { - await runCLI({ - command: 'server', - port, - }); - - expect(stdoutMessages.join('')).toContain( - `Error: listen EADDRINUSE: address already in use :::${port}` - ); + await expect( + runCLI({ + command: 'server', + port, + }) + ).rejects.toThrow(`EADDRINUSE`); } finally { - mockExit.mockRestore(); - mockStdout.mockRestore(); blockingServer.close(); } });