diff --git a/packages/rstack/test/helpers/cli.ts b/packages/rstack/test/helpers/cli.ts index ac13e6b..9693df5 100644 --- a/packages/rstack/test/helpers/cli.ts +++ b/packages/rstack/test/helpers/cli.ts @@ -2,7 +2,7 @@ import { type ExecSyncOptions, execSync } from 'node:child_process'; import path from 'node:path'; import type { LogHelper } from './logs.ts'; -const RSTACK_BIN_PATH = path.join(import.meta.dirname, '../../bin/rs.js'); +export const RSTACK_BIN_PATH: string = path.join(import.meta.dirname, '../../bin/rs.js'); export type ExecCliOptions = ExecSyncOptions & { logHelper?: LogHelper; diff --git a/packages/rstack/test/helpers/cliTest.ts b/packages/rstack/test/helpers/cliTest.ts index 25e8876..df0ba85 100644 --- a/packages/rstack/test/helpers/cliTest.ts +++ b/packages/rstack/test/helpers/cliTest.ts @@ -1,11 +1,21 @@ +import { type ChildProcess, type SpawnOptions, spawn as nodeSpawn } from 'node:child_process'; import path from 'node:path'; import { test as baseTest } from 'rstack/test'; -import { execCli as baseExecCli, type ExecCli } from './cli.ts'; +import { execCli as baseExecCli, type ExecCli, RSTACK_BIN_PATH } from './cli.ts'; import { type ExtendedLogHelper, proxyConsole } from './logs.ts'; +type Exec = ( + command: string, + options?: SpawnOptions, +) => { + childProcess: ChildProcess; +}; + export type CliTestFixtures = { cwd: string; + exec: Exec; execCli: ExecCli; + execCliAsync: Exec; logHelper: ExtendedLogHelper; }; @@ -20,6 +30,15 @@ function makeBox(title: string) { }; } +const setupExecOptions = (options: T, cwd: string): T => { + // inherit process.env from current process + const { NODE_ENV: _, ...restEnv } = process.env; + options.env ||= {}; + options.env = { ...restEnv, ...options.env }; + options.cwd ||= cwd; + return options; +}; + export const test: CliTest = baseTest.extend({ cwd: async ({ expect }, use) => { const { testPath } = expect.getState(); @@ -55,4 +74,40 @@ export const test: CliTest = baseTest.extend({ await use(execCli); }, + exec: async ({ cwd, logHelper }, use) => { + const closes: Array<() => void> = []; + + const exec: Exec = (command, options = {}) => { + const childProcess = nodeSpawn(command, setupExecOptions({ shell: true, ...options }, cwd)); + + const onData = (data: Buffer) => { + logHelper.addLog(data.toString()); + }; + + childProcess.stdout?.on('data', onData); + childProcess.stderr?.on('data', onData); + + closes.push(() => { + childProcess.stdout?.off('data', onData); + childProcess.stderr?.off('data', onData); + childProcess.kill(); + }); + + return { childProcess }; + }; + + try { + await use(exec); + } finally { + for (const close of closes) { + close(); + } + } + }, + execCliAsync: async ({ exec }, use) => { + const execCliAsync: Exec = (command, options = {}) => { + return exec(`node "${RSTACK_BIN_PATH}" ${command}`, options); + }; + await use(execCliAsync); + }, }); diff --git a/packages/rstack/test/helpers/utils.ts b/packages/rstack/test/helpers/utils.ts index fecbeab..e50b1fa 100644 --- a/packages/rstack/test/helpers/utils.ts +++ b/packages/rstack/test/helpers/utils.ts @@ -1 +1,61 @@ +import fs from 'node:fs'; +import net from 'node:net'; +import { expect } from 'rstack/test'; + export const toPosixPath: (filePath: string) => string = (filePath) => filePath.replace(/\\/g, '/'); + +function isPortAvailable(port: number) { + try { + const server = net.createServer().listen(port); + return new Promise((resolve) => { + server.on('listening', () => { + server.close(() => { + resolve(true); + }); + }); + server.on('error', () => { + resolve(false); + }); + }); + } catch { + return Promise.resolve(false); + } +} + +const portMap = new Map(); + +/** + * Get a random port + * Available port ranges: 1024 ~ 65535 + * `10080` is not available on macOS CI, `> 50000` get 'permission denied' on Windows. + * so we use `15000` ~ `45000`. + */ +export async function getRandomPort( + defaultPort: number = Math.ceil(Math.random() * 30000) + 15000, +): Promise { + let port = defaultPort; + while (port <= 65535) { + if (!portMap.get(port) && (await isPortAvailable(port))) { + portMap.set(port, 1); + return port; + } + port++; + } + throw new Error('No available ports found in the valid range.'); +} + +/** + * Expect a file to exist + */ +export const expectFile = (dir: string): Promise => + expectPoll(() => fs.existsSync(dir)).toBeTruthy(); + +/** + * A faster `expect.poll` + */ +export const expectPoll: typeof expect.poll = (fn) => { + return expect.poll(fn, { + interval: 50, + timeout: 5_000, + }); +};