diff --git a/packages/wb/src/commands/concurrently.ts b/packages/wb/src/commands/concurrently.ts new file mode 100644 index 00000000..c89778e3 --- /dev/null +++ b/packages/wb/src/commands/concurrently.ts @@ -0,0 +1,197 @@ +import child_process from 'node:child_process'; +import { constants } from 'node:os'; + +import { treeKill } from '@willbooster/shared-lib-node/src'; +import chalk from 'chalk'; +import type { CommandModule, InferredOptionTypes } from 'yargs'; + +import { findSelfProject } from '../project.js'; +import { sharedOptionsBuilder } from '../sharedOptionsBuilder.js'; + +const builder = { + 'kill-others': { + description: 'Kill other commands when one command exits', + type: 'boolean', + }, + 'kill-others-on-fail': { + description: 'Kill other commands when one command fails', + type: 'boolean', + }, + success: { + description: 'Define successful completion criteria', + type: 'string', + choices: ['all', 'first'], + default: 'all', + }, +} as const; + +const argumentsBuilder = { + commands: { + description: 'Commands to run concurrently', + type: 'array', + }, +} as const; + +interface RunConcurrentlyOptions { + commands: string[]; + cwd: string; + env: Record; + killOthers: boolean; + killOthersOnFail: boolean; + success: 'all' | 'first'; +} + +export const concurrentlyCommand: CommandModule< + unknown, + InferredOptionTypes +> = { + command: 'concurrently ', + describe: 'Run commands concurrently', + builder: { ...sharedOptionsBuilder, ...builder, ...argumentsBuilder }, + async handler(argv) { + const project = findSelfProject(argv); + if (!project) { + console.error(chalk.red('No project found.')); + process.exit(1); + } + + const commands = (argv.commands ?? []).map(String).filter(Boolean); + if (commands.length === 0) { + console.error(chalk.red('No commands provided.')); + process.exit(1); + } + + try { + const exitCode = await runConcurrently({ + commands, + cwd: project.dirPath, + env: project.env, + killOthers: argv.killOthers ?? false, + killOthersOnFail: argv.killOthersOnFail ?? false, + success: argv.success, + }); + process.exit(exitCode); + } catch (error) { + console.error(chalk.red(error instanceof Error ? error.message : String(error))); + process.exit(1); + } + }, +}; + +export async function runConcurrently(options: RunConcurrentlyOptions): Promise { + const children = options.commands.map((command) => + child_process.spawn(command, { + cwd: options.cwd, + detached: process.platform !== 'win32', + env: options.env, + shell: true, + stdio: 'inherit', + }) + ); + + let stopping = false; + let firstResult: number | undefined; + const results = Array.from({ length: children.length }); + const waitForExitPromises = children.map((child, index) => { + return new Promise((resolve) => { + let settled = false; + const settle = (exitCode: number): void => { + if (settled) return; + + settled = true; + results[index] = exitCode; + firstResult ??= exitCode; + + if (!stopping && shouldStopOthers(exitCode, options)) { + stopping = true; + terminateChildren(children); + } + resolve(); + }; + + child.once('error', (error) => { + console.error('Failed to start child process:', error); + settle(1); + }); + child.once('exit', (code, signal) => { + settle(getExitCode(code, signal)); + }); + }); + }); + + const stopAll = (): void => { + if (stopping) return; + + stopping = true; + terminateChildren(children); + }; + process.on('SIGINT', stopAll); + process.on('SIGTERM', stopAll); + process.on('SIGQUIT', stopAll); + try { + await Promise.all(waitForExitPromises); + } finally { + process.removeListener('SIGINT', stopAll); + process.removeListener('SIGTERM', stopAll); + process.removeListener('SIGQUIT', stopAll); + } + + if (options.success === 'first') { + return firstResult ?? 1; + } + for (const result of results) { + if (result !== undefined && result !== 0) { + return result; + } + } + return 0; +} + +function getExitCode(code: number | null, signal: NodeJS.Signals | null): number { + if (code !== null) { + return code; + } + if (signal && signal in constants.signals) { + return 128 + constants.signals[signal]; + } + return 1; +} + +function shouldStopOthers( + exitCode: number, + options: Pick +): boolean { + return options.success === 'first' || options.killOthers || (options.killOthersOnFail && exitCode !== 0); +} + +function terminateChildren(children: child_process.ChildProcess[], exceptPid?: number): void { + for (const child of children) { + if (!child.pid || child.pid === exceptPid) continue; + + try { + killProcessGroup(child.pid); + treeKill(child.pid); + } catch (error) { + console.warn('Failed to kill child process:', error); + } + } +} + +function killProcessGroup(pid: number): void { + if (process.platform === 'win32') { + return; + } + + try { + process.kill(-pid, 'SIGTERM'); + } catch (error) { + if (isNoSuchProcessError(error)) { + return; + } + throw error; + } +} + +function isNoSuchProcessError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH'; +} diff --git a/packages/wb/src/commands/lint.ts b/packages/wb/src/commands/lint.ts index a5719215..2a424656 100644 --- a/packages/wb/src/commands/lint.ts +++ b/packages/wb/src/commands/lint.ts @@ -7,6 +7,7 @@ import type { Project } from '../project.js'; import { findDescendantProjects } from '../project.js'; import { runWithSpawnInParallel } from '../scripts/run.js'; import type { sharedOptionsBuilder } from '../sharedOptionsBuilder.js'; +import { buildShellCommand } from '../utils/shell.js'; const builder = { fix: { @@ -281,11 +282,3 @@ function supportsLintingExtension(project: Pick, ext function needsPrettier(project: Pick): boolean { return project.preferredLinter === 'eslint'; } - -function buildShellCommand(args: string[]): string { - return args.map((arg) => shellEscapeArgument(arg)).join(' '); -} - -function shellEscapeArgument(arg: string): string { - return /^[\w./:=,@%+-]+$/u.test(arg) ? arg : `'${arg.replaceAll("'", `'"'"'`)}'`; -} diff --git a/packages/wb/src/commands/test.ts b/packages/wb/src/commands/test.ts index a34c4885..8f9340b0 100644 --- a/packages/wb/src/commands/test.ts +++ b/packages/wb/src/commands/test.ts @@ -147,17 +147,21 @@ export async function test( continue; } case 'docker-debug': { - const e2eTarget = e2eTargets.length > 0 ? e2eTargets.join(' ') : 'test/e2e/'; - await testOnDocker(project, e2eArgv, scripts, `test ${e2eTarget} --debug`); + await testOnDocker(project, e2eArgv, scripts, [ + 'test', + ...(e2eTargets.length > 0 ? e2eTargets : ['test/e2e/']), + '--debug', + ]); continue; } } if (deps.blitz || deps.next || devDeps['@remix-run/dev'] || devDeps.vite) { - const e2eTarget = e2eTargets.length > 0 ? e2eTargets.join(' ') : 'test/e2e/'; switch (argv.e2e) { case 'headed': { await runWithSpawn( - await scripts.testE2EProduction(project, e2eArgv, { playwrightArgs: `test ${e2eTarget} --headed` }), + await scripts.testE2EProduction(project, e2eArgv, { + playwrightArgs: ['test', ...(e2eTargets.length > 0 ? e2eTargets : ['test/e2e/']), '--headed'], + }), project, argv ); @@ -165,7 +169,9 @@ export async function test( } case 'headed-dev': { await runWithSpawn( - await scripts.testE2EDev(project, e2eArgv, { playwrightArgs: `test ${e2eTarget} --headed` }), + await scripts.testE2EDev(project, e2eArgv, { + playwrightArgs: ['test', ...(e2eTargets.length > 0 ? e2eTargets : ['test/e2e/']), '--headed'], + }), project, argv ); @@ -173,7 +179,9 @@ export async function test( } case 'debug': { await runWithSpawn( - await scripts.testE2EProduction(project, e2eArgv, { playwrightArgs: `test ${e2eTarget} --debug` }), + await scripts.testE2EProduction(project, e2eArgv, { + playwrightArgs: ['test', ...(e2eTargets.length > 0 ? e2eTargets : ['test/e2e/']), '--debug'], + }), project, argv ); @@ -182,7 +190,7 @@ export async function test( case 'generate': { await runWithSpawn( await scripts.testE2EProduction(project, e2eArgv, { - playwrightArgs: `codegen http://localhost:${project.env.PORT}`, + playwrightArgs: ['codegen', `http://localhost:${project.env.PORT}`], }), project, argv @@ -202,7 +210,7 @@ async function testOnDocker( project: Project, argv: ArgumentsCamelCase>, scripts: BaseScripts, - playwrightArgs?: string + playwrightArgs?: string[] ): Promise { project.env.WB_DOCKER ||= '1'; await runWithSpawn(`${scripts.buildDocker(project, 'test')}${toDevNull(argv)}`, project, argv); diff --git a/packages/wb/src/index.ts b/packages/wb/src/index.ts index 09364c3c..30fc1651 100644 --- a/packages/wb/src/index.ts +++ b/packages/wb/src/index.ts @@ -6,6 +6,7 @@ import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import { buildIfNeededCommand } from './commands/buildIfNeeded.js'; +import { concurrentlyCommand } from './commands/concurrently.js'; import { killPortIfNonCiCommand } from './commands/killPortIfNonCi.js'; import { lintCommand } from './commands/lint.js'; import { optimizeForDockerBuildCommand } from './commands/optimizeForDockerBuild.js'; @@ -32,6 +33,7 @@ await yargs(hideBin(process.argv)) removeNpmAndYarnEnvironmentVariables(process.env); }) .command(buildIfNeededCommand) + .command(concurrentlyCommand) .command(killPortIfNonCiCommand) .command(lintCommand) .command(optimizeForDockerBuildCommand) diff --git a/packages/wb/src/scripts/builder.ts b/packages/wb/src/scripts/builder.ts index 9c86fcb1..3f1034cb 100644 --- a/packages/wb/src/scripts/builder.ts +++ b/packages/wb/src/scripts/builder.ts @@ -1,5 +1,7 @@ import type { ArgumentsCamelCase, InferredOptionTypes } from 'yargs'; +import { shellEscapeArgument } from '../utils/shell.js'; + export const scriptOptionsBuilder = { watch: { description: 'Whether to watch files', @@ -29,9 +31,11 @@ export function normalizeArgs( argv: Partial>> ): void { (argv as ScriptArgv).normalizedArgsText = [...(argv.args ?? []), ...(argv._?.slice(1) ?? [])] - .map((arg) => `'${arg}'`) + .map((arg) => shellEscapeArgument(String(arg))) + .join(' '); + (argv as ScriptArgv).normalizedDockerOptionsText = (argv.dockerOptions ?? []) + .map((arg) => shellEscapeArgument(String(arg))) .join(' '); - (argv as ScriptArgv).normalizedDockerOptionsText = (argv.dockerOptions ?? []).map((arg) => `'${arg}'`).join(' '); } export function toDevNull(argv: unknown): string { diff --git a/packages/wb/src/scripts/execution/baseScripts.ts b/packages/wb/src/scripts/execution/baseScripts.ts index 7538b235..e2ff4544 100644 --- a/packages/wb/src/scripts/execution/baseScripts.ts +++ b/packages/wb/src/scripts/execution/baseScripts.ts @@ -1,6 +1,8 @@ import type { TestArgv } from '../../commands/test.js'; import type { Project } from '../../project.js'; +import { buildEnvReaderOptionArgs } from '../../sharedOptionsBuilder.js'; import { checkAndKillPortProcess } from '../../utils/port.js'; +import { buildShellCommand, buildShellEnvironmentAssignment } from '../../utils/shell.js'; import type { ScriptArgv } from '../builder.js'; import { toDevNull } from '../builder.js'; import { dockerScripts } from '../dockerScripts.js'; @@ -8,7 +10,7 @@ import { prismaScripts } from '../prismaScripts.js'; export interface TestE2EOptions { /** '--e2e generate' calls 'codegen http://localhost:8080' */ - playwrightArgs?: string; + playwrightArgs?: string[]; } /** @@ -30,17 +32,29 @@ export abstract class BaseScripts { await checkAndKillPortProcess(project.env.PORT, project); if (!this.shouldWaitAndOpenApp) return this.startDevProtected(project, argv); - return `YARN concurrently --raw --kill-others-on-fail - "${this.startDevProtected(project, argv)}" - "${this.waitAndOpenApp(project)}"`; + return buildShellCommand([ + 'YARN', + 'wb', + 'concurrently', + ...buildEnvReaderOptionArgs(argv), + '--kill-others-on-fail', + this.startDevProtected(project, argv), + this.waitAndOpenApp(project), + ]); } async startProduction(project: Project, argv: ScriptArgv): Promise { await checkAndKillPortProcess(project.env.PORT, project); if (!this.shouldWaitAndOpenApp) return this.startProductionProtected(project, argv); - return `YARN concurrently --raw --kill-others-on-fail - "${this.startProductionProtected(project, argv)}" - "${this.waitAndOpenApp(project)}"`; + return buildShellCommand([ + 'YARN', + 'wb', + 'concurrently', + ...buildEnvReaderOptionArgs(argv), + '--kill-others-on-fail', + this.startProductionProtected(project, argv), + this.waitAndOpenApp(project), + ]); } async startTest(project: Project, argv: ScriptArgv): Promise { await checkAndKillPortProcess(project.env.PORT, project); @@ -54,9 +68,15 @@ export abstract class BaseScripts { } return `${this.buildDocker(project, 'development')} - && YARN concurrently --raw --kill-others-on-fail - "${dockerScripts.stopAndStart(project, argv.normalizedDockerOptionsText ?? '', argv.normalizedArgsText ?? '')}" - "${this.waitAndOpenApp(project)}"`; + && ${buildShellCommand([ + 'YARN', + 'wb', + 'concurrently', + ...buildEnvReaderOptionArgs(argv), + '--kill-others-on-fail', + dockerScripts.stopAndStart(project, argv.normalizedDockerOptionsText ?? '', argv.normalizedArgsText ?? ''), + this.waitAndOpenApp(project), + ])}`; } protected abstract startDevProtected(_: Project, argv: ScriptArgv): string; @@ -90,14 +110,24 @@ export abstract class BaseScripts { async testStart(project: Project, argv: ScriptArgv): Promise { await checkAndKillPortProcess(project.env.PORT, project); // Use empty NODE_ENV to avoid "production" mode in some frameworks like Blitz.js. - return `NODE_ENV="" YARN concurrently --kill-others --raw --success first "${this.startDevProtected(project, argv)}" "${this.waitApp(project)}"`; + return `${buildShellEnvironmentAssignment('NODE_ENV', '')} ${buildShellCommand([ + 'YARN', + 'wb', + 'concurrently', + ...buildEnvReaderOptionArgs(argv), + '--kill-others', + '--success', + 'first', + this.startDevProtected(project, argv), + this.waitApp(project), + ])}`; } async testE2EProtected( project: Project, argv: TestArgv, startCommand: string, - { playwrightArgs = 'test test/e2e/' }: TestE2EOptions + { playwrightArgs = ['test', 'test/e2e/'] }: TestE2EOptions ): Promise { const port = await checkAndKillPortProcess(project.env.PORT, project); const suffix = project.packageJson.scripts?.['test/e2e-additional'] ? ' && YARN test/e2e-additional' : ''; @@ -106,22 +136,43 @@ export abstract class BaseScripts { return `${playwrightCommand}${suffix}`; } - return `YARN concurrently --kill-others --raw --success first - "${startCommand} && exit 1" - "wait-on -t 600000 -i 2000 http-get://127.0.0.1:${port} - && ${playwrightCommand}${suffix}"`; + return buildShellCommand([ + 'YARN', + 'wb', + 'concurrently', + ...buildEnvReaderOptionArgs(argv), + '--kill-others', + '--success', + 'first', + `${startCommand} && exit 1`, + `wait-on -t 600000 -i 2000 http-get://127.0.0.1:${port} + && ${playwrightCommand}${suffix}`, + ]); } // ------------ END: test (e2e) commands ------------ testUnit(project: Project, argv: TestArgv): string { - const testTarget = argv.targets?.join(' ') || 'test/unit/'; + const targets = argv.targets?.map(String); if (project.hasVitest) { - const bailOption = argv.bail ? ' --bail=1' : ''; // Since this command is referred from other commands, we have to use "vitest run" (non-interactive mode). - return `YARN vitest run ${testTarget} --color --passWithNoTests --allowOnly --watch=false${bailOption}`; + return buildShellCommand([ + 'YARN', + 'vitest', + 'run', + ...(targets?.length ? targets : ['test/unit/']), + '--color', + '--passWithNoTests', + '--allowOnly', + '--watch=false', + ...(argv.bail ? ['--bail=1'] : []), + ]); } else if (project.isBunAvailable) { - const bailOption = argv.bail ? ' --bail' : ''; - return `bun test ${testTarget}${bailOption}`; + return buildShellCommand([ + 'bun', + 'test', + ...(targets?.length ? targets : ['test/unit/']), + ...(argv.bail ? ['--bail'] : []), + ]); } return 'echo "No tests."'; } @@ -153,29 +204,98 @@ function findEcosystemConfigPath(project: Project): string | undefined { } } -function buildPlaywrightCommand(playwrightArgs: string, targets: TestArgv['targets'], bail?: boolean): string { - const base = 'BUN playwright'; - const target = targets?.join(' ') || 'test/e2e/'; - if (!playwrightArgs.startsWith('test ') || !targets?.length) { - return appendPlaywrightBailOption(`${base} ${playwrightArgs}`, bail); +function buildPlaywrightCommand(playwrightArgs: string[], targets: TestArgv['targets'], bail?: boolean): string { + const base = ['BUN', 'playwright']; + const normalizedTargets = targets?.map(String); + if (playwrightArgs[0] !== 'test' || !normalizedTargets?.length) { + return appendPlaywrightBailOption([...base, ...playwrightArgs], bail); } - const rest = playwrightArgs.slice('test '.length).trim(); - const parts = rest.length > 0 ? rest.split(/\s+/) : []; - if (!parts[0] || parts[0].startsWith('-')) { - parts.unshift(target); - } else { - parts[0] = target; - } - return appendPlaywrightBailOption(`${base} test ${parts.join(' ')}`, bail); + const rest = playwrightArgs.slice(1); + const explicitTargetIndexes = findExplicitPlaywrightTargetIndexes(rest); + const restWithoutExplicitTarget = + explicitTargetIndexes.length === 0 ? rest : rest.filter((_, index) => !explicitTargetIndexes.includes(index)); + return appendPlaywrightBailOption([...base, 'test', ...normalizedTargets, ...restWithoutExplicitTarget], bail); } -function appendPlaywrightBailOption(command: string, bail?: boolean): string { - if (!bail || !command.includes('playwright test')) { - return command; +function appendPlaywrightBailOption(commandArgs: string[], bail?: boolean): string { + const playwrightIndex = commandArgs.indexOf('playwright'); + const isPlaywrightTestCommand = playwrightIndex !== -1 && commandArgs[playwrightIndex + 1] === 'test'; + if (!bail || !isPlaywrightTestCommand) { + return buildShellCommand(commandArgs); + } + if (commandArgs.some((arg) => arg === '--max-failures' || arg.startsWith('--max-failures='))) { + return buildShellCommand(commandArgs); } - if (/--max-failures(?:=|\s)/.test(command)) { - return command; + return buildShellCommand([...commandArgs, '--max-failures=1']); +} + +function findExplicitPlaywrightTargetIndexes(args: string[]): number[] { + let pendingValueMode: 'optional' | 'required' | undefined; + const targetIndexes: number[] = []; + + for (const [index, arg] of args.entries()) { + if (pendingValueMode) { + if (pendingValueMode === 'required' || !arg.startsWith('-')) { + pendingValueMode = undefined; + continue; + } + pendingValueMode = undefined; + } + + if (arg === '--') { + return index + 1 < args.length ? [index + 1] : targetIndexes; + } + if (arg.startsWith('--')) { + if (arg.includes('=')) continue; + if (PLAYWRIGHT_TEST_OPTIONS_WITH_REQUIRED_VALUES.has(arg)) { + pendingValueMode = 'required'; + } else if (PLAYWRIGHT_TEST_OPTIONS_WITH_OPTIONAL_VALUES.has(arg)) { + pendingValueMode = 'optional'; + } + continue; + } + if (arg.startsWith('-') && arg !== '-') { + const shortOption = arg.slice(0, 2); + if (arg.length === 2 && PLAYWRIGHT_TEST_SHORT_OPTIONS_WITH_REQUIRED_VALUES.has(shortOption)) { + pendingValueMode = 'required'; + } + continue; + } + targetIndexes.push(index); } - return `${command} --max-failures=1`; + + return targetIndexes; } + +const PLAYWRIGHT_TEST_OPTIONS_WITH_REQUIRED_VALUES = new Set([ + '--browser', + '--config', + '--grep', + '--grep-invert', + '--global-timeout', + '--max-failures', + '--output', + '--project', + '--repeat-each', + '--reporter', + '--retries', + '--shard', + '--test-list', + '--test-list-invert', + '--timeout', + '--trace', + '--tsconfig', + '--ui-host', + '--ui-port', + '--ui-title', + '--workers', +]); + +const PLAYWRIGHT_TEST_OPTIONS_WITH_OPTIONAL_VALUES = new Set([ + '--only-changed', + '--update-snapshots', + '--update-source-method', +]); + +const PLAYWRIGHT_TEST_SHORT_OPTIONS_WITH_REQUIRED_VALUES = new Set(['-c', '-g', '-j']); diff --git a/packages/wb/src/scripts/execution/httpServerScripts.ts b/packages/wb/src/scripts/execution/httpServerScripts.ts index 16aca504..4131980f 100644 --- a/packages/wb/src/scripts/execution/httpServerScripts.ts +++ b/packages/wb/src/scripts/execution/httpServerScripts.ts @@ -1,6 +1,8 @@ import type { TestArgv } from '../../commands/test.js'; import type { Project } from '../../project.js'; +import { buildEnvReaderOptionArgs } from '../../sharedOptionsBuilder.js'; import { checkAndKillPortProcess } from '../../utils/port.js'; +import { buildShellCommand } from '../../utils/shell.js'; import type { ScriptArgv } from '../builder.js'; import { BaseScripts, type TestE2EOptions } from './baseScripts.js'; @@ -30,13 +32,27 @@ class HttpServerScripts extends BaseScripts { const port = await checkAndKillPortProcess(project.env.PORT, project); const suffix = project.packageJson.scripts?.['test/e2e-additional'] ? ' && YARN test/e2e-additional' : ''; - const testTarget = argv.targets && argv.targets.length > 0 ? argv.targets.join(' ') : 'test/e2e/'; - const bailOption = argv.bail ? ' --bail=1' : ''; - - return `YARN concurrently --kill-others --raw --success first - "${startCommand} && exit 1" - "wait-on -t 600000 -i 2000 http-get://127.0.0.1:${port} - && vitest run ${testTarget} --color --passWithNoTests --allowOnly${bailOption}${suffix}"`; + const targets = argv.targets?.map(String); + return buildShellCommand([ + 'YARN', + 'wb', + 'concurrently', + ...buildEnvReaderOptionArgs(argv), + '--kill-others', + '--success', + 'first', + `${startCommand} && exit 1`, + `wait-on -t 600000 -i 2000 http-get://127.0.0.1:${port} + && ${buildShellCommand([ + 'vitest', + 'run', + ...(targets && targets.length > 0 ? targets : ['test/e2e/']), + '--color', + '--passWithNoTests', + '--allowOnly', + ...(argv.bail ? ['--bail=1'] : []), + ])}${suffix}`, + ]); } } diff --git a/packages/wb/src/sharedOptionsBuilder.ts b/packages/wb/src/sharedOptionsBuilder.ts index 5494a930..eee66096 100644 --- a/packages/wb/src/sharedOptionsBuilder.ts +++ b/packages/wb/src/sharedOptionsBuilder.ts @@ -1,4 +1,5 @@ import { yargsOptionsBuilderForEnv } from '@willbooster/shared-lib-node/src'; +import type { EnvReaderOptions } from '@willbooster/shared-lib-node/src'; export const sharedOptionsBuilder = { ...yargsOptionsBuilderForEnv, @@ -14,3 +15,32 @@ export const sharedOptionsBuilder = { alias: ['dry', 'd'], }, } as const; + +export function buildEnvReaderOptionArgs(argv: EnvReaderOptions): string[] { + const args: string[] = []; + for (const optionName of Object.keys(yargsOptionsBuilderForEnv)) { + const value = getOptionValue(argv, optionName); + if (value === undefined) continue; + + if (typeof value === 'boolean') { + args.push(value ? `--${optionName}` : `--${optionName}=false`); + continue; + } + if (typeof value === 'string' || typeof value === 'number') { + args.push(`--${optionName}=${value}`); + continue; + } + if (Array.isArray(value)) { + for (const item of value) { + args.push(`--${optionName}=${item}`); + } + } + } + return args; +} + +function getOptionValue(argv: EnvReaderOptions, optionName: string): unknown { + const camelCaseOptionName = optionName.replaceAll(/-([a-z])/g, (_, character: string) => character.toUpperCase()); + const options = argv as Record; + return options[optionName] ?? options[camelCaseOptionName]; +} diff --git a/packages/wb/src/utils/shell.ts b/packages/wb/src/utils/shell.ts new file mode 100644 index 00000000..a19f1d88 --- /dev/null +++ b/packages/wb/src/utils/shell.ts @@ -0,0 +1,11 @@ +export function buildShellCommand(args: string[]): string { + return args.map((arg) => shellEscapeArgument(arg)).join(' '); +} + +export function buildShellEnvironmentAssignment(name: string, value: string): string { + return `${name}=${shellEscapeArgument(value)}`; +} + +export function shellEscapeArgument(arg: string): string { + return /^[\w./:=,@%+-]+$/u.test(arg) ? arg : `'${arg.replaceAll("'", `'"'"'`)}'`; +} diff --git a/packages/wb/test/concurrently.test.ts b/packages/wb/test/concurrently.test.ts new file mode 100644 index 00000000..02aac4ec --- /dev/null +++ b/packages/wb/test/concurrently.test.ts @@ -0,0 +1,225 @@ +import child_process from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { describe, expect, it, afterEach, vi } from 'vitest'; +import yargs from 'yargs'; + +import { concurrentlyCommand, runConcurrently } from '../src/commands/concurrently.js'; + +describe('runConcurrently', () => { + const env = process.env as Record; + const cwd = process.cwd(); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns success when all commands succeed', async () => { + const exitCode = await runConcurrently({ + commands: [ + `${process.execPath} -e "setTimeout(() => process.exit(0), 50)"`, + `${process.execPath} -e "setTimeout(() => process.exit(0), 100)"`, + ], + cwd, + env, + killOthers: false, + killOthersOnFail: false, + success: 'all', + }); + + expect(exitCode).toBe(0); + }); + + it('returns success when the first exiting command succeeds with success=first', async () => { + const exitCode = await runConcurrently({ + commands: [ + `${process.execPath} -e "setTimeout(() => process.exit(0), 120)"`, + `${process.execPath} -e "setTimeout(() => process.exit(0), 50)"`, + ], + cwd, + env, + killOthers: true, + killOthersOnFail: false, + success: 'first', + }); + + expect(exitCode).toBe(0); + }); + + it('returns failure when one command fails with kill-others-on-fail', async () => { + const exitCode = await runConcurrently({ + commands: [ + `${process.execPath} -e "setTimeout(() => process.exit(1), 40)"`, + `${process.execPath} -e "setTimeout(() => process.exit(0), 1000)"`, + ], + cwd, + env, + killOthers: false, + killOthersOnFail: true, + success: 'all', + }); + + expect(exitCode).not.toBe(0); + }); + + it('returns failure when the first exiting command fails with success=first', async () => { + const exitCode = await runConcurrently({ + commands: [ + `${process.execPath} -e "setTimeout(() => process.exit(0), 120)"`, + `${process.execPath} -e "setTimeout(() => process.exit(1), 40)"`, + ], + cwd, + env, + killOthers: false, + killOthersOnFail: false, + success: 'first', + }); + + expect(exitCode).toBe(1); + }); + + it('stops descendants of the command that triggered success=first shutdown', async () => { + if (process.platform === 'win32') { + return; + } + + const markerFilePath = path.join( + os.tmpdir(), + `wb-concurrently-descendant-${process.pid}-${Math.random().toString(36).slice(2)}.txt` + ); + const leakedChildScript = `setTimeout(() => require('node:fs').writeFileSync(${JSON.stringify(markerFilePath)}, 'leaked'), 300)`; + const grandchildScript = [ + 'const { spawn } = require("node:child_process");', + `const leakedChild = spawn(process.execPath, ["-e", ${JSON.stringify(leakedChildScript)}], { stdio: "ignore" });`, + 'leakedChild.unref();', + 'setTimeout(() => process.exit(0), 40);', + ].join(' '); + + try { + const exitCode = await runConcurrently({ + commands: [ + `${process.execPath} -e ${JSON.stringify(grandchildScript)}`, + `${process.execPath} -e "setTimeout(() => process.exit(0), 1000)"`, + ], + cwd, + env, + killOthers: false, + killOthersOnFail: false, + success: 'first', + }); + + expect(exitCode).toBe(0); + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(fs.existsSync(markerFilePath)).toBe(false); + } finally { + await fs.promises.rm(markerFilePath, { force: true }); + } + }); + + it('does not stop other commands when kill-others-on-fail is enabled but the first exit succeeds', async () => { + const markerFilePath = path.join( + os.tmpdir(), + `wb-concurrently-${process.pid}-${Math.random().toString(36).slice(2)}.txt` + ); + const writeMarkerScript = `setTimeout(() => { require('node:fs').writeFileSync(${JSON.stringify(markerFilePath)}, 'done'); process.exit(0); }, 180)`; + const exitCode = await runConcurrently({ + commands: [ + `${process.execPath} -e "setTimeout(() => process.exit(0), 40)"`, + `${process.execPath} -e ${JSON.stringify(writeMarkerScript)}`, + ], + cwd, + env, + killOthers: false, + killOthersOnFail: true, + success: 'all', + }); + + expect(exitCode).toBe(0); + expect(fs.existsSync(markerFilePath)).toBe(true); + await fs.promises.rm(markerFilePath, { force: true }); + }); + + it('maps signal exits to 128 plus the signal number', async () => { + const exitCode = await runConcurrently({ + commands: ['kill -TERM $$'], + cwd, + env, + killOthers: false, + killOthersOnFail: false, + success: 'all', + }); + + expect(exitCode).toBe(143); + }); + + it('returns failure when a child process emits an error', async () => { + vi.spyOn(child_process, 'spawn').mockImplementation(() => { + const listeners = new Map void)[]>(); + const child = { + once(event: string, listener: (...args: unknown[]) => void) { + listeners.set(event, [...(listeners.get(event) ?? []), listener]); + return child; + }, + } as unknown as child_process.ChildProcess; + + queueMicrotask(() => { + for (const listener of listeners.get('error') ?? []) { + listener(new Error('spawn failed')); + } + }); + return child; + }); + + await expect( + runConcurrently({ + commands: ['ignored'], + cwd, + env, + killOthers: false, + killOthersOnFail: false, + success: 'all', + }) + ).resolves.toBe(1); + }); +}); + +describe('concurrentlyCommand', () => { + it('registers shared env-loading options', () => { + const builder = concurrentlyCommand.builder as Record; + expect(builder.env).toBeDefined(); + expect(builder['cascade-env']).toBeDefined(); + expect(builder['include-root-env']).toBeDefined(); + expect(builder.verbose).toBeDefined(); + }); + + it('accepts env-loading flags when parsing direct wb concurrently usage', () => { + const command = { + ...concurrentlyCommand, + handler: vi.fn(), + }; + const argv = yargs() + .scriptName('wb') + .command(command) + .demandCommand() + .strict() + .parseSync([ + 'concurrently', + '--env', + '.env.test', + '--include-root-env=false', + '--cascade-env=staging', + '--check-env=.env.required', + 'echo first', + 'echo second', + ]); + + expect(argv.env).toEqual(['.env.test']); + expect(argv.includeRootEnv).toBe(false); + expect(argv.cascadeEnv).toBe('staging'); + expect(argv.checkEnv).toBe('.env.required'); + expect(argv.commands).toEqual(['echo first', 'echo second']); + expect(command.handler).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/wb/test/scripts/builder.test.ts b/packages/wb/test/scripts/builder.test.ts new file mode 100644 index 00000000..4ad68e00 --- /dev/null +++ b/packages/wb/test/scripts/builder.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeArgs, type ScriptArgv } from '../../src/scripts/builder.js'; + +describe('normalizeArgs', () => { + it('escapes shell-sensitive arguments', () => { + const argv = { + _: ['start', `semi;colon`, `quo'te`, `double"quote`], + args: ['space value'], + dockerOptions: [`name=quo'ted`], + } as unknown as ScriptArgv; + + normalizeArgs(argv); + + expect(argv.normalizedArgsText).toBe(`'space value' 'semi;colon' 'quo'"'"'te' 'double"quote'`); + expect(argv.normalizedDockerOptionsText).toBe(`'name=quo'"'"'ted'`); + }); +}); diff --git a/packages/wb/test/scripts/execution/baseScripts.test.ts b/packages/wb/test/scripts/execution/baseScripts.test.ts index f041dfa0..136bca2a 100644 --- a/packages/wb/test/scripts/execution/baseScripts.test.ts +++ b/packages/wb/test/scripts/execution/baseScripts.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it, vi } from 'vitest'; +import yargs from 'yargs'; import type { TestArgv } from '../../../src/commands/test.js'; import type { Project } from '../../../src/project.js'; import type { ScriptArgv } from '../../../src/scripts/builder.js'; +import { normalizeArgs } from '../../../src/scripts/builder.js'; import { BaseScripts } from '../../../src/scripts/execution/baseScripts.js'; +import { buildEnvReaderOptionArgs, sharedOptionsBuilder } from '../../../src/sharedOptionsBuilder.js'; +import { buildShellCommand, buildShellEnvironmentAssignment } from '../../../src/utils/shell.js'; vi.mock('../../../src/utils/port.js', () => ({ checkAndKillPortProcess: vi.fn().mockResolvedValue(3000), @@ -23,6 +27,28 @@ class TestScripts extends BaseScripts { } } +class TestScriptsWithWait extends BaseScripts { + constructor() { + super(true); + } + + getWaitApp(project: Project): string { + return this.waitApp(project); + } + + getWaitAndOpenApp(project: Project): string { + return this.waitAndOpenApp(project); + } + + protected startDevProtected(_: Project, argv: ScriptArgv): string { + return `start-dev ${argv.normalizedArgsText ?? ''}`.trim(); + } + + protected override startProductionProtected(_: Project, argv: ScriptArgv): string { + return `start-production ${argv.normalizedArgsText ?? ''}`.trim(); + } +} + describe('BaseScripts.testE2E', () => { const project = { env: { WB_ENV: 'test', PORT: '3000' }, @@ -44,8 +70,158 @@ describe('BaseScripts.testE2E', () => { it('keeps additional playwright args when replacing target', async () => { const command = await scripts.testE2EProduction(project, { targets: ['test/e2e/topPage.spec.ts'] } as TestArgv, { - playwrightArgs: 'test test/e2e/ --headed', + playwrightArgs: ['test', 'test/e2e/', '--headed'], }); expect(command).toContain('BUN playwright test test/e2e/topPage.spec.ts --headed'); }); + + it('replaces the first explicit playwright target even when options come first', async () => { + const command = await scripts.testE2EProduction(project, { targets: ['test/e2e/topPage.spec.ts'] } as TestArgv, { + playwrightArgs: ['test', '--headed', 'test/e2e/', '--grep', 'hello'], + }); + expect(command).toContain('BUN playwright test test/e2e/topPage.spec.ts --headed --grep hello'); + }); + + it('replaces all explicit playwright targets when multiple targets are already present', async () => { + const command = await scripts.testE2EProduction( + project, + { targets: ['test/e2e/a.spec.ts', 'test/e2e/b.spec.ts'] } as TestArgv, + { + playwrightArgs: ['test', 'test/e2e/a.spec.ts', 'test/e2e/b.spec.ts', '--headed'], + } + ); + + expect(command).toContain('BUN playwright test test/e2e/a.spec.ts test/e2e/b.spec.ts --headed'); + expect(command).not.toContain('test/e2e/b.spec.ts test/e2e/b.spec.ts'); + }); + + it('replaces all explicit playwright targets while preserving surrounding options', async () => { + const command = await scripts.testE2EProduction( + project, + { targets: ['test/e2e/a.spec.ts', 'test/e2e/b.spec.ts'] } as TestArgv, + { + playwrightArgs: ['test', '--headed', 'test/e2e/a.spec.ts', 'test/e2e/b.spec.ts', '--grep', 'hello'], + } + ); + + expect(command).toContain('BUN playwright test test/e2e/a.spec.ts test/e2e/b.spec.ts --headed --grep hello'); + expect(command).not.toContain('test/e2e/b.spec.ts test/e2e/b.spec.ts'); + }); + + it('preserves option values when replacing explicit playwright targets', async () => { + const command = await scripts.testE2EProduction(project, { targets: ['test/e2e/topPage.spec.ts'] } as TestArgv, { + playwrightArgs: ['test', '--project', 'chromium'], + }); + + expect(command).toContain('BUN playwright test test/e2e/topPage.spec.ts --project chromium'); + }); + + it('preserves test list option values when replacing explicit playwright targets', async () => { + const command = await scripts.testE2EProduction(project, { targets: ['test/e2e/topPage.spec.ts'] } as TestArgv, { + playwrightArgs: ['test', '--test-list', 'cases.txt', '--test-list-invert', 'ignored.txt', 'test/e2e/'], + }); + + expect(command).toContain( + 'BUN playwright test test/e2e/topPage.spec.ts --test-list cases.txt --test-list-invert ignored.txt' + ); + expect(command).not.toContain('test/e2e/ --test-list'); + }); + + it('does not add max-failures to non-test playwright subcommands', async () => { + const command = await scripts.testE2EProduction(project, {} as TestArgv, { + playwrightArgs: ['codegen', 'http://localhost:3000'], + }); + + expect(command).toBe('BUN playwright codegen http://localhost:3000'); + }); + + it('escapes start commands passed to wb concurrently', async () => { + const scriptsWithWait = new TestScriptsWithWait(); + const argv = yargs() + .options(sharedOptionsBuilder) + .parseSync([ + '--env', + '.env.test', + '--include-root-env=false', + '--cascade-env=staging', + '--check-env', + '.env.custom', + '--verbose', + 'start', + `semi;colon`, + `quo'te`, + ]) as unknown as ScriptArgv; + normalizeArgs(argv); + + const command = await scriptsWithWait.startDev(project, argv); + + expect(command).toBe( + buildShellCommand([ + 'YARN', + 'wb', + 'concurrently', + ...buildEnvReaderOptionArgs(argv), + '--kill-others-on-fail', + `start-dev ${argv.normalizedArgsText}`, + scriptsWithWait.getWaitAndOpenApp(project), + ]) + ); + }); + + it('escapes test-start commands passed to wb concurrently', async () => { + const scriptsWithWait = new TestScriptsWithWait(); + const argv = yargs() + .options(sharedOptionsBuilder) + .parseSync(['--env', '.env.test', '--include-root-env=false', 'start', `quo'te`]) as unknown as ScriptArgv; + normalizeArgs(argv); + + const command = await scriptsWithWait.testStart(project, argv); + + expect(command).toBe( + `${buildShellEnvironmentAssignment('NODE_ENV', '')} ${buildShellCommand([ + 'YARN', + 'wb', + 'concurrently', + ...buildEnvReaderOptionArgs(argv), + '--kill-others', + '--success', + 'first', + `start-dev ${argv.normalizedArgsText}`, + scriptsWithWait.getWaitApp(project), + ])}` + ); + }); + + it('preserves explicit env-loading overrides when building nested concurrently commands', async () => { + const scriptsWithWait = new TestScriptsWithWait(); + const argv = yargs() + .options(sharedOptionsBuilder) + .parseSync([ + '--env', + '.env.test', + '--env', + '.env.local.test', + '--include-root-env=false', + '--auto-cascade-env=false', + '--check-env=.env.required', + 'start', + ]) as unknown as ScriptArgv; + normalizeArgs(argv); + + expect(buildEnvReaderOptionArgs(argv)).toEqual([ + '--env=.env.test', + '--env=.env.local.test', + '--auto-cascade-env=false', + '--include-root-env=false', + '--check-env=.env.required', + ]); + + const command = await scriptsWithWait.startProduction(project, argv); + + expect(command).toContain('--env=.env.test'); + expect(command).toContain('--env=.env.local.test'); + expect(command).toContain('--include-root-env=false'); + expect(command).toContain('--auto-cascade-env=false'); + expect(command).toContain('--check-env=.env.required'); + }); }); diff --git a/packages/wb/test/scripts/execution/httpServerScripts.test.ts b/packages/wb/test/scripts/execution/httpServerScripts.test.ts index 515de720..3c1f4bee 100644 --- a/packages/wb/test/scripts/execution/httpServerScripts.test.ts +++ b/packages/wb/test/scripts/execution/httpServerScripts.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; +import yargs from 'yargs'; import type { TestArgv } from '../../../src/commands/test.js'; import type { Project } from '../../../src/project.js'; +import { normalizeArgs } from '../../../src/scripts/builder.js'; import { httpServerScripts } from '../../../src/scripts/execution/httpServerScripts.js'; +import { buildEnvReaderOptionArgs, sharedOptionsBuilder } from '../../../src/sharedOptionsBuilder.js'; +import { buildShellCommand } from '../../../src/utils/shell.js'; vi.mock('../../../src/utils/port.js', () => ({ checkAndKillPortProcess: vi.fn().mockResolvedValue(3000), @@ -41,4 +45,86 @@ describe('HttpServerScripts.testE2E', () => { const command = await httpServerScripts.testE2EProduction(project, {} as TestArgv, {}); expect(command).toContain('BUN playwright test test/e2e/'); }); + + it('escapes vitest targets inside concurrently commands', async () => { + const project = { + env: { WB_ENV: 'test', PORT: '3000' }, + packageJson: { scripts: {} }, + hasPlaywrightConfig: false, + hasPrisma: false, + buildCommand: 'echo "no build"', + findFile: vi.fn().mockImplementation(() => { + throw new Error('File not found'); + }), + } as unknown as Project; + + const command = await httpServerScripts.testE2EProduction( + project, + { targets: [`test/e2e/quo'te.spec.ts`, 'test/e2e/space path.spec.ts'] } as TestArgv, + {} + ); + + expect(command).toBe( + buildShellCommand([ + 'YARN', + 'wb', + 'concurrently', + '--kill-others', + '--success', + 'first', + 'YARN wb buildIfNeeded && node dist/index.js && exit 1', + `wait-on -t 600000 -i 2000 http-get://127.0.0.1:3000 + && ${buildShellCommand([ + 'vitest', + 'run', + `test/e2e/quo'te.spec.ts`, + 'test/e2e/space path.spec.ts', + '--color', + '--passWithNoTests', + '--allowOnly', + ])}`, + ]) + ); + }); + + it('preserves env-loading overrides inside nested concurrently commands', async () => { + const project = { + env: { WB_ENV: 'test', PORT: '3000' }, + packageJson: { scripts: {} }, + hasPlaywrightConfig: false, + hasPrisma: false, + buildCommand: 'echo "no build"', + findFile: vi.fn().mockImplementation(() => { + throw new Error('File not found'); + }), + } as unknown as Project; + const argv = yargs() + .options(sharedOptionsBuilder) + .parseSync([ + '--env', + '.env.test', + '--env', + '.env.local.test', + '--include-root-env=false', + '--auto-cascade-env=false', + '--check-env=.env.required', + 'test', + ]) as unknown as TestArgv; + normalizeArgs(argv); + + const command = await httpServerScripts.testE2EProduction(project, argv, {}); + + expect(buildEnvReaderOptionArgs(argv)).toEqual([ + '--env=.env.test', + '--env=.env.local.test', + '--auto-cascade-env=false', + '--include-root-env=false', + '--check-env=.env.required', + ]); + expect(command).toContain('--env=.env.test'); + expect(command).toContain('--env=.env.local.test'); + expect(command).toContain('--include-root-env=false'); + expect(command).toContain('--auto-cascade-env=false'); + expect(command).toContain('--check-env=.env.required'); + }); });