From 0a42691c52be4177172fe9506d5ca6ea7c0de3ea Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 18 May 2026 10:21:59 -0400 Subject: [PATCH] refactor(@angular/cli): implement native stream line-buffering & VT removal for MCP logs Refactor the process log capturing mechanism in host.ts and devserver.ts to natively line-buffer and sanitize process stdout and stderr streams using Node's native readline `createInterface` API and `util.stripVTControlCharacters`. This ensures all command and devserver logs are cleanly line-split, trimmed, and stripped of VT/ANSI color sequences and carriage returns. --- .../angular/cli/src/commands/mcp/devserver.ts | 13 ++++----- packages/angular/cli/src/commands/mcp/host.ts | 28 +++++++++++++++++-- .../mcp/tools/devserver/devserver_spec.ts | 23 +++++++++------ 3 files changed, 45 insertions(+), 19 deletions(-) diff --git a/packages/angular/cli/src/commands/mcp/devserver.ts b/packages/angular/cli/src/commands/mcp/devserver.ts index 51b230e9289f..1a667afed6c9 100644 --- a/packages/angular/cli/src/commands/mcp/devserver.ts +++ b/packages/angular/cli/src/commands/mcp/devserver.ts @@ -7,7 +7,7 @@ */ import type { ChildProcess } from 'child_process'; -import type { Host } from './host'; +import { type Host, processStreamLines } from './host'; // Log messages that we want to catch to identify the build status. @@ -122,13 +122,10 @@ export class LocalDevserver implements Devserver { stdio: 'pipe', cwd: this.workspacePath, }); - this.devserverProcess.stdout?.on('data', (data) => { - this.addLog(data.toString()); - }); - this.devserverProcess.stderr?.on('data', (data) => { - this.addLog(data.toString()); - }); - this.devserverProcess.stderr?.on('close', () => { + processStreamLines(this.devserverProcess.stdout, (line) => this.addLog(line)); + processStreamLines(this.devserverProcess.stderr, (line) => this.addLog(line)); + + this.devserverProcess.on('close', () => { this.stop(); }); this.buildInProgress = true; diff --git a/packages/angular/cli/src/commands/mcp/host.ts b/packages/angular/cli/src/commands/mcp/host.ts index 8a378a4d238b..5dda0ade077f 100644 --- a/packages/angular/cli/src/commands/mcp/host.ts +++ b/packages/angular/cli/src/commands/mcp/host.ts @@ -20,6 +20,8 @@ import { glob as nodeGlob, readFile as nodeReadFile, stat } from 'node:fs/promis import { createRequire } from 'node:module'; import { createServer } from 'node:net'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { createInterface } from 'node:readline'; +import { stripVTControlCharacters } from 'node:util'; /** * An error thrown when a command fails to execute. @@ -191,8 +193,8 @@ export const LocalWorkspaceHost: Host = { }); const logs: string[] = []; - childProcess.stdout?.on('data', (data) => logs.push(data.toString())); - childProcess.stderr?.on('data', (data) => logs.push(data.toString())); + processStreamLines(childProcess.stdout, (line) => logs.push(line)); + processStreamLines(childProcess.stderr, (line) => logs.push(line)); childProcess.on('close', (code) => { if (code === 0) { @@ -381,3 +383,25 @@ export function createRootRestrictedHost( }, }; } + +/** + * Binds a readline interface to the given stream to process each line. + * Sanitizes lines by removing VT/ANSI control characters, trimming trailing whitespace, + * and preserving leading indentation. + */ +export function processStreamLines( + stream: NodeJS.ReadableStream | undefined | null, + lineCallback: (line: string) => void, +): void { + if (!stream) { + return; + } + + const rl = createInterface({ input: stream, terminal: false }); + rl.on('line', (line) => { + const cleanLine = stripVTControlCharacters(line).trimEnd(); + if (cleanLine.length > 0) { + lineCallback(cleanLine); + } + }); +} 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 ea5fddad184b..735e82302a94 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 @@ -18,9 +18,14 @@ import { startDevserver } from './devserver-start'; import { stopDevserver } from './devserver-stop'; import { WATCH_DELAY, waitForDevserverBuild } from './devserver-wait-for-build'; +class MockStream extends EventEmitter { + resume = jasmine.createSpy('resume').and.returnValue(this); + pause = jasmine.createSpy('pause').and.returnValue(this); +} + class MockChildProcess extends EventEmitter { - stdout = new EventEmitter(); - stderr = new EventEmitter(); + stdout = new MockStream(); + stderr = new MockStream(); kill = jasmine.createSpy('kill'); } @@ -95,10 +100,10 @@ describe('Serve Tools', () => { const waitPromise = waitForDevserverBuild({ timeout: 10 }, mockContext); // Simulate build logs. - mockProcess.stdout.emit('data', '... building ...'); - mockProcess.stdout.emit('data', '✔ Changes detected. Rebuilding...'); - mockProcess.stdout.emit('data', '... more logs ...'); - mockProcess.stdout.emit('data', 'Application bundle generation complete.'); + mockProcess.stdout.emit('data', '... building ...\n'); + mockProcess.stdout.emit('data', '✔ Changes detected. Rebuilding...\n'); + mockProcess.stdout.emit('data', '... more logs ...\n'); + mockProcess.stdout.emit('data', 'Application bundle generation complete.\n'); const waitResult = await waitPromise; expect(waitResult.structuredContent.status).toBe('success'); @@ -161,7 +166,7 @@ describe('Serve Tools', () => { await startDevserver({ project: 'crash-app' }, mockContext); // Simulate a crash with exit code 1 - mockProcess.stdout.emit('data', 'Fatal error.'); + mockProcess.stdout.emit('data', 'Fatal error.\n'); mockProcess.emit('close', 1); const stopResult = await stopDevserver({ project: 'crash-app' }, mockContext); @@ -185,7 +190,7 @@ describe('Serve Tools', () => { await startDevserver({}, mockContext); // Immediately simulate a build starting so isBuilding() is true. - mockProcess.stdout.emit('data', '❯ Changes detected. Rebuilding...'); + mockProcess.stdout.emit('data', '❯ Changes detected. Rebuilding...\n'); const waitPromise = waitForDevserverBuild({ timeout: 5 * WATCH_DELAY }, mockContext); @@ -199,7 +204,7 @@ describe('Serve Tools', () => { jasmine.clock().tick(WATCH_DELAY + 1); // Now finish the build. - mockProcess.stdout.emit('data', 'Application bundle generation complete.'); + mockProcess.stdout.emit('data', 'Application bundle generation complete.\n'); // Tick past another debounce to exit the loop. jasmine.clock().tick(WATCH_DELAY + 1);