diff --git a/package-lock.json b/package-lock.json index ae06b28e..aaef391f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@sentry/node": "^10.43.0", "bplist-parser": "^0.3.2", "chokidar": "^5.0.0", + "glob": "^13.0.6", "uuid": "^11.1.0", "yaml": "^2.4.5", "yargs": "^17.7.2", @@ -3086,9 +3087,9 @@ "license": "MIT" }, "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { @@ -4449,21 +4450,17 @@ } }, "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "license": "ISC", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -4486,7 +4483,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -4496,7 +4492,6 @@ "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -4506,16 +4501,15 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", - "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", - "dev": true, - "license": "ISC", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -4994,11 +4988,13 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", + "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/magic-string": { "version": "0.30.17", @@ -5134,11 +5130,10 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -5366,17 +5361,16 @@ } }, "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -6357,13 +6351,13 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -6445,6 +6439,78 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/sucrase/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -6512,6 +6578,35 @@ "node": "18 || 20 || >=22" } }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/test-exclude/node_modules/minimatch": { "version": "9.0.7", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", @@ -6528,6 +6623,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/test-exclude/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -7367,9 +7479,9 @@ } }, "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 7167ed42..97d5d190 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "@sentry/node": "^10.43.0", "bplist-parser": "^0.3.2", "chokidar": "^5.0.0", + "glob": "^13.0.6", "uuid": "^11.1.0", "yaml": "^2.4.5", "yargs": "^17.7.2", diff --git a/src/rendering/render.ts b/src/rendering/render.ts new file mode 100644 index 00000000..67dd448b --- /dev/null +++ b/src/rendering/render.ts @@ -0,0 +1,274 @@ +import type { + CompilerErrorEvent, + CompilerWarningEvent, + PipelineEvent, + TestFailureEvent, +} from '../types/pipeline-events.ts'; +import { sessionStore } from '../utils/session-store.ts'; +import { deriveDiagnosticBaseDir } from '../utils/renderers/index.ts'; +import { + formatBuildStageEvent, + formatDetailTreeEvent, + formatFileRefEvent, + formatGroupedCompilerErrors, + formatGroupedTestFailures, + formatGroupedWarnings, + formatHeaderEvent, + formatNextStepsEvent, + formatSectionEvent, + formatStatusLineEvent, + formatSummaryEvent, + formatTableEvent, + formatTestDiscoveryEvent, +} from '../utils/renderers/event-formatting.ts'; +import { createCliTextRenderer } from '../utils/renderers/cli-text-renderer.ts'; +import type { RenderSession, RenderStrategy, ImageAttachment } from './types.ts'; + +function isErrorEvent(event: PipelineEvent): boolean { + return ( + (event.type === 'status-line' && event.level === 'error') || + (event.type === 'summary' && event.status === 'FAILED') + ); +} + +function createTextRenderSession(): RenderSession { + const events: PipelineEvent[] = []; + const attachments: ImageAttachment[] = []; + const contentParts: string[] = []; + const suppressWarnings = sessionStore.get('suppressWarnings'); + const groupedCompilerErrors: CompilerErrorEvent[] = []; + const groupedWarnings: CompilerWarningEvent[] = []; + const groupedTestFailures: TestFailureEvent[] = []; + + let diagnosticBaseDir: string | null = null; + let hasError = false; + + const pushText = (text: string): void => { + contentParts.push(text); + }; + + const pushSection = (text: string): void => { + pushText(`\n${text}`); + }; + + return { + emit(event: PipelineEvent): void { + events.push(event); + if (isErrorEvent(event)) hasError = true; + + switch (event.type) { + case 'header': { + diagnosticBaseDir = deriveDiagnosticBaseDir(event); + pushSection(formatHeaderEvent(event)); + break; + } + + case 'build-stage': { + pushSection(formatBuildStageEvent(event)); + break; + } + + case 'status-line': { + pushSection(formatStatusLineEvent(event)); + break; + } + + case 'section': { + pushText(`\n\n${formatSectionEvent(event)}`); + break; + } + + case 'detail-tree': { + pushSection(formatDetailTreeEvent(event)); + break; + } + + case 'table': { + pushSection(formatTableEvent(event)); + break; + } + + case 'file-ref': { + pushSection(formatFileRefEvent(event)); + break; + } + + case 'compiler-warning': { + if (!suppressWarnings) { + groupedWarnings.push(event); + } + break; + } + + case 'compiler-error': { + groupedCompilerErrors.push(event); + break; + } + + case 'test-discovery': { + pushText(formatTestDiscoveryEvent(event)); + break; + } + + case 'test-progress': { + break; + } + + case 'test-failure': { + groupedTestFailures.push(event); + break; + } + + case 'summary': { + const diagOpts = { baseDir: diagnosticBaseDir ?? undefined }; + const diagnosticSections: string[] = []; + + if (groupedTestFailures.length > 0) { + diagnosticSections.push(formatGroupedTestFailures(groupedTestFailures, diagOpts)); + groupedTestFailures.length = 0; + } + + if (groupedWarnings.length > 0) { + diagnosticSections.push(formatGroupedWarnings(groupedWarnings, diagOpts)); + groupedWarnings.length = 0; + } + + if (event.status === 'FAILED' && groupedCompilerErrors.length > 0) { + diagnosticSections.push(formatGroupedCompilerErrors(groupedCompilerErrors, diagOpts)); + groupedCompilerErrors.length = 0; + } + + if (diagnosticSections.length > 0) { + pushSection(diagnosticSections.join('\n\n')); + } + + pushSection(formatSummaryEvent(event)); + break; + } + + case 'next-steps': { + const effectiveRuntime = event.runtime === 'cli' ? 'cli' : 'mcp'; + pushText(`\n\n${formatNextStepsEvent(event, effectiveRuntime)}`); + break; + } + } + }, + + attach(image: ImageAttachment): void { + attachments.push(image); + }, + + getEvents(): readonly PipelineEvent[] { + return events; + }, + + getAttachments(): readonly ImageAttachment[] { + return attachments; + }, + + isError(): boolean { + return hasError; + }, + + finalize(): string { + diagnosticBaseDir = null; + return contentParts.join(''); + }, + }; +} + +function createCliTextRenderSession(options: { interactive: boolean }): RenderSession { + const events: PipelineEvent[] = []; + const attachments: ImageAttachment[] = []; + const renderer = createCliTextRenderer(options); + let hasError = false; + + return { + emit(event: PipelineEvent): void { + events.push(event); + if (isErrorEvent(event)) hasError = true; + renderer.onEvent(event); + }, + + attach(image: ImageAttachment): void { + attachments.push(image); + }, + + getEvents(): readonly PipelineEvent[] { + return events; + }, + + getAttachments(): readonly ImageAttachment[] { + return attachments; + }, + + isError(): boolean { + return hasError; + }, + + finalize(): string { + renderer.finalize(); + return ''; + }, + }; +} + +function createCliJsonRenderSession(): RenderSession { + const events: PipelineEvent[] = []; + const attachments: ImageAttachment[] = []; + let hasError = false; + + return { + emit(event: PipelineEvent): void { + events.push(event); + if (isErrorEvent(event)) hasError = true; + process.stdout.write(JSON.stringify(event) + '\n'); + }, + + attach(image: ImageAttachment): void { + attachments.push(image); + }, + + getEvents(): readonly PipelineEvent[] { + return events; + }, + + getAttachments(): readonly ImageAttachment[] { + return attachments; + }, + + isError(): boolean { + return hasError; + }, + + finalize(): string { + return ''; + }, + }; +} + +export interface RenderSessionOptions { + interactive?: boolean; +} + +export function createRenderSession( + strategy: RenderStrategy, + options?: RenderSessionOptions, +): RenderSession { + switch (strategy) { + case 'text': + return createTextRenderSession(); + case 'cli-text': + return createCliTextRenderSession({ interactive: options?.interactive ?? false }); + case 'cli-json': + return createCliJsonRenderSession(); + } +} + +export function renderEvents(events: readonly PipelineEvent[], strategy: RenderStrategy): string { + const session = createRenderSession(strategy); + for (const event of events) { + session.emit(event); + } + return session.finalize(); +} diff --git a/src/rendering/types.ts b/src/rendering/types.ts new file mode 100644 index 00000000..3a0139af --- /dev/null +++ b/src/rendering/types.ts @@ -0,0 +1,25 @@ +import type { PipelineEvent } from '../types/pipeline-events.ts'; +import type { NextStep, NextStepParamsMap } from '../types/common.ts'; + +export type RenderStrategy = 'text' | 'cli-text' | 'cli-json'; + +export interface ImageAttachment { + data: string; + mimeType: string; +} + +export interface RenderSession { + emit(event: PipelineEvent): void; + attach(image: ImageAttachment): void; + getEvents(): readonly PipelineEvent[]; + getAttachments(): readonly ImageAttachment[]; + isError(): boolean; + finalize(): string; +} + +export interface ToolHandlerContext { + emit: (event: PipelineEvent) => void; + attach: (image: ImageAttachment) => void; + nextStepParams?: NextStepParamsMap; + nextSteps?: NextStep[]; +} diff --git a/src/utils/__tests__/xcodebuild-run-state.test.ts b/src/utils/__tests__/xcodebuild-run-state.test.ts index fd8dd3f3..2a56f4aa 100644 --- a/src/utils/__tests__/xcodebuild-run-state.test.ts +++ b/src/utils/__tests__/xcodebuild-run-state.test.ts @@ -328,8 +328,8 @@ describe('xcodebuild-run-state', () => { expect(summary).toBeDefined(); if (summary?.type === 'summary') { - expect(summary.totalTests).toBe(7); - expect(summary.passedTests).toBe(5); + expect(summary.totalTests).toBe(6); + expect(summary.passedTests).toBe(4); expect(summary.failedTests).toBe(2); expect(summary.skippedTests).toBe(0); } diff --git a/src/utils/cli-progress-reporter.ts b/src/utils/cli-progress-reporter.ts new file mode 100644 index 00000000..314080db --- /dev/null +++ b/src/utils/cli-progress-reporter.ts @@ -0,0 +1,31 @@ +import * as clack from '@clack/prompts'; + +export interface CliProgressReporter { + update(message: string): void; + clear(): void; +} + +export function createCliProgressReporter(): CliProgressReporter { + const spinner = clack.spinner(); + let active = false; + + return { + update(message: string): void { + if (!active) { + spinner.start(message); + active = true; + return; + } + + spinner.message(message); + }, + clear(): void { + if (!active) { + return; + } + + spinner.clear(); + active = false; + }, + }; +} diff --git a/src/utils/renderers/__tests__/cli-text-renderer.test.ts b/src/utils/renderers/__tests__/cli-text-renderer.test.ts new file mode 100644 index 00000000..47a353d2 --- /dev/null +++ b/src/utils/renderers/__tests__/cli-text-renderer.test.ts @@ -0,0 +1,343 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createCliTextRenderer } from '../cli-text-renderer.ts'; + +const reporter = { + update: vi.fn<(message: string) => void>(), + clear: vi.fn<() => void>(), +}; + +vi.mock('../../cli-progress-reporter.ts', () => ({ + createCliProgressReporter: () => reporter, +})); + +describe('cli-text-renderer', () => { + const originalIsTTY = process.stdout.isTTY; + const originalNoColor = process.env.NO_COLOR; + + beforeEach(() => { + reporter.update.mockReset(); + reporter.clear.mockReset(); + process.env.NO_COLOR = '1'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: originalIsTTY, + }); + + if (originalNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = originalNoColor; + } + }); + + it('renders one blank-line boundary between front matter and first runtime line', () => { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const renderer = createCliTextRenderer({ interactive: false }); + + renderer.onEvent({ + type: 'header', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'Build & Run', + params: [ + { label: 'Scheme', value: 'MyApp' }, + { label: 'Project', value: '/tmp/MyApp.xcodeproj' }, + { label: 'Configuration', value: 'Debug' }, + { label: 'Platform', value: 'macOS' }, + ], + }); + + renderer.onEvent({ + type: 'build-stage', + timestamp: '2026-03-20T12:00:01.000Z', + operation: 'BUILD', + stage: 'COMPILING', + message: 'Compiling', + }); + + const output = stdoutWrite.mock.calls.flat().join(''); + expect(output).toContain(' Platform: macOS\n\n\u203A Compiling\n'); + }); + + it('uses transient interactive updates for active phases and durable writes for lasting events', () => { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const renderer = createCliTextRenderer({ interactive: true }); + + renderer.onEvent({ + type: 'header', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'Build & Run', + params: [{ label: 'Scheme', value: 'MyApp' }], + }); + + renderer.onEvent({ + type: 'build-stage', + timestamp: '2026-03-20T12:00:01.000Z', + operation: 'BUILD', + stage: 'COMPILING', + message: 'Compiling', + }); + + renderer.onEvent({ + type: 'status-line', + timestamp: '2026-03-20T12:00:02.000Z', + level: 'info', + message: 'Resolving app path', + }); + + renderer.onEvent({ + type: 'compiler-warning', + timestamp: '2026-03-20T12:00:03.000Z', + operation: 'BUILD', + message: 'unused variable', + rawLine: '/tmp/MyApp.swift:10: warning: unused variable', + }); + + renderer.onEvent({ + type: 'status-line', + timestamp: '2026-03-20T12:00:04.000Z', + level: 'success', + message: 'Resolving app path', + }); + + renderer.onEvent({ + type: 'summary', + timestamp: '2026-03-20T12:00:05.000Z', + operation: 'BUILD', + status: 'SUCCEEDED', + }); + + expect(reporter.update).toHaveBeenCalledWith('Compiling...'); + expect(reporter.update).toHaveBeenCalledWith('Resolving app path...'); + + const output = stdoutWrite.mock.calls.flat().join(''); + expect(output).not.toContain('\u203A Compiling\n'); + expect(output).toContain('Warnings (1):'); + expect(output).toContain('unused variable'); + expect(output).toContain('\u{2705} Resolving app path\n'); + }); + + it('renders grouped sad-path diagnostics before the failed summary', () => { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const renderer = createCliTextRenderer({ interactive: false }); + + renderer.onEvent({ + type: 'header', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'Build & Run', + params: [ + { label: 'Scheme', value: 'MyApp' }, + { label: 'Project', value: '/tmp/MyApp.xcodeproj' }, + { label: 'Configuration', value: 'Debug' }, + { label: 'Platform', value: 'iOS Simulator' }, + { label: 'Simulator', value: 'INVALID-SIM-ID-123' }, + ], + }); + + renderer.onEvent({ + type: 'compiler-error', + timestamp: '2026-03-20T12:00:01.000Z', + operation: 'BUILD', + message: 'No available simulator matched: INVALID-SIM-ID-123', + rawLine: 'No available simulator matched: INVALID-SIM-ID-123', + }); + + renderer.onEvent({ + type: 'summary', + timestamp: '2026-03-20T12:00:02.000Z', + operation: 'BUILD', + status: 'FAILED', + durationMs: 1200, + }); + + const output = stdoutWrite.mock.calls.flat().join(''); + expect(output).toContain('Errors (1):'); + expect(output).toContain(' \u2717 No available simulator matched: INVALID-SIM-ID-123'); + expect(output).toContain('\u{274C} Build failed. (\u{23F1}\u{FE0F} 1.2s)'); + }); + + it('groups compiler diagnostics under a nested failure header before the failed summary', () => { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const renderer = createCliTextRenderer({ interactive: false }); + + renderer.onEvent({ + type: 'header', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'Build & Run', + params: [ + { label: 'Scheme', value: 'MyApp' }, + { label: 'Project', value: '/tmp/MyApp.xcodeproj' }, + { label: 'Configuration', value: 'Debug' }, + { label: 'Platform', value: 'macOS' }, + ], + }); + + renderer.onEvent({ + type: 'build-stage', + timestamp: '2026-03-20T12:00:01.000Z', + operation: 'BUILD', + stage: 'COMPILING', + message: 'Compiling', + }); + + renderer.onEvent({ + type: 'compiler-error', + timestamp: '2026-03-20T12:00:02.000Z', + operation: 'BUILD', + message: 'unterminated string literal', + rawLine: '/tmp/MCPTest/ContentView.swift:16:18: error: unterminated string literal', + }); + + renderer.onEvent({ + type: 'summary', + timestamp: '2026-03-20T12:00:03.000Z', + operation: 'BUILD', + status: 'FAILED', + durationMs: 4000, + }); + + const output = stdoutWrite.mock.calls.flat().join(''); + expect(output).toContain( + '\u203A Compiling\n\nCompiler Errors (1):\n\n \u2717 unterminated string literal\n /tmp/MCPTest/ContentView.swift:16:18', + ); + expect(output).not.toContain('error: unterminated string literal\n ContentView.swift:16:18'); + expect(output).toContain('\n\n\u{274C} Build failed. (\u{23F1}\u{FE0F} 4.0s)'); + }); + + it('uses exactly one blank-line boundary between front matter and compiler errors when no runtime line rendered', () => { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const renderer = createCliTextRenderer({ interactive: false }); + + renderer.onEvent({ + type: 'header', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'Build & Run', + params: [ + { label: 'Scheme', value: 'MyApp' }, + { label: 'Project', value: '/tmp/MyApp.xcodeproj' }, + { label: 'Configuration', value: 'Debug' }, + { label: 'Platform', value: 'macOS' }, + ], + }); + + renderer.onEvent({ + type: 'compiler-error', + timestamp: '2026-03-20T12:00:01.000Z', + operation: 'BUILD', + message: 'unterminated string literal', + rawLine: '/tmp/MCPTest/ContentView.swift:16:18: error: unterminated string literal', + }); + + renderer.onEvent({ + type: 'summary', + timestamp: '2026-03-20T12:00:02.000Z', + operation: 'BUILD', + status: 'FAILED', + durationMs: 2000, + }); + + const output = stdoutWrite.mock.calls.flat().join(''); + expect(output).toContain( + ' Platform: macOS\n\nCompiler Errors (1):\n\n \u2717 unterminated string literal\n /tmp/MCPTest/ContentView.swift:16:18', + ); + expect(output).not.toContain(' Platform: macOS\n\n\nCompiler Errors (1):'); + }); + + it('persists the last transient runtime phase as a durable line before grouped compiler errors', () => { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const renderer = createCliTextRenderer({ interactive: true }); + + renderer.onEvent({ + type: 'header', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'Build & Run', + params: [{ label: 'Scheme', value: 'MyApp' }], + }); + + renderer.onEvent({ + type: 'build-stage', + timestamp: '2026-03-20T12:00:01.000Z', + operation: 'BUILD', + stage: 'COMPILING', + message: 'Compiling', + }); + + renderer.onEvent({ + type: 'build-stage', + timestamp: '2026-03-20T12:00:02.000Z', + operation: 'BUILD', + stage: 'LINKING', + message: 'Linking', + }); + + renderer.onEvent({ + type: 'compiler-error', + timestamp: '2026-03-20T12:00:03.000Z', + operation: 'BUILD', + message: 'unterminated string literal', + rawLine: '/tmp/MCPTest/ContentView.swift:16:18: error: unterminated string literal', + }); + + renderer.onEvent({ + type: 'summary', + timestamp: '2026-03-20T12:00:04.000Z', + operation: 'BUILD', + status: 'FAILED', + durationMs: 4000, + }); + + expect(reporter.update).toHaveBeenCalledWith('Compiling...'); + expect(reporter.update).toHaveBeenCalledWith('Linking...'); + + const output = stdoutWrite.mock.calls.flat().join(''); + expect(output).toContain( + '\u203A Linking\n\nCompiler Errors (1):\n\n \u2717 unterminated string literal\n /tmp/MCPTest/ContentView.swift:16:18', + ); + }); + + it('renders summary, execution-derived footer, and next steps in that order', () => { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const renderer = createCliTextRenderer({ interactive: false }); + + renderer.onEvent({ + type: 'summary', + timestamp: '2026-03-20T12:00:05.000Z', + operation: 'BUILD', + status: 'SUCCEEDED', + durationMs: 7100, + }); + + renderer.onEvent({ + type: 'status-line', + timestamp: '2026-03-20T12:00:06.000Z', + level: 'success', + message: 'Build & Run complete', + }); + + renderer.onEvent({ + type: 'detail-tree', + timestamp: '2026-03-20T12:00:06.000Z', + items: [{ label: 'App Path', value: '/tmp/build/MyApp.app' }], + }); + + renderer.onEvent({ + type: 'next-steps', + timestamp: '2026-03-20T12:00:07.000Z', + steps: [{ label: 'Get built macOS app path', cliTool: 'get-app-path', workflow: 'macos' }], + }); + + const output = stdoutWrite.mock.calls.flat().join(''); + const summaryIndex = output.indexOf('\u{2705} Build succeeded.'); + const footerIndex = output.indexOf('\u{2705} Build & Run complete'); + const nextStepsIndex = output.indexOf('Next steps:'); + + expect(summaryIndex).toBeGreaterThanOrEqual(0); + expect(footerIndex).toBeGreaterThan(summaryIndex); + expect(nextStepsIndex).toBeGreaterThan(footerIndex); + expect(output).toContain('\u{2705} Build & Run complete'); + expect(output).toContain('\u2514 App Path: /tmp/build/MyApp.app'); + }); +}); diff --git a/src/utils/renderers/__tests__/event-formatting.test.ts b/src/utils/renderers/__tests__/event-formatting.test.ts new file mode 100644 index 00000000..4964112b --- /dev/null +++ b/src/utils/renderers/__tests__/event-formatting.test.ts @@ -0,0 +1,253 @@ +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + extractGroupedCompilerError, + formatGroupedCompilerErrors, + formatGroupedTestFailures, + formatHumanCompilerErrorEvent, + formatHumanCompilerWarningEvent, + formatHeaderEvent, + formatBuildStageEvent, + formatTransientBuildStageEvent, + formatStatusLineEvent, + formatDetailTreeEvent, + formatTransientStatusLineEvent, +} from '../event-formatting.ts'; + +describe('event formatting', () => { + it('formats header events with emoji, operation, and params', () => { + expect( + formatHeaderEvent({ + type: 'header', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'Build & Run', + params: [{ label: 'Scheme', value: 'MyApp' }], + }), + ).toBe('\u{1F680} Build & Run\n\n Scheme: MyApp\n'); + }); + + it('formats build-stage events as durable phase lines', () => { + expect( + formatBuildStageEvent({ + type: 'build-stage', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + stage: 'COMPILING', + message: 'Compiling', + }), + ).toBe('\u203A Compiling'); + }); + + it('formats transient build-stage events for interactive runtime updates', () => { + expect( + formatTransientBuildStageEvent({ + type: 'build-stage', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + stage: 'COMPILING', + message: 'Compiling', + }), + ).toBe('Compiling...'); + }); + + it('formats compiler-style errors with a cwd-relative source location when possible', () => { + const projectBaseDir = join(process.cwd(), 'example_projects/macOS'); + + expect( + formatHumanCompilerErrorEvent( + { + type: 'compiler-error', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + message: 'unterminated string literal', + rawLine: 'ContentView.swift:16:18: error: unterminated string literal', + }, + { baseDir: projectBaseDir }, + ), + ).toBe( + [ + 'error: unterminated string literal', + ' example_projects/macOS/MCPTest/ContentView.swift:16:18', + ].join('\n'), + ); + }); + + it('keeps compiler-style error paths absolute when they are outside cwd', () => { + expect( + formatHumanCompilerErrorEvent({ + type: 'compiler-error', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + message: 'unterminated string literal', + rawLine: '/tmp/MCPTest/ContentView.swift:16:18: error: unterminated string literal', + }), + ).toBe( + ['error: unterminated string literal', ' /tmp/MCPTest/ContentView.swift:16:18'].join('\n'), + ); + }); + + it('formats tool-originated errors in xcodebuild-style form', () => { + expect( + formatHumanCompilerErrorEvent({ + type: 'compiler-error', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + message: 'No available simulator matched: INVALID-SIM-ID-123', + rawLine: 'No available simulator matched: INVALID-SIM-ID-123', + }), + ).toBe('error: No available simulator matched: INVALID-SIM-ID-123'); + }); + + it('extracts compiler diagnostics for grouped sad-path rendering', () => { + expect( + extractGroupedCompilerError( + { + type: 'compiler-error', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + message: 'unterminated string literal', + rawLine: 'ContentView.swift:16:18: error: unterminated string literal', + }, + { baseDir: join(process.cwd(), 'example_projects/macOS') }, + ), + ).toEqual({ + message: 'unterminated string literal', + location: 'example_projects/macOS/MCPTest/ContentView.swift:16:18', + }); + }); + + it('formats grouped compiler errors without repeating the error prefix per line', () => { + expect( + formatGroupedCompilerErrors( + [ + { + type: 'compiler-error', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + message: 'unterminated string literal', + rawLine: 'ContentView.swift:16:18: error: unterminated string literal', + }, + ], + { baseDir: join(process.cwd(), 'example_projects/macOS') }, + ), + ).toBe( + [ + 'Compiler Errors (1):', + '', + ' \u2717 unterminated string literal', + ' example_projects/macOS/MCPTest/ContentView.swift:16:18', + '', + ].join('\n'), + ); + }); + + it('formats tool-originated warnings with warning emoji', () => { + expect( + formatHumanCompilerWarningEvent({ + type: 'compiler-warning', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'BUILD', + message: 'Using cached build products', + rawLine: 'Using cached build products', + }), + ).toBe(' \u{26A0} Using cached build products'); + }); + + it('formats status-line events with level emojis', () => { + expect( + formatStatusLineEvent({ + type: 'status-line', + timestamp: '2026-03-20T12:00:00.000Z', + level: 'info', + message: 'Resolving app path', + }), + ).toBe('\u{2139}\u{FE0F} Resolving app path'); + + expect( + formatStatusLineEvent({ + type: 'status-line', + timestamp: '2026-03-20T12:00:00.000Z', + level: 'success', + message: 'Build & Run complete', + }), + ).toBe('\u{2705} Build & Run complete'); + }); + + it('formats transient status-line events for info level', () => { + expect( + formatTransientStatusLineEvent({ + type: 'status-line', + timestamp: '2026-03-20T12:00:00.000Z', + level: 'info', + message: 'Resolving app path', + }), + ).toBe('Resolving app path...'); + + expect( + formatTransientStatusLineEvent({ + type: 'status-line', + timestamp: '2026-03-20T12:00:00.000Z', + level: 'success', + message: 'App path resolved', + }), + ).toBeNull(); + }); + + it('formats detail-tree events as a tree section', () => { + const rendered = formatDetailTreeEvent({ + type: 'detail-tree', + timestamp: '2026-03-20T12:00:00.000Z', + items: [ + { label: 'App Path', value: '/tmp/build/MyApp.app' }, + { label: 'Bundle ID', value: 'com.example.myapp' }, + { label: 'App ID', value: 'A1B2C3D4' }, + { label: 'Process ID', value: '12345' }, + { label: 'Launch', value: 'Running' }, + ], + }); + + expect(rendered).toContain(' \u251C App Path: /tmp/build/MyApp.app'); + expect(rendered).toContain(' \u251C Bundle ID: com.example.myapp'); + expect(rendered).toContain(' \u251C App ID: A1B2C3D4'); + expect(rendered).toContain(' \u251C Process ID: 12345'); + expect(rendered).toContain(' \u2514 Launch: Running'); + }); + + it('formats detail-tree with single item using end branch', () => { + expect( + formatDetailTreeEvent({ + type: 'detail-tree', + timestamp: '2026-03-20T12:00:00.000Z', + items: [{ label: 'App Path', value: '/tmp/build/MyApp.app' }], + }), + ).toBe(' \u2514 App Path: /tmp/build/MyApp.app'); + }); + + it('groups test failures by test case within a suite', () => { + const rendered = formatGroupedTestFailures([ + { + type: 'test-failure', + timestamp: '2026-03-20T12:00:00.000Z', + operation: 'TEST', + suite: 'MathTests', + test: 'testAdd', + message: 'XCTAssertEqual failed', + location: '/tmp/MathTests.swift:12', + }, + { + type: 'test-failure', + timestamp: '2026-03-20T12:00:01.000Z', + operation: 'TEST', + suite: 'MathTests', + test: 'testAdd', + message: 'Expected 4, got 5', + location: '/tmp/MathTests.swift:13', + }, + ]); + + expect(rendered).toContain('MathTests'); + expect(rendered).toContain(' ✗ testAdd:'); + expect(rendered).toContain(' - XCTAssertEqual failed'); + expect(rendered).toContain(' - Expected 4, got 5'); + }); +}); diff --git a/src/utils/renderers/cli-text-renderer.ts b/src/utils/renderers/cli-text-renderer.ts new file mode 100644 index 00000000..d2e8fcb3 --- /dev/null +++ b/src/utils/renderers/cli-text-renderer.ts @@ -0,0 +1,225 @@ +import type { + CompilerErrorEvent, + CompilerWarningEvent, + TestFailureEvent, + PipelineEvent, + StatusLineEvent, +} from '../../types/pipeline-events.ts'; +import { createCliProgressReporter } from '../cli-progress-reporter.ts'; +import { formatCliTextLine } from '../terminal-output.ts'; +import { deriveDiagnosticBaseDir } from './index.ts'; +import type { PipelineRenderer } from './index.ts'; +import { + formatHeaderEvent, + formatBuildStageEvent, + formatTransientBuildStageEvent, + formatStatusLineEvent, + formatTransientStatusLineEvent, + formatSectionEvent, + formatDetailTreeEvent, + formatTableEvent, + formatFileRefEvent, + formatGroupedCompilerErrors, + formatGroupedWarnings, + formatGroupedTestFailures, + formatSummaryEvent, + formatNextStepsEvent, +} from './event-formatting.ts'; + +function formatCliTextBlock(text: string): string { + return text + .split('\n') + .map((line) => formatCliTextLine(line)) + .join('\n'); +} + +export function createCliTextRenderer(options: { interactive: boolean }): PipelineRenderer { + const { interactive } = options; + const reporter = createCliProgressReporter(); + const groupedCompilerErrors: CompilerErrorEvent[] = []; + const groupedWarnings: CompilerWarningEvent[] = []; + const groupedTestFailures: TestFailureEvent[] = []; + let pendingTransientRuntimeLine: string | null = null; + let diagnosticBaseDir: string | null = null; + let hasDurableRuntimeContent = false; + let lastVisibleEventType: PipelineEvent['type'] | null = null; + let lastStatusLineLevel: StatusLineEvent['level'] | null = null; + + function writeDurable(text: string): void { + reporter.clear(); + pendingTransientRuntimeLine = null; + hasDurableRuntimeContent = true; + process.stdout.write(`${formatCliTextBlock(text)}\n`); + } + + function writeSection(text: string): void { + reporter.clear(); + pendingTransientRuntimeLine = null; + process.stdout.write(`\n${formatCliTextBlock(text)}\n`); + } + + function flushPendingTransientRuntimeLine(): void { + if (pendingTransientRuntimeLine) { + writeDurable(pendingTransientRuntimeLine); + } + } + + return { + onEvent(event: PipelineEvent): void { + switch (event.type) { + case 'header': { + diagnosticBaseDir = deriveDiagnosticBaseDir(event); + hasDurableRuntimeContent = false; + writeSection(formatHeaderEvent(event)); + lastVisibleEventType = 'header'; + break; + } + + case 'build-stage': { + if (interactive) { + pendingTransientRuntimeLine = formatBuildStageEvent(event); + reporter.update(formatTransientBuildStageEvent(event)); + } else { + writeDurable(formatBuildStageEvent(event)); + } + lastVisibleEventType = 'build-stage'; + break; + } + + case 'status-line': { + const transient = interactive ? formatTransientStatusLineEvent(event) : null; + if (transient) { + pendingTransientRuntimeLine = formatStatusLineEvent(event); + reporter.update(transient); + break; + } + + const compact = + (lastVisibleEventType === 'status-line' && + lastStatusLineLevel !== 'warning' && + event.level !== 'warning') || + lastVisibleEventType === 'summary'; + if (compact) { + writeDurable(formatStatusLineEvent(event)); + } else { + writeSection(formatStatusLineEvent(event)); + } + lastVisibleEventType = 'status-line'; + lastStatusLineLevel = event.level; + break; + } + + case 'section': { + writeSection(formatSectionEvent(event)); + lastVisibleEventType = 'section'; + lastStatusLineLevel = null; + break; + } + + case 'detail-tree': { + writeDurable(formatDetailTreeEvent(event)); + lastVisibleEventType = 'detail-tree'; + lastStatusLineLevel = null; + break; + } + + case 'table': { + writeSection(formatTableEvent(event)); + lastVisibleEventType = 'table'; + lastStatusLineLevel = null; + break; + } + + case 'file-ref': { + writeSection(formatFileRefEvent(event)); + lastVisibleEventType = 'file-ref'; + lastStatusLineLevel = null; + break; + } + + case 'compiler-warning': { + groupedWarnings.push(event); + break; + } + + case 'compiler-error': { + groupedCompilerErrors.push(event); + break; + } + + case 'test-discovery': { + break; + } + + case 'test-progress': { + if (interactive) { + const failWord = event.failed === 1 ? 'failure' : 'failures'; + pendingTransientRuntimeLine = null; + reporter.update(`Running tests (${event.completed}, ${event.failed} ${failWord})`); + } + break; + } + + case 'test-failure': { + groupedTestFailures.push(event); + break; + } + + case 'summary': { + const diagOpts = { baseDir: diagnosticBaseDir ?? undefined }; + const diagnosticSections: string[] = []; + + if (groupedTestFailures.length > 0) { + diagnosticSections.push(formatGroupedTestFailures(groupedTestFailures, diagOpts)); + groupedTestFailures.length = 0; + } + + if (groupedWarnings.length > 0) { + diagnosticSections.push(formatGroupedWarnings(groupedWarnings, diagOpts)); + groupedWarnings.length = 0; + } + + if (event.status === 'FAILED' && groupedCompilerErrors.length > 0) { + diagnosticSections.push(formatGroupedCompilerErrors(groupedCompilerErrors, diagOpts)); + groupedCompilerErrors.length = 0; + } + + if (diagnosticSections.length > 0) { + const diagnosticsBlock = diagnosticSections.join('\n\n'); + if (pendingTransientRuntimeLine) { + writeSection(`${pendingTransientRuntimeLine}\n\n${diagnosticsBlock}`); + pendingTransientRuntimeLine = null; + } else if (hasDurableRuntimeContent) { + writeSection(diagnosticsBlock); + } else { + writeDurable(diagnosticsBlock); + } + } else if (event.status === 'FAILED') { + flushPendingTransientRuntimeLine(); + } + + writeSection(formatSummaryEvent(event)); + lastVisibleEventType = 'summary'; + lastStatusLineLevel = null; + break; + } + + case 'next-steps': { + writeSection(formatNextStepsEvent(event, 'cli')); + lastVisibleEventType = 'next-steps'; + lastStatusLineLevel = null; + break; + } + } + }, + + finalize(): void { + reporter.clear(); + pendingTransientRuntimeLine = null; + diagnosticBaseDir = null; + hasDurableRuntimeContent = false; + lastVisibleEventType = null; + lastStatusLineLevel = null; + }, + }; +} diff --git a/src/utils/renderers/event-formatting.ts b/src/utils/renderers/event-formatting.ts new file mode 100644 index 00000000..c903036c --- /dev/null +++ b/src/utils/renderers/event-formatting.ts @@ -0,0 +1,562 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { globSync } from 'glob'; +import type { + CompilerErrorEvent, + CompilerWarningEvent, + BuildStageEvent, + HeaderEvent, + StatusLineEvent, + SectionEvent, + TableEvent, + FileRefEvent, + DetailTreeEvent, + SummaryEvent, + TestDiscoveryEvent, + TestFailureEvent, + NextStepsEvent, +} from '../../types/pipeline-events.ts'; +import { displayPath } from '../build-preflight.ts'; +import { renderNextStepsSection } from '../responses/next-steps-renderer.ts'; + +// --- Operation emoji map --- + +export const OPERATION_EMOJI: Record = { + Build: '\u{1F528}', + 'Build & Run': '\u{1F680}', + Clean: '\u{1F9F9}', + Test: '\u{1F9EA}', + 'List Schemes': '\u{1F50D}', + 'Show Build Settings': '\u{1F50D}', + 'Get App Path': '\u{1F50D}', + 'Coverage Report': '\u{1F4CA}', + 'File Coverage': '\u{1F4CA}', + 'List Simulators': '\u{1F4F1}', + 'Boot Simulator': '\u{1F4F1}', + 'Open Simulator': '\u{1F4F1}', + 'Set Appearance': '\u{1F3A8}', + 'Set Location': '\u{1F4CD}', + 'Reset Location': '\u{1F4CD}', + Statusbar: '\u{1F4F1}', + 'Erase Simulator': '\u{1F5D1}', + 'List Devices': '\u{1F4F1}', + 'Install App': '\u{1F4E6}', + 'Launch App': '\u{1F680}', + 'Stop App': '\u{1F6D1}', + 'Launch macOS App': '\u{1F680}', + 'Stop macOS App': '\u{1F6D1}', + 'Discover Projects': '\u{1F50D}', + 'Get Bundle ID': '\u{1F50D}', + 'Get macOS Bundle ID': '\u{1F50D}', + 'Scaffold iOS Project': '\u{1F4DD}', + 'Scaffold macOS Project': '\u{1F4DD}', + 'Set Defaults': '\u{2699}\u{FE0F}', + 'Show Defaults': '\u{2699}\u{FE0F}', + 'Clear Defaults': '\u{2699}\u{FE0F}', + 'Use Defaults Profile': '\u{2699}\u{FE0F}', + 'Sync Xcode Defaults': '\u{2699}\u{FE0F}', + 'Start Log Capture': '\u{1F4DD}', + 'Stop Log Capture': '\u{1F4DD}', + 'Attach Debugger': '\u{1F41B}', + 'Add Breakpoint': '\u{1F41B}', + 'Remove Breakpoint': '\u{1F41B}', + Continue: '\u{1F41B}', + Detach: '\u{1F41B}', + 'LLDB Command': '\u{1F41B}', + 'Stack Trace': '\u{1F41B}', + Variables: '\u{1F41B}', + Tap: '\u{1F446}', + Swipe: '\u{1F446}', + 'Type Text': '\u{2328}\u{FE0F}', + Screenshot: '\u{1F4F7}', + 'Snapshot UI': '\u{1F4F7}', + Button: '\u{1F446}', + Gesture: '\u{1F446}', + 'Key Press': '\u{2328}\u{FE0F}', + 'Key Sequence': '\u{2328}\u{FE0F}', + 'Long Press': '\u{1F446}', + Touch: '\u{1F446}', + 'Swift Package Build': '\u{1F4E6}', + 'Swift Package Test': '\u{1F9EA}', + 'Swift Package Clean': '\u{1F9F9}', + 'Swift Package Run': '\u{1F680}', + 'Swift Package List': '\u{1F4E6}', + 'Swift Package Processes': '\u{1F4E6}', + 'Swift Package Stop': '\u{1F6D1}', + 'Xcode IDE Call Tool': '\u{1F527}', + 'Xcode IDE List Tools': '\u{1F527}', + 'Bridge Disconnect': '\u{1F527}', + 'Bridge Status': '\u{1F527}', + 'Bridge Sync': '\u{1F527}', + Doctor: '\u{1FA7A}', + 'Manage Workflows': '\u{2699}\u{FE0F}', + 'Record Video': '\u{1F3AC}', +}; + +// --- Detail tree formatting --- + +function formatDetailTreeLines(details: Array<{ label: string; value: string }>): string[] { + return details.map((detail, index) => { + const branch = index === details.length - 1 ? '\u2514' : '\u251C'; + return ` ${branch} ${detail.label}: ${detail.value}`; + }); +} + +// --- Diagnostic path resolution --- + +const FILE_DIAGNOSTIC_REGEX = + /^(?.+?):(?\d+)(?::(?\d+))?:\s*(?warning|error):\s*(?.+)$/i; +const TOOLCHAIN_DIAGNOSTIC_REGEX = /^(warning|error):\s+.+$/i; +const LINKER_DIAGNOSTIC_REGEX = /^(ld|clang|swiftc):\s+(warning|error):\s+.+$/i; +const DIAGNOSTIC_PATH_IGNORE_PATTERNS = [ + '**/.git/**', + '**/node_modules/**', + '**/build/**', + '**/dist/**', + '**/DerivedData/**', +]; +const resolvedDiagnosticPathCache = new Map(); + +export interface GroupedDiagnosticEntry { + message: string; + location?: string; +} + +export interface DiagnosticFormattingOptions { + baseDir?: string; +} + +function resolveDiagnosticPathCandidate( + filePath: string, + options?: DiagnosticFormattingOptions, +): string { + if (path.isAbsolute(filePath) || !options?.baseDir) { + return filePath; + } + + const directCandidate = path.resolve(options.baseDir, filePath); + if (existsSync(directCandidate)) { + return directCandidate; + } + + if (filePath.includes('/') || filePath.includes(path.sep)) { + return filePath; + } + + const cacheKey = `${options.baseDir}::${filePath}`; + const cached = resolvedDiagnosticPathCache.get(cacheKey); + if (cached !== undefined) { + return cached ?? filePath; + } + + const matches = globSync(`**/${filePath}`, { + cwd: options.baseDir, + nodir: true, + ignore: DIAGNOSTIC_PATH_IGNORE_PATTERNS, + }); + + if (matches.length === 1) { + const resolvedMatch = path.resolve(options.baseDir, matches[0]); + resolvedDiagnosticPathCache.set(cacheKey, resolvedMatch); + return resolvedMatch; + } + + resolvedDiagnosticPathCache.set(cacheKey, null); + return filePath; +} + +function formatDiagnosticFilePath(filePath: string, options?: DiagnosticFormattingOptions): string { + const candidate = resolveDiagnosticPathCandidate(filePath, options); + if (!path.isAbsolute(candidate)) { + return candidate; + } + + const relative = path.relative(process.cwd(), candidate); + if (relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)) { + return relative; + } + + return candidate; +} + +function parseHumanDiagnostic( + event: CompilerWarningEvent | CompilerErrorEvent, + kind: 'warning' | 'error', + options?: DiagnosticFormattingOptions, +): GroupedDiagnosticEntry { + const rawLine = event.rawLine.trim(); + const fileMatch = FILE_DIAGNOSTIC_REGEX.exec(rawLine); + + if (fileMatch?.groups) { + const filePath = formatDiagnosticFilePath(fileMatch.groups.file, options); + const line = fileMatch.groups.line; + const column = fileMatch.groups.column; + const message = fileMatch.groups.message; + const location = column ? `${filePath}:${line}:${column}` : `${filePath}:${line}`; + return { message: `${kind}: ${message}`, location }; + } + + if (TOOLCHAIN_DIAGNOSTIC_REGEX.test(rawLine) || LINKER_DIAGNOSTIC_REGEX.test(rawLine)) { + return { message: `${kind}: ${event.message}` }; + } + + if (event.location) { + return { message: `${event.location}: ${kind}: ${event.message}` }; + } + + return { message: `${kind}: ${event.message}` }; +} + +// --- Canonical event formatters --- + +export function formatHeaderEvent(event: HeaderEvent): string { + const emoji = OPERATION_EMOJI[event.operation] ?? '\u{2699}\u{FE0F}'; + const lines: string[] = [`${emoji} ${event.operation}`, '']; + + for (const param of event.params) { + lines.push(` ${param.label}: ${param.value}`); + } + + if (event.params.length > 0) { + lines.push(''); + } + return lines.join('\n'); +} + +export function formatStatusLineEvent(event: StatusLineEvent): string { + switch (event.level) { + case 'success': + return `\u{2705} ${event.message}`; + case 'error': + return `\u{274C} ${event.message}`; + case 'warning': + return `\u{26A0}\u{FE0F} ${event.message}`; + default: + return `\u{2139}\u{FE0F} ${event.message}`; + } +} + +const SECTION_ICON_MAP: Record, string> = { + 'red-circle': '\u{1F534}', + 'yellow-circle': '\u{1F7E1}', + 'green-circle': '\u{1F7E2}', + checkmark: '\u{2705}', + cross: '\u{274C}', + info: '\u{2139}\u{FE0F}', +}; + +export function formatSectionEvent(event: SectionEvent): string { + const icon = event.icon ? `${SECTION_ICON_MAP[event.icon]} ` : ''; + const headerLine = `${icon}${event.title}`; + if (event.lines.length === 0) { + return headerLine; + } + const indent = event.icon ? ' ' : ' '; + const indented = event.lines.map((line) => (line === '' ? '' : `${indent}${line}`)); + const lines = [headerLine]; + if (event.blankLineAfterTitle) { + lines.push(''); + } + lines.push(...indented); + return lines.join('\n'); +} + +export function formatTableEvent(event: TableEvent): string { + const lines: string[] = []; + if (event.heading) { + lines.push(event.heading); + lines.push(''); + } + + if (event.columns.length === 0 || event.rows.length === 0) { + return lines.join('\n'); + } + + const colWidths = event.columns.map((col) => col.length); + for (const row of event.rows) { + for (let i = 0; i < event.columns.length; i++) { + const value = row[event.columns[i]] ?? ''; + colWidths[i] = Math.max(colWidths[i], value.length); + } + } + + const headerLine = event.columns.map((col, i) => col.padEnd(colWidths[i])).join(' '); + lines.push(headerLine); + lines.push(colWidths.map((w) => '-'.repeat(w)).join(' ')); + + for (const row of event.rows) { + const rowLine = event.columns.map((col, i) => (row[col] ?? '').padEnd(colWidths[i])).join(' '); + lines.push(rowLine); + } + + return lines.join('\n'); +} + +export function formatFileRefEvent(event: FileRefEvent): string { + const displayed = displayPath(event.path); + if (event.label) { + return `${event.label}: ${displayed}`; + } + return displayed; +} + +export function formatDetailTreeEvent(event: DetailTreeEvent): string { + return formatDetailTreeLines(event.items).join('\n'); +} + +// --- Xcodebuild-specific formatters --- + +export function extractGroupedCompilerError( + event: CompilerErrorEvent, + options?: DiagnosticFormattingOptions, +): GroupedDiagnosticEntry | null { + const firstRawLine = event.rawLine.split('\n')[0].trim(); + const fileMatch = FILE_DIAGNOSTIC_REGEX.exec(firstRawLine); + + if (fileMatch?.groups) { + const filePath = formatDiagnosticFilePath(fileMatch.groups.file, options); + const line = fileMatch.groups.line; + const column = fileMatch.groups.column; + const location = column ? `${filePath}:${line}:${column}` : `${filePath}:${line}`; + return { message: event.message, location }; + } + + if (event.location) { + return { message: event.message, location: formatLocationPath(event.location, options) }; + } + + return null; +} + +export function formatGroupedCompilerErrors( + events: CompilerErrorEvent[], + options?: DiagnosticFormattingOptions, +): string { + const hasFileLocated = events.some((e) => extractGroupedCompilerError(e, options) !== null); + const heading = hasFileLocated + ? `Compiler Errors (${events.length}):` + : `Errors (${events.length}):`; + const lines = [heading, '']; + + for (const event of events) { + const fileDiagnostic = extractGroupedCompilerError(event, options); + if (fileDiagnostic) { + lines.push(` \u2717 ${fileDiagnostic.message}`); + if (fileDiagnostic.location) { + lines.push(` ${fileDiagnostic.location}`); + } + } else { + const messageLines = event.message.split('\n'); + lines.push(` \u2717 ${messageLines[0]}`); + for (let i = 1; i < messageLines.length; i++) { + lines.push(` ${messageLines[i]}`); + } + } + lines.push(''); + } + + while (lines.length > 0 && lines.at(-1) === '') { + lines.pop(); + } + + return lines.join('\n') + '\n'; +} + +const BUILD_STAGE_LABEL: Record, string> = { + RESOLVING_PACKAGES: 'Resolving packages', + COMPILING: 'Compiling', + LINKING: 'Linking', + PREPARING_TESTS: 'Preparing tests', + RUN_TESTS: 'Running tests', + ARCHIVING: 'Archiving', +}; + +export function formatBuildStageEvent(event: BuildStageEvent): string { + if (event.stage === 'COMPLETED') { + return event.message; + } + return `\u203A ${BUILD_STAGE_LABEL[event.stage]}`; +} + +export function formatTransientBuildStageEvent(event: BuildStageEvent): string { + if (event.stage === 'COMPLETED') { + return event.message; + } + return `${BUILD_STAGE_LABEL[event.stage]}...`; +} + +export function formatHumanCompilerWarningEvent( + event: CompilerWarningEvent, + options?: DiagnosticFormattingOptions, +): string { + const diagnostic = parseHumanDiagnostic(event, 'warning', options); + const lines = [` \u{26A0} ${event.message}`]; + if (diagnostic.location) { + lines.push(` ${diagnostic.location}`); + } + return lines.join('\n'); +} + +export function formatGroupedWarnings( + events: CompilerWarningEvent[], + options?: DiagnosticFormattingOptions, +): string { + const heading = `Warnings (${events.length}):`; + const lines = [heading, '']; + + for (const event of events) { + lines.push(formatHumanCompilerWarningEvent(event, options)); + lines.push(''); + } + + while (lines.at(-1) === '') { + lines.pop(); + } + + return lines.join('\n'); +} + +export function formatHumanCompilerErrorEvent( + event: CompilerErrorEvent, + options?: DiagnosticFormattingOptions, +): string { + const diagnostic = parseHumanDiagnostic(event, 'error', options); + return diagnostic.location + ? [diagnostic.message, ` ${diagnostic.location}`].join('\n') + : diagnostic.message; +} + +export function formatTransientStatusLineEvent(event: StatusLineEvent): string | null { + if (event.level === 'info') { + return `${event.message}...`; + } + return null; +} + +export function formatTestFailureEvent( + event: TestFailureEvent, + options?: DiagnosticFormattingOptions, +): string { + const parts: string[] = []; + if (event.suite) { + parts.push(event.suite); + } + if (event.test) { + parts.push(event.test); + } + const testPath = parts.length > 0 ? `${parts.join('/')}: ` : ''; + const lines = [` \u{2717} ${testPath}${event.message}`]; + if (event.location) { + lines.push(` ${formatLocationPath(event.location, options)}`); + } + return lines.join('\n'); +} + +function formatLocationPath(location: string, options?: DiagnosticFormattingOptions): string { + const locParts = location.match(/^(.+?)(:(?:\d+)(?::\d+)?)$/); + if (locParts) { + return `${formatDiagnosticFilePath(locParts[1], options)}${locParts[2]}`; + } + return location; +} + +function pluralize(count: number, singular: string, plural: string): string { + return count === 1 ? `${count} ${singular}` : `${count} ${plural}`; +} + +export function formatSummaryEvent(event: SummaryEvent): string { + const succeeded = event.status === 'SUCCEEDED'; + const statusEmoji = succeeded ? '\u{2705}' : '\u{274C}'; + const durationPart = + event.durationMs !== undefined + ? ` (\u{23F1}\u{FE0F} ${(event.durationMs / 1000).toFixed(1)}s)` + : ''; + + const hasTestCounts = event.totalTests !== undefined && event.totalTests > 0; + + if (hasTestCounts) { + const passed = event.passedTests ?? 0; + const failed = event.failedTests ?? 0; + const skipped = event.skippedTests ?? 0; + + if (succeeded) { + return `${statusEmoji} ${pluralize(passed, 'test', 'tests')} passed, ${skipped} skipped${durationPart}`; + } + + return `${statusEmoji} ${pluralize(failed, 'test', 'tests')} failed, ${passed} passed, ${skipped} skipped${durationPart}`; + } + + const op = event.operation + ? event.operation.charAt(0) + event.operation.slice(1).toLowerCase() + : 'Operation'; + const statusWord = succeeded ? 'succeeded' : 'failed'; + + return `${statusEmoji} ${op} ${statusWord}.${durationPart}`; +} + +export function formatTestDiscoveryEvent(event: TestDiscoveryEvent): string { + const testList = event.tests.join(', '); + const truncation = event.truncated ? ` (and more)` : ''; + return `Discovered ${event.total} test(s): ${testList}${truncation}`; +} + +export function formatNextStepsEvent(event: NextStepsEvent, runtime: 'cli' | 'mcp'): string { + return renderNextStepsSection(event.steps, runtime); +} + +export function formatGroupedTestFailures( + events: TestFailureEvent[], + options?: DiagnosticFormattingOptions, +): string { + if (events.length === 0) return ''; + + const allUnnamedSuites = events.every((e) => e.suite === undefined); + + const groupedSuites = new Map>(); + for (const event of events) { + const suiteKey = event.suite ?? '(Unknown Suite)'; + const testKey = event.test ?? '(unknown test)'; + const suiteGroup = groupedSuites.get(suiteKey) ?? new Map(); + const testGroup = suiteGroup.get(testKey) ?? []; + testGroup.push(event); + suiteGroup.set(testKey, testGroup); + groupedSuites.set(suiteKey, suiteGroup); + } + + const lines: string[] = []; + + if (allUnnamedSuites) { + lines.push(`Test Failures (${events.length}):`); + lines.push(''); + for (const [suite, tests] of groupedSuites.entries()) { + lines.push(` ${suite}`); + for (const [testName, failures] of tests.entries()) { + lines.push(` \u{2717} ${testName}`); + for (const failure of failures) { + lines.push(` ${failure.message}`); + if (failure.location) { + lines.push(` ${formatLocationPath(failure.location, options)}`); + } + } + } + } + return lines.join('\n'); + } + + for (const [suite, tests] of groupedSuites.entries()) { + if (lines.length > 0) lines.push(''); + lines.push(suite); + for (const [testName, failures] of tests.entries()) { + lines.push(` ✗ ${testName}:`); + for (const failure of failures) { + const msgIndent = failure.location ? ' ' : ' '; + lines.push(`${msgIndent}- ${failure.message}`); + if (failure.location) { + lines.push(` ${formatLocationPath(failure.location, options)}`); + } + } + } + } + + return lines.join('\n'); +} diff --git a/src/utils/renderers/index.ts b/src/utils/renderers/index.ts new file mode 100644 index 00000000..f51cdeb3 --- /dev/null +++ b/src/utils/renderers/index.ts @@ -0,0 +1,18 @@ +import path from 'node:path'; +import type { HeaderEvent, PipelineEvent } from '../../types/pipeline-events.ts'; + +export interface PipelineRenderer { + onEvent(event: PipelineEvent): void; + finalize(): void; +} + +export function deriveDiagnosticBaseDir(event: HeaderEvent): string | null { + for (const param of event.params) { + if (param.label === 'Workspace' || param.label === 'Project') { + return path.dirname(path.resolve(process.cwd(), param.value)); + } + } + return null; +} + +export { createCliTextRenderer } from './cli-text-renderer.ts'; diff --git a/src/utils/responses/__tests__/next-steps-renderer.test.ts b/src/utils/responses/__tests__/next-steps-renderer.test.ts index acac43d8..d81cef3a 100644 --- a/src/utils/responses/__tests__/next-steps-renderer.test.ts +++ b/src/utils/responses/__tests__/next-steps-renderer.test.ts @@ -1,10 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { - renderNextStep, - renderNextStepsSection, - processToolResponse, -} from '../next-steps-renderer.ts'; -import type { NextStep, ToolResponse } from '../../../types/common.ts'; +import { renderNextStep, renderNextStepsSection } from '../next-steps-renderer.ts'; +import type { NextStep } from '../../../types/common.ts'; describe('next-steps-renderer', () => { describe('renderNextStep', () => { @@ -180,7 +176,7 @@ describe('next-steps-renderer', () => { const result = renderNextStepsSection(steps, 'cli'); expect(result).toBe( - '\n\nNext steps:\n' + + 'Next steps:\n' + '1. Open Simulator: xcodebuildmcp open-sim\n' + '2. Install app: xcodebuildmcp install-app-sim --simulator-id "X"', ); @@ -194,7 +190,7 @@ describe('next-steps-renderer', () => { const result = renderNextStepsSection(steps, 'mcp'); expect(result).toBe( - '\n\nNext steps:\n' + + 'Next steps:\n' + '1. Open Simulator: open_sim()\n' + '2. Install app: install_app_sim({ simulatorId: "X" })', ); @@ -248,110 +244,4 @@ describe('next-steps-renderer', () => { expect(result).toContain('xcodebuildmcp take-screenshot'); }); }); - - describe('processToolResponse', () => { - it('should pass through response with no nextSteps', () => { - const response: ToolResponse = { - content: [{ type: 'text', text: 'Success!' }], - }; - - const result = processToolResponse(response, 'cli', 'normal'); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Success!' }], - }); - }); - - it('should strip nextSteps in minimal style', () => { - const response: ToolResponse = { - content: [{ type: 'text', text: 'Success!' }], - nextSteps: [{ tool: 'foo', cliTool: 'foo', label: 'Do foo', params: {} }], - }; - - const result = processToolResponse(response, 'cli', 'minimal'); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Success!' }], - }); - expect(result.nextSteps).toBeUndefined(); - }); - - it('should append next steps to last text content in normal style', () => { - const response: ToolResponse = { - content: [{ type: 'text', text: 'Simulator booted.' }], - nextSteps: [ - { - tool: 'open_sim', - cliTool: 'open-sim', - label: 'Open Simulator', - params: {}, - priority: 1, - }, - ], - }; - - const result = processToolResponse(response, 'cli', 'normal'); - expect(result.content[0].text).toBe( - 'Simulator booted.\n\nNext steps:\n1. Open Simulator: xcodebuildmcp open-sim', - ); - expect(result.nextSteps).toBeUndefined(); - }); - - it('should render MCP-style for MCP runtime', () => { - const response: ToolResponse = { - content: [{ type: 'text', text: 'Simulator booted.' }], - nextSteps: [{ tool: 'open_sim', label: 'Open Simulator', params: {}, priority: 1 }], - }; - - const result = processToolResponse(response, 'mcp', 'normal'); - expect(result.content[0].text).toBe( - 'Simulator booted.\n\nNext steps:\n1. Open Simulator: open_sim()', - ); - }); - - it('should handle response with empty nextSteps array', () => { - const response: ToolResponse = { - content: [{ type: 'text', text: 'Done.' }], - nextSteps: [], - }; - - const result = processToolResponse(response, 'cli', 'normal'); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Done.' }], - }); - }); - - it('should preserve other response properties', () => { - const response: ToolResponse = { - content: [{ type: 'text', text: 'Error!' }], - isError: true, - _meta: { foo: 'bar' }, - nextSteps: [{ tool: 'retry', cliTool: 'retry', label: 'Retry', params: {} }], - }; - - const result = processToolResponse(response, 'cli', 'minimal'); - expect(result.isError).toBe(true); - expect(result._meta).toEqual({ foo: 'bar' }); - }); - - it('should not mutate original response', () => { - const response: ToolResponse = { - content: [{ type: 'text', text: 'Original' }], - nextSteps: [{ tool: 'foo', cliTool: 'foo', label: 'Foo', params: {} }], - }; - - processToolResponse(response, 'cli', 'normal'); - - expect(response.content[0].text).toBe('Original'); - expect(response.nextSteps).toHaveLength(1); - }); - - it('should default to normal style when not specified', () => { - const response: ToolResponse = { - content: [{ type: 'text', text: 'Success!' }], - nextSteps: [{ tool: 'foo', cliTool: 'foo', label: 'Do foo', params: {} }], - }; - - const result = processToolResponse(response, 'cli'); - expect(result.content[0].text).toContain('Next steps:'); - }); - }); }); diff --git a/src/utils/responses/index.ts b/src/utils/responses/index.ts deleted file mode 100644 index 1707ea06..00000000 --- a/src/utils/responses/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Focused responses facade. - * Prefer importing from 'utils/responses/index.js' instead of the legacy utils barrel. - */ -export { createTextResponse } from '../validation.ts'; -export { - createErrorResponse, - DependencyError, - AxeError, - SystemError, - ValidationError, -} from '../errors.ts'; -export { - processToolResponse, - renderNextStep, - renderNextStepsSection, -} from './next-steps-renderer.ts'; - -// Types -export type { ToolResponse, NextStep, OutputStyle } from '../../types/common.ts'; diff --git a/src/utils/responses/next-steps-renderer.ts b/src/utils/responses/next-steps-renderer.ts index 4ad71e46..ebe30394 100644 --- a/src/utils/responses/next-steps-renderer.ts +++ b/src/utils/responses/next-steps-renderer.ts @@ -1,15 +1,6 @@ import type { RuntimeKind } from '../../runtime/types.ts'; -import type { NextStep, OutputStyle, ToolResponse } from '../../types/common.ts'; - -/** - * Convert a string to kebab-case for CLI flag names. - */ -function toKebabCase(name: string): string { - return name - .replace(/_/g, '-') - .replace(/([a-z])([A-Z])/g, '$1-$2') - .toLowerCase(); -} +import type { NextStep } from '../../types/common.ts'; +import { toKebabCase } from '../../runtime/naming.ts'; function resolveLabel(step: NextStep): string { if (step.label?.trim()) return step.label; @@ -31,7 +22,6 @@ function formatNextStepForCli(step: NextStep): string { const cliTool = step.cliTool ?? toKebabCase(step.tool); const params = step.params ?? {}; - // Include workflow as subcommand if provided if (step.workflow) { parts.push(step.workflow); } @@ -79,9 +69,6 @@ function formatNextStepForMcp(step: NextStep): string { return `${step.tool}({ ${paramsStr} })`; } -/** - * Render a single next step based on runtime. - */ export function renderNextStep(step: NextStep, runtime: RuntimeKind): string { if (!step.tool) { return resolveLabel(step); @@ -93,10 +80,6 @@ export function renderNextStep(step: NextStep, runtime: RuntimeKind): string { return `${step.label}: ${formatted}`; } -/** - * Render the full next steps section. - * Returns empty string if no steps. - */ export function renderNextStepsSection(steps: NextStep[], runtime: RuntimeKind): string { if (steps.length === 0) { return ''; @@ -105,45 +88,5 @@ export function renderNextStepsSection(steps: NextStep[], runtime: RuntimeKind): const sorted = [...steps].sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)); const lines = sorted.map((step, index) => `${index + 1}. ${renderNextStep(step, runtime)}`); - return `\n\nNext steps:\n${lines.join('\n')}`; -} - -/** - * Process a tool response, applying next steps rendering based on runtime and style. - * - * - In 'minimal' style, nextSteps are stripped entirely - * - In 'normal' style, nextSteps are rendered and appended to text content - * - * Returns a new response object (does not mutate the original). - */ -export function processToolResponse( - response: ToolResponse, - runtime: RuntimeKind, - style: OutputStyle = 'normal', -): ToolResponse { - const { nextSteps, ...rest } = response; - - // If no nextSteps or minimal style, strip nextSteps and return - if (!nextSteps || nextSteps.length === 0 || style === 'minimal') { - return { ...rest }; - } - - // Render next steps section - const nextStepsSection = renderNextStepsSection(nextSteps, runtime); - - // Append to the last text content item - const processedContent = response.content.map((item, index) => { - if (item.type === 'text' && index === response.content.length - 1) { - return { ...item, text: item.text + nextStepsSection }; - } - return item; - }); - - // If no text content existed, add one with just the next steps - const hasTextContent = response.content.some((item) => item.type === 'text'); - if (!hasTextContent && nextStepsSection) { - processedContent.push({ type: 'text', text: nextStepsSection.trim() }); - } - - return { ...rest, content: processedContent }; + return `Next steps:\n${lines.join('\n')}`; } diff --git a/src/utils/terminal-output.ts b/src/utils/terminal-output.ts new file mode 100644 index 00000000..039d7c2a --- /dev/null +++ b/src/utils/terminal-output.ts @@ -0,0 +1,50 @@ +const ANSI_RESET = '\u001B[0m'; +const ANSI_RED = '\u001B[31m'; +const ANSI_YELLOW = '\u001B[33m'; + +let cachedUseCliColor: boolean | undefined; + +function shouldUseCliColor(): boolean { + if (cachedUseCliColor === undefined) { + cachedUseCliColor = process.stdout.isTTY === true && process.env.NO_COLOR === undefined; + } + return cachedUseCliColor; +} + +function colorRed(text: string): string { + return `${ANSI_RED}${text}${ANSI_RESET}`; +} + +function colorYellow(text: string): string { + return `${ANSI_YELLOW}${text}${ANSI_RESET}`; +} + +export function formatCliTextLine(line: string): string { + if (!shouldUseCliColor()) { + return line; + } + + if (/^\s*(?:.*:\s+)?(?:fatal )?error:\s/iu.test(line)) { + return colorRed(line); + } + + if (/^\s*⚠ /u.test(line)) { + return line.replace( + /^(\s*)(⚠ )/u, + (_m, indent: string, prefix: string) => `${indent}${colorYellow(prefix)}`, + ); + } + + if (/^\s*✗ /u.test(line)) { + return line.replace( + /^(\s*)(✗ )/u, + (_m, indent: string, prefix: string) => `${indent}${colorRed(prefix)}`, + ); + } + + if (/^❌ /u.test(line)) { + return line.replace(/^(❌ )/u, (_m, prefix: string) => colorRed(prefix)); + } + + return line; +} diff --git a/src/utils/xcodebuild-run-state.ts b/src/utils/xcodebuild-run-state.ts index 1ea7fbb9..d8adff2e 100644 --- a/src/utils/xcodebuild-run-state.ts +++ b/src/utils/xcodebuild-run-state.ts @@ -199,7 +199,7 @@ export function createXcodebuildRunState(options: RunStateOptions): XcodebuildRu const reconciledFailedTests = Math.max(state.failedTests, state.testFailures.length); const reconciledPassedTests = Math.max( 0, - state.completedTests - state.failedTests - state.skippedTests, + state.completedTests - reconciledFailedTests - state.skippedTests, ); const reconciledTotalTests = operation === 'TEST'