diff --git a/packages/cli/src/reporters/__tests__/abstract-list.spec.ts b/packages/cli/src/reporters/__tests__/abstract-list.spec.ts index be46fba01..60fe1c4e5 100644 --- a/packages/cli/src/reporters/__tests__/abstract-list.spec.ts +++ b/packages/cli/src/reporters/__tests__/abstract-list.spec.ts @@ -61,6 +61,21 @@ function countOccurrences (text: string, substring: string): number { return text.split(substring).length - 1 } +function withStdoutTty (isTTY: boolean, callback: () => T): T { + const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY') + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: isTTY }) + + try { + return callback() + } finally { + if (stdoutDescriptor) { + Object.defineProperty(process.stdout, 'isTTY', stdoutDescriptor) + } else { + delete (process.stdout as { isTTY?: boolean }).isTTY + } + } +} + describe('AbstractListReporter', () => { beforeEach(() => { printLnMock.mockClear() @@ -99,11 +114,13 @@ describe('AbstractListReporter', () => { }) it('should populate _clearString after _printSummary runs', () => { - const { reporter } = makeReporterWithOneCheck() + withStdoutTty(true, () => { + const { reporter } = makeReporterWithOneCheck() - reporter._printSummary() + reporter._printSummary() - expect(reporter._clearString).not.toBe('') + expect(reporter._clearString).not.toBe('') + }) }) it('should include cancellation message with --detach hint after onCancel', () => { @@ -139,6 +156,20 @@ describe('AbstractListReporter', () => { expect(countOccurrences(summary, `Open session: ${TEST_SESSION_URL}`)).toBe(1) }) + it('does not redraw the check list for detached non-TTY output', () => { + withStdoutTty(false, () => { + const { reporter } = makeReporterWithOneCheck(TEST_SESSION_ID) + reporter.onDetach() + + const summary = printLnMock.mock.calls.map(([text]: [string]) => text).join('\n') + expect(countOccurrences(summary, SOURCE_FILE)).toBe(1) + expect(countOccurrences(summary, 'My API Check')).toBe(1) + expect(summary).toContain('Checks will continue running in the cloud.') + expect(summary).toContain('1 scheduling, 1 total') + expect(summary).not.toContain('\x1B[K') + }) + }) + it('should render existing detailed session summary link output', async () => { vi.mocked(api.testSessions.getShortLink).mockResolvedValue({ data: { diff --git a/packages/cli/src/reporters/abstract-list.ts b/packages/cli/src/reporters/abstract-list.ts index b0764ac64..c9c79ada0 100644 --- a/packages/cli/src/reporters/abstract-list.ts +++ b/packages/cli/src/reporters/abstract-list.ts @@ -149,14 +149,26 @@ export default abstract class AbstractListReporter implements Reporter { onCancel (): void { this._isCancelling = true - this._clearSummary() - this._printSummary() + if (this._shouldRenderLiveSummary()) { + this._clearSummary() + this._printSummary() + return + } + this._printSummary({ includeCheckTitles: false }) } onDetach (): void { this._isDetaching = true - this._clearSummary() - this._printSummary() + if (this._shouldRenderLiveSummary()) { + this._clearSummary() + this._printSummary() + return + } + this._printSummary({ includeCheckTitles: false }) + } + + private _shouldRenderLiveSummary (): boolean { + return process.stdout.isTTY === true } // Clear the summary which was printed by _printStatus from stdout @@ -169,17 +181,18 @@ export default abstract class AbstractListReporter implements Reporter { } } - _printSummary (opts: { skipCheckCount?: boolean } = {}) { + _printSummary (opts: { skipCheckCount?: boolean, includeCheckTitles?: boolean } = {}) { const counts = { numFailed: 0, numPassed: 0, numDegraded: 0, numRunning: 0, numRetrying: 0, scheduling: 0, numCancelled: 0, } const status = [] - if (this.checkFilesMap!.size === 1 && this.checkFilesMap!.has(undefined)) { + const includeCheckTitles = opts.includeCheckTitles ?? true + if (includeCheckTitles && this.checkFilesMap!.size === 1 && this.checkFilesMap!.has(undefined)) { status.push(chalk.bold('Summary:')) } for (const [sourceFile, checkMap] of this.checkFilesMap!.entries()) { - if (sourceFile) status.push(sourceFile) + if (includeCheckTitles && sourceFile) status.push(sourceFile) for (const [, { titleString, result, checkStatus }] of checkMap.entries()) { if (checkStatus === CheckStatus.SCHEDULING) { counts.scheduling++ @@ -196,7 +209,9 @@ export default abstract class AbstractListReporter implements Reporter { } else { counts.numPassed++ } - status.push(sourceFile ? indentString(titleString, 2) : titleString) + if (includeCheckTitles) { + status.push(sourceFile ? indentString(titleString, 2) : titleString) + } } } @@ -241,8 +256,10 @@ export default abstract class AbstractListReporter implements Reporter { const statusString = status.join('\n') printLn(statusString) - // Ansi escape code for erasing the line and moving the cursor up - this._clearString = '\r\x1B[K\r\x1B[1A'.repeat(statusString.split('\n').length + 1) + if (this._shouldRenderLiveSummary()) { + // Ansi escape code for erasing the line and moving the cursor up. + this._clearString = '\r\x1B[K\r\x1B[1A'.repeat(statusString.split('\n').length + 1) + } } _printBriefSummary () { @@ -275,8 +292,10 @@ export default abstract class AbstractListReporter implements Reporter { status.push('') const statusString = status.join('\n') printLn(statusString) - // Ansi escape code for erasing the line and moving the cursor up - this._clearString = '\r\x1B[K\r\x1B[1A'.repeat(statusString.split('\n').length + 1) + if (this._shouldRenderLiveSummary()) { + // Ansi escape code for erasing the line and moving the cursor up. + this._clearString = '\r\x1B[K\r\x1B[1A'.repeat(statusString.split('\n').length + 1) + } } // Checks that fail with a run error (e.g. the CLI timing out while waiting for a result)