From 480d7948bb81f42eb5eade6fad057637aa583aad Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Wed, 16 Sep 2026 12:35:13 +0530 Subject: [PATCH 1/5] feat(browserstack-service): render end-of-build summary entries (SDK-7358) The binary returns CustomerVisibleSummaryEntry items on StopBinSessionResponse for end-of-build messages such as the SDK version nudge. The v8 line had neither the proto field nor a renderer, so those messages were dropped for every wdio v8 customer running through the CLI path. Adds the proto field and a renderer that writes `body` verbatim, picks the stream from `severity` (warn/warning/error -> stderr, everything else including unknown -> stdout so a malformed severity cannot trip CI stderr watchers), and archives a copy to the log file for runners that keep only the log directory. Deliberately does not branch on `entry_type`, so future entry types need no further service change. Mirrors the v9 change on main. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/cli/grpcClient.ts | 47 +++++++++++++ .../browserstack/sdk/v1/sdk-messages.proto | 18 +++++ .../tests/cli/grpcClient.test.ts | 69 +++++++++++++++++++ 3 files changed, 134 insertions(+) diff --git a/packages/browserstack-service/src/cli/grpcClient.ts b/packages/browserstack-service/src/cli/grpcClient.ts index bb189e20..dde45e92 100644 --- a/packages/browserstack-service/src/cli/grpcClient.ts +++ b/packages/browserstack-service/src/cli/grpcClient.ts @@ -278,6 +278,7 @@ export class GrpcClient { try { const response = await stopBinSessionPromise(request) this.logger.info('StopBinSession successful') + this.renderCustomerVisibleSummary(response) PerformanceTester.end(PERFORMANCE_SDK_EVENTS.EVENTS.SDK_CLI_ON_STOP) return response } catch (error: unknown) { @@ -292,6 +293,52 @@ export class GrpcClient { } } + /** + * Render end-of-build customer-visible summary entries. + * + * Per the binary proto contract (CustomerVisibleSummaryEntry in + * sdk-messages.proto): iterate by `severity` + `body`, write `body` verbatim, + * and pick the stream from `severity`. Never branches on `entryType`, so new + * entry types need no SDK change. + * @private + */ + private renderCustomerVisibleSummary(response: unknown) { + try { + const entries = (response as { entries?: Array<{ severity?: string, body?: string }> })?.entries + if (!entries?.length) { + return + } + + for (const entry of entries) { + const body = entry?.body || '' + if (!body) { + continue + } + + const severity = (entry?.severity || 'info').toLowerCase() + // warn/warning/error -> stderr, everything else (info AND unknown) -> + // stdout, so a malformed severity cannot false-alarm CI tooling + // watching stderr. + const isErrorStream = severity === 'warn' || severity === 'warning' || severity === 'error' + // Written directly rather than through the logger, whose per-line + // prefix would break the binary's box-border alignment. + ;(isErrorStream ? process.stderr : process.stdout).write(`${body}\n`) + + // Archived copy — terminal scrollback is lost on CI runners that + // keep only the log directory. + if (severity === 'error') { + this.logger.error(body) + } else if (isErrorStream) { + this.logger.warn(body) + } else { + this.logger.info(body) + } + } + } catch (error: unknown) { + this.logger.debug(`StopBinSession entries forwarding failed: ${util.format(error)}`) + } + } + async testSessionEvent(data: Omit) { PerformanceTester.start(PERFORMANCE_SDK_EVENTS.DISPATCHER_EVENTS.TEST_SESSION) const workerId = this.getClientWorkerIdFromContext(data.executionContext) diff --git a/packages/browserstack-service/src/proto/browserstack/sdk/v1/sdk-messages.proto b/packages/browserstack-service/src/proto/browserstack/sdk/v1/sdk-messages.proto index f6d840e5..b9dc77bd 100644 --- a/packages/browserstack-service/src/proto/browserstack/sdk/v1/sdk-messages.proto +++ b/packages/browserstack-service/src/proto/browserstack/sdk/v1/sdk-messages.proto @@ -157,6 +157,24 @@ message StopBinSessionResponse { optional string error = 2; optional string automate_buildlink = 3; optional string hashed_id = 4; + // End-of-build customer-visible summary entries. Populated on EVERY response + // shape — success, error, and clean-build alike (empty when nothing to + // surface). Iterate by `severity` + `body`; do not branch on `entry_type`, + // so new entry types need no SDK change. + repeated CustomerVisibleSummaryEntry entries = 5; +} + +// A single customer-visible summary entry surfaced at end of build via +// StopBinSessionResponse.entries. The binary owns the prose so all SDKs render +// consistent text; SDKs choose the output stream from `severity`. +message CustomerVisibleSummaryEntry { + // Stable machine-readable identifier (e.g. "network_restrictions"). + string entry_type = 1; + // One of: "info" | "warn" | "error". + string severity = 2; + // Pre-formatted block to display verbatim. May contain embedded newlines. + string body = 3; + optional string doc_link = 4; } message ConnectBinSessionRequest { diff --git a/packages/browserstack-service/tests/cli/grpcClient.test.ts b/packages/browserstack-service/tests/cli/grpcClient.test.ts index 6d55f1e1..b076fe65 100644 --- a/packages/browserstack-service/tests/cli/grpcClient.test.ts +++ b/packages/browserstack-service/tests/cli/grpcClient.test.ts @@ -154,6 +154,75 @@ describe('GrpcClient', () => { expect(request.exitSignal).toBe('') expect(request.exitReason).toBe('') }) + + describe('customer-visible summary entries', () => { + let stdoutSpy: ReturnType + let stderrSpy: ReturnType + + const respondWith = (response: unknown) => { + grpcClient.client = { + stopBinSession: vi.fn().mockImplementation((req, cb) => cb(null, response)) + } as any + } + + beforeEach(() => { + stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + stdoutSpy.mockRestore() + stderrSpy.mockRestore() + }) + + it('writes the body verbatim to stdout for an info entry', async () => { + respondWith({ entries: [{ entryType: 'version_nudge', severity: 'info', body: 'line one\nline two' }] }) + await grpcClient.stopBinSession() + expect(stdoutSpy).toHaveBeenCalledWith('line one\nline two\n') + expect(stderrSpy).not.toHaveBeenCalled() + }) + + it('routes warn and error entries to stderr', async () => { + respondWith({ entries: [ + { entryType: 'version_nudge', severity: 'warn', body: 'outdated' }, + { entryType: 'version_nudge', severity: 'error', body: 'deprecated' } + ] }) + await grpcClient.stopBinSession() + expect(stderrSpy).toHaveBeenCalledWith('outdated\n') + expect(stderrSpy).toHaveBeenCalledWith('deprecated\n') + expect(stdoutSpy).not.toHaveBeenCalled() + }) + + it('treats the server\'s "warning" spelling as an error stream', async () => { + respondWith({ entries: [{ entryType: 'version_nudge', severity: 'warning', body: 'outdated' }] }) + await grpcClient.stopBinSession() + expect(stderrSpy).toHaveBeenCalledWith('outdated\n') + }) + + it('sends an unknown severity to stdout so CI stderr watchers are not tripped', async () => { + respondWith({ entries: [{ entryType: 'version_nudge', severity: 'bogus', body: 'body' }] }) + await grpcClient.stopBinSession() + expect(stdoutSpy).toHaveBeenCalledWith('body\n') + expect(stderrSpy).not.toHaveBeenCalled() + }) + + it('writes nothing when entries are absent, empty, or bodiless', async () => { + for (const response of [{ done: true }, { entries: [] }, { entries: [{ severity: 'warn', body: '' }] }]) { + respondWith(response) + await grpcClient.stopBinSession() + } + expect(stdoutSpy).not.toHaveBeenCalled() + expect(stderrSpy).not.toHaveBeenCalled() + }) + + it('still returns the response when rendering throws', async () => { + stdoutSpy.mockImplementation(() => { + throw new Error('stream closed') + }) + respondWith({ entries: [{ severity: 'info', body: 'body' }], done: true }) + await expect(grpcClient.stopBinSession()).resolves.toMatchObject({ done: true }) + }) + }) }) describe('connectBinSession', () => { From 5b04b27b27b101abd9a00e083a1bc7c912602d99 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 08:07:16 +0000 Subject: [PATCH 2/5] chore(changeset): auto-generate from PR template (minor) --- .changeset/pr-201.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pr-201.md diff --git a/.changeset/pr-201.md b/.changeset/pr-201.md new file mode 100644 index 00000000..b167daeb --- /dev/null +++ b/.changeset/pr-201.md @@ -0,0 +1,5 @@ +--- +"@wdio/browserstack-service": minor +--- + +- End-of-build messages from BrowserStack — such as a notice that your SDK version is outdated or has a known issue — are now shown at the end of your test run and written to the SDK log. From 31a1f2ed838e22e5b8e4668476c2fca0e29e0504 Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Thu, 24 Sep 2026 13:11:00 +0530 Subject: [PATCH 3/5] feat(browserstack-service): tint summary entries and archive them once (SDK-7358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to the end-of-build summary rendering added in this PR. Colour: a warn entry (an outdated SDK) is tinted yellow and an error entry (a deprecated one) red, with the block's first line of text emphasised. Lines are wrapped and reset individually rather than the block as a whole, so a truncated or interleaved write cannot leave the customer's terminal stuck in colour. Applied to the stream copy only — the archived copy stays plain, because escape codes reach a log file as literal bytes and break anchored searches over it. Archiving: switch from the info/warn/error helpers to logToFile. Those helpers also call @wdio/logger, which writes to the console, so the customer saw the block twice — once raw from the stream write, once prefixed by the logger. logToFile writes to the log file only. Unlike v9, this branch's logToFile does not redact, but neither did the helpers it replaces, so redaction behaviour is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/cli/grpcClient.ts | 66 ++++++++++++++++--- .../tests/cli/grpcClient.test.ts | 61 ++++++++++++++++- 2 files changed, 115 insertions(+), 12 deletions(-) diff --git a/packages/browserstack-service/src/cli/grpcClient.ts b/packages/browserstack-service/src/cli/grpcClient.ts index dde45e92..b060e252 100644 --- a/packages/browserstack-service/src/cli/grpcClient.ts +++ b/packages/browserstack-service/src/cli/grpcClient.ts @@ -48,6 +48,14 @@ import { BStackLogger } from './cliLogger.js' // Increased from default 4 MB to accommodate large extension payloads const GRPC_MESSAGE_LIMIT = 20 * 1024 * 1024 // 20 MB in bytes +// Explicit \x1b escapes so the ESC byte stays visible in source. Applied to the +// terminal copy of a summary entry only; the archived copy stays plain. +const SUMMARY_ANSI = { + reset: '\x1b[0m', + error: { base: '\x1b[31m', emphasis: '\x1b[1;31m' }, + warn: { base: '\x1b[33m', emphasis: '\x1b[1;33m' } +} + /** * GrpcClient - Singleton class for managing gRPC client connections * @@ -321,24 +329,64 @@ export class GrpcClient { // watching stderr. const isErrorStream = severity === 'warn' || severity === 'warning' || severity === 'error' // Written directly rather than through the logger, whose per-line - // prefix would break the binary's box-border alignment. - ;(isErrorStream ? process.stderr : process.stdout).write(`${body}\n`) + // prefix would break the binary's box-border alignment. Colour is + // applied here only — the archived copy below stays plain. + ;(isErrorStream ? process.stderr : process.stdout) + .write(`${this.colouriseSummaryBody(body, severity)}\n`) // Archived copy — terminal scrollback is lost on CI runners that // keep only the log directory. - if (severity === 'error') { - this.logger.error(body) - } else if (isErrorStream) { - this.logger.warn(body) - } else { - this.logger.info(body) - } + // + // logToFile, NOT the info/warn/error helpers: those also call + // @wdio/logger, which writes to the console, so the customer + // would see the block twice (once raw above, once prefixed). + this.logger.logToFile(body, severity === 'error' ? 'error' : (isErrorStream ? 'warn' : 'info')) } } catch (error: unknown) { this.logger.debug(`StopBinSession entries forwarding failed: ${util.format(error)}`) } } + /** + * Tint a summary block by severity — yellow for warn (an outdated SDK), red for + * error (a deprecated one), untouched otherwise. + * + * Each line is wrapped and reset on its own rather than the block as a whole, so + * a truncated or interleaved write cannot leave the customer's terminal stuck in + * colour. The first non-blank, non-border line is emphasised. + * @private + */ + private colouriseSummaryBody(body: string, severity: string): string { + try { + const palette = severity === 'error' + ? SUMMARY_ANSI.error + : ((severity === 'warn' || severity === 'warning') ? SUMMARY_ANSI.warn : null) + if (!palette) { + return body + } + + let emphasised = false + return body.split('\n').map((line) => { + const trimmed = line.trim() + if (!trimmed) { + return line + } + // A border is any line carrying no letters or digits, rather than a + // check for the binary's current U+2500 divider — so a change to the + // glyph cannot silently start emphasising the wrong line. + const isBorder = !/[A-Za-z0-9]/.test(trimmed) + if (!isBorder && !emphasised) { + emphasised = true + return `${palette.emphasis}${line}${SUMMARY_ANSI.reset}` + } + return `${palette.base}${line}${SUMMARY_ANSI.reset}` + }).join('\n') + } catch { + // Colour is cosmetic — never let it cost the customer the message. + return body + } + } + async testSessionEvent(data: Omit) { PerformanceTester.start(PERFORMANCE_SDK_EVENTS.DISPATCHER_EVENTS.TEST_SESSION) const workerId = this.getClientWorkerIdFromContext(data.executionContext) diff --git a/packages/browserstack-service/tests/cli/grpcClient.test.ts b/packages/browserstack-service/tests/cli/grpcClient.test.ts index b076fe65..63c52ff4 100644 --- a/packages/browserstack-service/tests/cli/grpcClient.test.ts +++ b/packages/browserstack-service/tests/cli/grpcClient.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { GrpcClient } from '../../src/cli/grpcClient.js' import * as bstackLogger from '../../src/bstackLogger.js' +import { BStackLogger as CliBStackLogger } from '../../src/cli/cliLogger.js' import type { SDKClient } from '../../src/grpc/index.js' import { CLIUtils } from '../../src/cli/cliUtils.js' import type grpc from '@grpc/grpc-js' @@ -188,15 +189,36 @@ describe('GrpcClient', () => { { entryType: 'version_nudge', severity: 'error', body: 'deprecated' } ] }) await grpcClient.stopBinSession() - expect(stderrSpy).toHaveBeenCalledWith('outdated\n') - expect(stderrSpy).toHaveBeenCalledWith('deprecated\n') + expect(stderrSpy).toHaveBeenCalledWith('\x1b[1;33moutdated\x1b[0m\n') + expect(stderrSpy).toHaveBeenCalledWith('\x1b[1;31mdeprecated\x1b[0m\n') expect(stdoutSpy).not.toHaveBeenCalled() }) it('treats the server\'s "warning" spelling as an error stream', async () => { respondWith({ entries: [{ entryType: 'version_nudge', severity: 'warning', body: 'outdated' }] }) await grpcClient.stopBinSession() - expect(stderrSpy).toHaveBeenCalledWith('outdated\n') + expect(stderrSpy).toHaveBeenCalledWith('\x1b[1;33moutdated\x1b[0m\n') + }) + + it('tints a warn block yellow and an error block red, line by line', async () => { + const body = '────\n Title\n\n Detail\n────' + respondWith({ entries: [ + { entryType: 'version_nudge', severity: 'warn', body }, + { entryType: 'version_nudge', severity: 'error', body } + ] }) + await grpcClient.stopBinSession() + + // Borders take the base tint and the first line carrying text is emphasised. + // Blank lines are left alone, and every tinted line closes its own reset, so a + // truncated write cannot leave the terminal stuck in colour. + expect(stderrSpy).toHaveBeenCalledWith( + '\x1b[33m────\x1b[0m\n\x1b[1;33m Title\x1b[0m\n\n' + + '\x1b[33m Detail\x1b[0m\n\x1b[33m────\x1b[0m\n' + ) + expect(stderrSpy).toHaveBeenCalledWith( + '\x1b[31m────\x1b[0m\n\x1b[1;31m Title\x1b[0m\n\n' + + '\x1b[31m Detail\x1b[0m\n\x1b[31m────\x1b[0m\n' + ) }) it('sends an unknown severity to stdout so CI stderr watchers are not tripped', async () => { @@ -215,6 +237,39 @@ describe('GrpcClient', () => { expect(stderrSpy).not.toHaveBeenCalled() }) + it('archives via logToFile only, so the block is never printed twice', async () => { + // logToFile writes to the log file only. info/warn/error additionally + // call @wdio/logger, which writes to the console — using them here + // would duplicate the block the stream writes above already emitted. + const toFile = vi.spyOn(CliBStackLogger, 'logToFile').mockImplementation(() => {}) + const infoSpy = vi.spyOn(CliBStackLogger, 'info').mockImplementation(() => {}) + const warnSpy = vi.spyOn(CliBStackLogger, 'warn').mockImplementation(() => {}) + const errorSpy = vi.spyOn(CliBStackLogger, 'error').mockImplementation(() => {}) + + respondWith({ entries: [ + { entryType: 'version_nudge', severity: 'warning', body: 'outdated' }, + { entryType: 'version_nudge', severity: 'error', body: 'deprecated' }, + { entryType: 'version_nudge', severity: 'info', body: 'notice' } + ] }) + await grpcClient.stopBinSession() + + expect(toFile).toHaveBeenCalledWith('outdated', 'warn') + expect(toFile).toHaveBeenCalledWith('deprecated', 'error') + expect(toFile).toHaveBeenCalledWith('notice', 'info') + // The console-writing helpers must never receive a body. (They are + // still used for unrelated lines such as "StopBinSession successful".) + for (const body of ['outdated', 'deprecated', 'notice']) { + expect(warnSpy).not.toHaveBeenCalledWith(body) + expect(errorSpy).not.toHaveBeenCalledWith(body) + expect(infoSpy).not.toHaveBeenCalledWith(body) + } + + toFile.mockRestore() + infoSpy.mockRestore() + warnSpy.mockRestore() + errorSpy.mockRestore() + }) + it('still returns the response when rendering throws', async () => { stdoutSpy.mockImplementation(() => { throw new Error('stream closed') From f8b9c00f884ed773db2473ff1ba5fa4d8f2bc965 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:44:31 +0000 Subject: [PATCH 4/5] chore(changeset): auto-generate from PR template (minor) --- .changeset/pr-201.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/pr-201.md b/.changeset/pr-201.md index b167daeb..43cd3f4e 100644 --- a/.changeset/pr-201.md +++ b/.changeset/pr-201.md @@ -2,4 +2,4 @@ "@wdio/browserstack-service": minor --- -- End-of-build messages from BrowserStack — such as a notice that your SDK version is outdated or has a known issue — are now shown at the end of your test run and written to the SDK log. +- End-of-build messages from BrowserStack — such as a notice that your SDK version is outdated or has a known issue — are now shown at the end of your test run, highlighted in yellow for a warning and red for an error, and written to the SDK log without colour so they stay searchable. From b7b0f0aa12f3384f613e52bb6d7480c725ba264e Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Thu, 24 Sep 2026 17:01:16 +0530 Subject: [PATCH 5/5] fix(browserstack-service): scope the summary-entry catch per entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on SDK-7358. renderCustomerVisibleSummary wrapped the whole `for (const entry of entries)` loop in one try/catch, so a stream that rejected entry N aborted the loop: entries N+1.. were neither written nor archived. The PR's own "still returns the response when rendering throws" test shows a throwing write is a considered scenario. Only one entry exists today (the version nudge), but the proto is explicitly built for more entry types, so the gap widens as they are added. Moves the catch inside the loop body, and writes the archived copy before the stream write so archival no longer depends on the write succeeding. The outer catch stays — stopBinSession rethrows, so a throw escaping this method would cost the caller its response. Covered by a new test that fails without the fix (only the throwing entry reaches stderr; the two after it are dropped). Package suite on Node 25: 46 files, 1056 tests, 0 failures. tsc --noEmit exit 0. (No lint script exists on this branch.) Co-Authored-By: Claude Opus 5 (1M context) --- .../src/cli/grpcClient.ts | 53 +++++++++++-------- .../tests/cli/grpcClient.test.ts | 29 ++++++++++ 2 files changed, 60 insertions(+), 22 deletions(-) diff --git a/packages/browserstack-service/src/cli/grpcClient.ts b/packages/browserstack-service/src/cli/grpcClient.ts index b060e252..7e00ebdc 100644 --- a/packages/browserstack-service/src/cli/grpcClient.ts +++ b/packages/browserstack-service/src/cli/grpcClient.ts @@ -318,29 +318,38 @@ export class GrpcClient { } for (const entry of entries) { - const body = entry?.body || '' - if (!body) { - continue + // Scoped per entry, not around the loop: a stream that rejects one + // entry must not drop the entries after it. The proto allows many + // entry types, so this widens as more are added. + try { + const body = entry?.body || '' + if (!body) { + continue + } + + const severity = (entry?.severity || 'info').toLowerCase() + // warn/warning/error -> stderr, everything else (info AND unknown) -> + // stdout, so a malformed severity cannot false-alarm CI tooling + // watching stderr. + const isErrorStream = severity === 'warn' || severity === 'warning' || severity === 'error' + + // Archived copy is written FIRST — terminal scrollback is lost on + // CI runners that keep only the log directory, so the durable copy + // must not depend on the stream write succeeding. + // + // logToFile, NOT the info/warn/error helpers: those also call + // @wdio/logger, which writes to the console, so the customer + // would see the block twice (once raw below, once prefixed). + this.logger.logToFile(body, severity === 'error' ? 'error' : (isErrorStream ? 'warn' : 'info')) + + // Written directly rather than through the logger, whose per-line + // prefix would break the binary's box-border alignment. Colour is + // applied here only — the archived copy above stays plain. + ;(isErrorStream ? process.stderr : process.stdout) + .write(`${this.colouriseSummaryBody(body, severity)}\n`) + } catch (error: unknown) { + this.logger.debug(`StopBinSession entry forwarding failed: ${util.format(error)}`) } - - const severity = (entry?.severity || 'info').toLowerCase() - // warn/warning/error -> stderr, everything else (info AND unknown) -> - // stdout, so a malformed severity cannot false-alarm CI tooling - // watching stderr. - const isErrorStream = severity === 'warn' || severity === 'warning' || severity === 'error' - // Written directly rather than through the logger, whose per-line - // prefix would break the binary's box-border alignment. Colour is - // applied here only — the archived copy below stays plain. - ;(isErrorStream ? process.stderr : process.stdout) - .write(`${this.colouriseSummaryBody(body, severity)}\n`) - - // Archived copy — terminal scrollback is lost on CI runners that - // keep only the log directory. - // - // logToFile, NOT the info/warn/error helpers: those also call - // @wdio/logger, which writes to the console, so the customer - // would see the block twice (once raw above, once prefixed). - this.logger.logToFile(body, severity === 'error' ? 'error' : (isErrorStream ? 'warn' : 'info')) } } catch (error: unknown) { this.logger.debug(`StopBinSession entries forwarding failed: ${util.format(error)}`) diff --git a/packages/browserstack-service/tests/cli/grpcClient.test.ts b/packages/browserstack-service/tests/cli/grpcClient.test.ts index 63c52ff4..12f5e22c 100644 --- a/packages/browserstack-service/tests/cli/grpcClient.test.ts +++ b/packages/browserstack-service/tests/cli/grpcClient.test.ts @@ -270,6 +270,35 @@ describe('GrpcClient', () => { errorSpy.mockRestore() }) + it('keeps rendering and archiving the entries after one whose write throws', async () => { + // The catch is scoped per entry, not around the loop: a stream that + // rejects entry one must not silently drop entries two and three. + const toFile = vi.spyOn(CliBStackLogger, 'logToFile').mockImplementation(() => {}) + stderrSpy.mockImplementation((chunk: any) => { + if (String(chunk).includes('first')) { + throw new Error('stream closed') + } + return true + }) + + respondWith({ entries: [ + { entryType: 'version_nudge', severity: 'warn', body: 'first' }, + { entryType: 'version_nudge', severity: 'warn', body: 'second' }, + { entryType: 'version_nudge', severity: 'info', body: 'third' } + ] }) + await grpcClient.stopBinSession() + + expect(stderrSpy).toHaveBeenCalledWith('\x1b[1;33msecond\x1b[0m\n') + expect(stdoutSpy).toHaveBeenCalledWith('third\n') + // Archival runs before the stream write, so even the entry whose + // write threw is still kept in the log directory. + expect(toFile).toHaveBeenCalledWith('first', 'warn') + expect(toFile).toHaveBeenCalledWith('second', 'warn') + expect(toFile).toHaveBeenCalledWith('third', 'info') + + toFile.mockRestore() + }) + it('still returns the response when rendering throws', async () => { stdoutSpy.mockImplementation(() => { throw new Error('stream closed')