From e1aaddd9b286ca77a699b0ce4a20dae647532aa8 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 5 May 2026 13:09:16 -0400 Subject: [PATCH] refactor(@angular/cli): restrict MCP host process spawning to Angular CLI executable Update the Host abstraction inside the Model Context Protocol (MCP) layer to tighten the system shell surface and improve semantics. The generic spawn and execute methods are replaced with specialized counterparts that default to the Angular CLI, enabling stronger path security containment for developers while also clarifying the distinct control flows needed for buffered discrete commands and long-running background services. --- .../angular/cli/src/commands/mcp/devserver.ts | 2 +- packages/angular/cli/src/commands/mcp/host.ts | 49 ++++++++----------- .../cli/src/commands/mcp/testing/mock-host.ts | 4 +- .../src/commands/mcp/testing/test-utils.ts | 6 ++- .../cli/src/commands/mcp/tools/build.ts | 2 +- .../cli/src/commands/mcp/tools/build_spec.ts | 20 +++----- .../mcp/tools/devserver/devserver_spec.ts | 12 ++--- .../angular/cli/src/commands/mcp/tools/e2e.ts | 2 +- .../cli/src/commands/mcp/tools/e2e_spec.ts | 18 +++---- .../cli/src/commands/mcp/tools/test.ts | 2 +- .../cli/src/commands/mcp/tools/test_spec.ts | 22 +++------ 11 files changed, 61 insertions(+), 78 deletions(-) diff --git a/packages/angular/cli/src/commands/mcp/devserver.ts b/packages/angular/cli/src/commands/mcp/devserver.ts index dc24e5c73e4e..51b230e9289f 100644 --- a/packages/angular/cli/src/commands/mcp/devserver.ts +++ b/packages/angular/cli/src/commands/mcp/devserver.ts @@ -118,7 +118,7 @@ export class LocalDevserver implements Devserver { args.push(`--port=${this.port}`); - this.devserverProcess = this.host.spawn('ng', args, { + this.devserverProcess = this.host.startNgProcess(args, { stdio: 'pipe', cwd: this.workspacePath, }); diff --git a/packages/angular/cli/src/commands/mcp/host.ts b/packages/angular/cli/src/commands/mcp/host.ts index 40586bdcd8ac..e10e4bfc9c31 100644 --- a/packages/angular/cli/src/commands/mcp/host.ts +++ b/packages/angular/cli/src/commands/mcp/host.ts @@ -74,13 +74,11 @@ export interface Host { /** * Spawns a child process and returns a promise that resolves with the process's * output or rejects with a structured error. - * @param command The command to run. * @param args The arguments to pass to the command. * @param options Options for the child process. * @returns A promise that resolves with the standard output and standard error of the command. */ - runCommand( - command: string, + executeNgCommand( args: readonly string[], options?: { timeout?: number; @@ -92,13 +90,11 @@ export interface Host { /** * Spawns a long-running child process and returns the `ChildProcess` object. - * @param command The command to run. * @param args The arguments to pass to the command. * @param options Options for the child process. * @returns The spawned `ChildProcess` instance. */ - spawn( - command: string, + startNgProcess( args: readonly string[], options?: { stdio?: 'pipe' | 'ignore'; @@ -123,13 +119,13 @@ export interface Host { setRoots(roots: string[]): void; } -function resolveCommand( - command: string, +function resolveNgCommand( args: readonly string[], cwd?: string, ): { command: string; args: readonly string[] } { - if (command !== 'ng' || !cwd) { - return { command, args }; + const defaultCommand = { command: 'ng', args }; + if (!cwd) { + return defaultCommand; } try { @@ -150,7 +146,7 @@ function resolveCommand( // Failed to resolve the CLI binary, fall back to assuming `ng` is on PATH. } - return { command, args }; + return defaultCommand; } /** @@ -170,8 +166,7 @@ export const LocalWorkspaceHost: Host = { return nodeGlob(pattern, { ...options, withFileTypes: true }); }, - runCommand: async ( - command: string, + executeNgCommand: async ( args: readonly string[], options: { timeout?: number; @@ -180,7 +175,7 @@ export const LocalWorkspaceHost: Host = { env?: Record; } = {}, ): Promise<{ logs: string[] }> => { - const resolved = resolveCommand(command, args, options.cwd); + const resolved = resolveNgCommand(args, options.cwd); const signal = options.timeout ? AbortSignal.timeout(options.timeout) : undefined; return new Promise((resolve, reject) => { @@ -221,8 +216,7 @@ export const LocalWorkspaceHost: Host = { }); }, - spawn( - command: string, + startNgProcess( args: readonly string[], options: { stdio?: 'pipe' | 'ignore'; @@ -230,7 +224,7 @@ export const LocalWorkspaceHost: Host = { env?: Record; } = {}, ): ChildProcess { - const resolved = resolveCommand(command, args, options.cwd); + const resolved = resolveNgCommand(args, options.cwd); return spawn(resolved.command, resolved.args, { shell: false, @@ -370,23 +364,20 @@ export function createRootRestrictedHost( return baseHost.glob(pattern, options); }, - runCommand(command: string, args: readonly string[], options: { cwd?: string } = {}) { - const effectiveCwd = options.cwd ?? process.cwd(); + executeNgCommand( + args: readonly string[], + options: Parameters[1] = {}, + ) { + const effectiveCwd = options?.cwd ?? process.cwd(); checkPath(effectiveCwd); - if (command.includes('/') || command.includes('\\')) { - checkPath(resolve(effectiveCwd, command)); - } - return baseHost.runCommand(command, args, options); + return baseHost.executeNgCommand(args, options); }, - spawn(command: string, args: readonly string[], options: { cwd?: string } = {}) { - const effectiveCwd = options.cwd ?? process.cwd(); + startNgProcess(args: readonly string[], options: Parameters[1] = {}) { + const effectiveCwd = options?.cwd ?? process.cwd(); checkPath(effectiveCwd); - if (command.includes('/') || command.includes('\\')) { - checkPath(resolve(effectiveCwd, command)); - } - return baseHost.spawn(command, args, options); + return baseHost.startNgProcess(args, options); }, }; } diff --git a/packages/angular/cli/src/commands/mcp/testing/mock-host.ts b/packages/angular/cli/src/commands/mcp/testing/mock-host.ts index ef818062d559..1062191aebe1 100644 --- a/packages/angular/cli/src/commands/mcp/testing/mock-host.ts +++ b/packages/angular/cli/src/commands/mcp/testing/mock-host.ts @@ -13,12 +13,12 @@ import type { Host } from '../host'; * This class allows spying on host methods and controlling their return values. */ export class MockHost implements Host { - runCommand = jasmine.createSpy('runCommand').and.resolveTo({ logs: [] }); + executeNgCommand = jasmine.createSpy('executeNgCommand').and.resolveTo({ logs: [] }); stat = jasmine.createSpy('stat'); existsSync = jasmine.createSpy('existsSync'); readFile = jasmine.createSpy('readFile').and.resolveTo(''); glob = jasmine.createSpy('glob').and.returnValue((async function* () {})()); - spawn = jasmine.createSpy('spawn'); + startNgProcess = jasmine.createSpy('startNgProcess'); getAvailablePort = jasmine.createSpy('getAvailablePort'); isPortAvailable = jasmine.createSpy('isPortAvailable').and.resolveTo(true); setRoots = jasmine.createSpy('setRoots'); diff --git a/packages/angular/cli/src/commands/mcp/testing/test-utils.ts b/packages/angular/cli/src/commands/mcp/testing/test-utils.ts index 1bdf2ef416a5..1c95c51fe25e 100644 --- a/packages/angular/cli/src/commands/mcp/testing/test-utils.ts +++ b/packages/angular/cli/src/commands/mcp/testing/test-utils.ts @@ -20,10 +20,12 @@ import { MockHost } from './mock-host'; */ export function createMockHost(): MockHost { return { - runCommand: jasmine.createSpy('runCommand').and.resolveTo({ logs: [] }), + executeNgCommand: jasmine + .createSpy('executeNgCommand') + .and.resolveTo({ logs: [] }), stat: jasmine.createSpy('stat'), existsSync: jasmine.createSpy('existsSync'), - spawn: jasmine.createSpy('spawn'), + startNgProcess: jasmine.createSpy('startNgProcess'), getAvailablePort: jasmine .createSpy('getAvailablePort') .and.resolveTo(0), diff --git a/packages/angular/cli/src/commands/mcp/tools/build.ts b/packages/angular/cli/src/commands/mcp/tools/build.ts index a04812f8544b..fbf2729bf8bf 100644 --- a/packages/angular/cli/src/commands/mcp/tools/build.ts +++ b/packages/angular/cli/src/commands/mcp/tools/build.ts @@ -52,7 +52,7 @@ export async function runBuild(input: BuildToolInput, context: McpToolContext) { let outputPath: string | undefined; try { - logs = (await context.host.runCommand('ng', args, { cwd: workspacePath })).logs; + logs = (await context.host.executeNgCommand(args, { cwd: workspacePath })).logs; } catch (e) { status = 'failure'; logs = getCommandErrorLogs(e); diff --git a/packages/angular/cli/src/commands/mcp/tools/build_spec.ts b/packages/angular/cli/src/commands/mcp/tools/build_spec.ts index 403d5e68f877..3fd7318c554b 100644 --- a/packages/angular/cli/src/commands/mcp/tools/build_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/build_spec.ts @@ -29,8 +29,7 @@ describe('Build Tool', () => { it('should construct the command correctly with default configuration', async () => { mockContext.workspace.extensions['defaultProject'] = 'my-app'; await runBuild({}, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( ['build', 'my-app', '-c', 'development'], { cwd: '/test' }, ); @@ -39,8 +38,7 @@ describe('Build Tool', () => { it('should construct the command correctly with a specified project', async () => { addProjectToWorkspace(mockContext.workspace.projects, 'another-app'); await runBuild({ project: 'another-app' }, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( ['build', 'another-app', '-c', 'development'], { cwd: '/test' }, ); @@ -49,7 +47,7 @@ describe('Build Tool', () => { it('should construct the command correctly for a custom configuration', async () => { mockContext.workspace.extensions['defaultProject'] = 'my-app'; await runBuild({ configuration: 'myconfig' }, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build', 'my-app', '-c', 'myconfig'], { + expect(mockHost.executeNgCommand).toHaveBeenCalledWith(['build', 'my-app', '-c', 'myconfig'], { cwd: '/test', }); }); @@ -61,14 +59,13 @@ describe('Build Tool', () => { 'some warning', 'Output location: dist/my-app', ]; - mockHost.runCommand.and.resolveTo({ + mockHost.executeNgCommand.and.resolveTo({ logs: buildLogs, }); const { structuredContent } = await runBuild({ project: 'my-app' }, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( ['build', 'my-app', '-c', 'development'], { cwd: '/test' }, ); @@ -81,15 +78,14 @@ describe('Build Tool', () => { addProjectToWorkspace(mockContext.workspace.projects, 'my-failed-app'); const buildLogs = ['Some output before the crash.', 'Error: Something went wrong!']; const error = new CommandError('Build failed', buildLogs, 1); - mockHost.runCommand.and.rejectWith(error); + mockHost.executeNgCommand.and.rejectWith(error); const { structuredContent } = await runBuild( { project: 'my-failed-app', configuration: 'production' }, mockContext, ); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( ['build', 'my-failed-app', '-c', 'production'], { cwd: '/test' }, ); @@ -100,7 +96,7 @@ describe('Build Tool', () => { it('should handle builds where the output path is not found in logs', async () => { const buildLogs = ["Some logs that don't match any output path."]; - mockHost.runCommand.and.resolveTo({ logs: buildLogs }); + mockHost.executeNgCommand.and.resolveTo({ logs: buildLogs }); mockContext.workspace.extensions['defaultProject'] = 'my-app'; const { structuredContent } = await runBuild({}, mockContext); diff --git a/packages/angular/cli/src/commands/mcp/tools/devserver/devserver_spec.ts b/packages/angular/cli/src/commands/mcp/tools/devserver/devserver_spec.ts index 52a66902e2ef..ea5fddad184b 100644 --- a/packages/angular/cli/src/commands/mcp/tools/devserver/devserver_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/devserver/devserver_spec.ts @@ -39,7 +39,7 @@ describe('Serve Tools', () => { mockContext = mock.context; // Customize host spies - mockHost.spawn.and.returnValue(mockProcess as unknown as ChildProcess); + mockHost.startNgProcess.and.returnValue(mockProcess as unknown as ChildProcess); mockHost.getAvailablePort.and.callFake(() => Promise.resolve(portCounter++)); // Setup default project @@ -52,7 +52,7 @@ describe('Serve Tools', () => { expect(startResult.structuredContent.message).toBe( `Development server for project 'my-app' started and watching for workspace changes.`, ); - expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', 'my-app', '--port=12345'], { + expect(mockHost.startNgProcess).toHaveBeenCalledWith(['serve', 'my-app', '--port=12345'], { stdio: 'pipe', cwd: '/test', }); @@ -69,7 +69,7 @@ describe('Serve Tools', () => { expect(startResult.structuredContent.message).toBe( `Development server for project 'my-app' started and watching for workspace changes.`, ); - expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', 'my-app', '--port=54321'], { + expect(mockHost.startNgProcess).toHaveBeenCalledWith(['serve', 'my-app', '--port=54321'], { stdio: 'pipe', cwd: '/test', }); @@ -125,17 +125,17 @@ describe('Serve Tools', () => { // Start server for project 2, returning a new mock process. const process2 = new MockChildProcess(); - mockHost.spawn.and.returnValue(process2 as unknown as ChildProcess); + mockHost.startNgProcess.and.returnValue(process2 as unknown as ChildProcess); const startResult2 = await startDevserver({ project: 'app-two' }, mockContext); expect(startResult2.structuredContent.message).toBe( `Development server for project 'app-two' started and watching for workspace changes.`, ); - expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', 'app-one', '--port=12345'], { + expect(mockHost.startNgProcess).toHaveBeenCalledWith(['serve', 'app-one', '--port=12345'], { stdio: 'pipe', cwd: '/test', }); - expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', 'app-two', '--port=12346'], { + expect(mockHost.startNgProcess).toHaveBeenCalledWith(['serve', 'app-two', '--port=12346'], { stdio: 'pipe', cwd: '/test', }); diff --git a/packages/angular/cli/src/commands/mcp/tools/e2e.ts b/packages/angular/cli/src/commands/mcp/tools/e2e.ts index 2bd0441d2434..726308b12c87 100644 --- a/packages/angular/cli/src/commands/mcp/tools/e2e.ts +++ b/packages/angular/cli/src/commands/mcp/tools/e2e.ts @@ -62,7 +62,7 @@ export async function runE2e(input: E2eToolInput, host: Host, context: McpToolCo let logs: string[]; try { - logs = (await host.runCommand('ng', args, { cwd: workspacePath })).logs; + logs = (await host.executeNgCommand(args, { cwd: workspacePath })).logs; } catch (e) { status = 'failure'; logs = getCommandErrorLogs(e); diff --git a/packages/angular/cli/src/commands/mcp/tools/e2e_spec.ts b/packages/angular/cli/src/commands/mcp/tools/e2e_spec.ts index d2d3949a6451..318dd41aea52 100644 --- a/packages/angular/cli/src/commands/mcp/tools/e2e_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/e2e_spec.ts @@ -33,14 +33,14 @@ describe('E2E Tool', () => { mockContext.workspace.extensions['defaultProject'] = 'my-app'; await runE2e({}, mockHost, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['e2e', 'my-app'], { cwd: '/test' }); + expect(mockHost.executeNgCommand).toHaveBeenCalledWith(['e2e', 'my-app'], { cwd: '/test' }); }); it('should construct the command correctly with a specified project', async () => { addProjectToWorkspace(mockProjects, 'my-app', { e2e: { builder: 'mock-builder' } }); await runE2e({ project: 'my-app' }, mockHost, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['e2e', 'my-app'], { cwd: '/test' }); + expect(mockHost.executeNgCommand).toHaveBeenCalledWith(['e2e', 'my-app'], { cwd: '/test' }); }); it('should error if project does not have e2e target', async () => { @@ -50,7 +50,7 @@ describe('E2E Tool', () => { expect(structuredContent.status).toBe('failure'); expect(structuredContent.logs?.[0]).toContain("No e2e target is defined for project 'my-app'"); - expect(mockHost.runCommand).not.toHaveBeenCalled(); + expect(mockHost.executeNgCommand).not.toHaveBeenCalled(); }); it('should error if no project was specified and the default project does not have e2e target', async () => { @@ -61,32 +61,32 @@ describe('E2E Tool', () => { expect(structuredContent.status).toBe('failure'); expect(structuredContent.logs?.[0]).toContain("No e2e target is defined for project 'my-app'"); - expect(mockHost.runCommand).not.toHaveBeenCalled(); + expect(mockHost.executeNgCommand).not.toHaveBeenCalled(); }); it('should handle a successful e2e run with a specified project', async () => { addProjectToWorkspace(mockProjects, 'my-app', { e2e: { builder: 'mock-builder' } }); const e2eLogs = ['E2E passed for my-app']; - mockHost.runCommand.and.resolveTo({ logs: e2eLogs }); + mockHost.executeNgCommand.and.resolveTo({ logs: e2eLogs }); const { structuredContent } = await runE2e({ project: 'my-app' }, mockHost, mockContext); expect(structuredContent.status).toBe('success'); expect(structuredContent.logs).toEqual(e2eLogs); - expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['e2e', 'my-app'], { cwd: '/test' }); + expect(mockHost.executeNgCommand).toHaveBeenCalledWith(['e2e', 'my-app'], { cwd: '/test' }); }); it('should handle a successful e2e run with the default project', async () => { mockContext.workspace.extensions['defaultProject'] = 'default-app'; addProjectToWorkspace(mockProjects, 'default-app', { e2e: { builder: 'mock-builder' } }); const e2eLogs = ['E2E passed for default-app']; - mockHost.runCommand.and.resolveTo({ logs: e2eLogs }); + mockHost.executeNgCommand.and.resolveTo({ logs: e2eLogs }); const { structuredContent } = await runE2e({}, mockHost, mockContext); expect(structuredContent.status).toBe('success'); expect(structuredContent.logs).toEqual(e2eLogs); - expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['e2e', 'default-app'], { + expect(mockHost.executeNgCommand).toHaveBeenCalledWith(['e2e', 'default-app'], { cwd: '/test', }); }); @@ -94,7 +94,7 @@ describe('E2E Tool', () => { it('should handle a failed e2e run', async () => { addProjectToWorkspace(mockProjects, 'my-app', { e2e: { builder: 'mock-builder' } }); const e2eLogs = ['E2E failed']; - mockHost.runCommand.and.rejectWith(new CommandError('Failed', e2eLogs, 1)); + mockHost.executeNgCommand.and.rejectWith(new CommandError('Failed', e2eLogs, 1)); const { structuredContent } = await runE2e({ project: 'my-app' }, mockHost, mockContext); diff --git a/packages/angular/cli/src/commands/mcp/tools/test.ts b/packages/angular/cli/src/commands/mcp/tools/test.ts index 9c05a2f0f29b..72093c268a1b 100644 --- a/packages/angular/cli/src/commands/mcp/tools/test.ts +++ b/packages/angular/cli/src/commands/mcp/tools/test.ts @@ -65,7 +65,7 @@ export async function runTest(input: TestToolInput, context: McpToolContext) { let logs: string[]; try { - logs = (await context.host.runCommand('ng', args, { cwd: workspacePath })).logs; + logs = (await context.host.executeNgCommand(args, { cwd: workspacePath })).logs; } catch (e) { status = 'failure'; logs = getCommandErrorLogs(e); diff --git a/packages/angular/cli/src/commands/mcp/tools/test_spec.ts b/packages/angular/cli/src/commands/mcp/tools/test_spec.ts index 487c986cdcd2..a56307dcf3cb 100644 --- a/packages/angular/cli/src/commands/mcp/tools/test_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/test_spec.ts @@ -29,8 +29,7 @@ describe('Test Tool', () => { it('should construct the command correctly with defaults', async () => { mockContext.workspace.extensions['defaultProject'] = 'my-app'; await runTest({}, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( ['test', 'my-app', '--browsers', 'ChromeHeadless', '--watch', 'false'], { cwd: '/test' }, ); @@ -39,8 +38,7 @@ describe('Test Tool', () => { it('should construct the command correctly with a specified project', async () => { addProjectToWorkspace(mockContext.workspace.projects, 'my-lib'); await runTest({ project: 'my-lib' }, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( ['test', 'my-lib', '--browsers', 'ChromeHeadless', '--watch', 'false'], { cwd: '/test' }, ); @@ -49,8 +47,7 @@ describe('Test Tool', () => { it('should construct the command correctly with filter', async () => { mockContext.workspace.extensions['defaultProject'] = 'my-app'; await runTest({ filter: 'AppComponent' }, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( [ 'test', 'my-app', @@ -67,14 +64,13 @@ describe('Test Tool', () => { it('should handle a successful test run and capture logs', async () => { const testLogs = ['Executed 10 of 10 SUCCESS', 'Total: 10 success']; - mockHost.runCommand.and.resolveTo({ + mockHost.executeNgCommand.and.resolveTo({ logs: testLogs, }); const { structuredContent } = await runTest({ project: 'my-app' }, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( ['test', 'my-app', '--browsers', 'ChromeHeadless', '--watch', 'false'], { cwd: '/test' }, ); @@ -86,7 +82,7 @@ describe('Test Tool', () => { addProjectToWorkspace(mockContext.workspace.projects, 'my-failed-app'); const testLogs = ['Executed 10 of 10 FAILED', 'Error: Some test failed']; const error = new CommandError('Test failed', testLogs, 1); - mockHost.runCommand.and.rejectWith(error); + mockHost.executeNgCommand.and.rejectWith(error); const { structuredContent } = await runTest({ project: 'my-failed-app' }, mockContext); @@ -106,8 +102,7 @@ describe('Test Tool', () => { await runTest({ project: 'my-vitest-app' }, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( ['test', 'my-vitest-app', '--headless', 'true', '--watch', 'false'], { cwd: '/test' }, ); @@ -123,8 +118,7 @@ describe('Test Tool', () => { await runTest({ project: 'my-default-vitest-app' }, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( ['test', 'my-default-vitest-app', '--headless', 'true', '--watch', 'false'], { cwd: '/test' }, );