From 53fed741cbaa76a26bf7b79bfaff10b64b6a1e50 Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Tue, 15 Sep 2026 22:17:25 +0530 Subject: [PATCH 1/2] 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 service had neither the proto field nor a renderer, so those messages were dropped for every wdio 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. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/cli/grpcClient.ts | 52 +++++++++++++ .../browserstack/sdk/v1/sdk-messages.proto | 18 +++++ .../tests/cli/grpcClient.test.ts | 73 +++++++++++++++++++ 3 files changed, 143 insertions(+) diff --git a/packages/browserstack-service/src/cli/grpcClient.ts b/packages/browserstack-service/src/cli/grpcClient.ts index 51243188..2ad167d2 100644 --- a/packages/browserstack-service/src/cli/grpcClient.ts +++ b/packages/browserstack-service/src/cli/grpcClient.ts @@ -277,6 +277,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) { @@ -291,6 +292,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) @@ -496,6 +543,11 @@ export class GrpcClient { message: log.message, timestamp: log.timestamp, level: log.level, + // Attachment entries carry no message — the binary streams the file + // from filePath when it drains its upload queue. + fileName: log.fileName, + fileSize: log.fileSize, + filePath: log.filePath, }) logEntries.push(logEntry) } 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 e63ab79f..af6cc056 100644 --- a/packages/browserstack-service/tests/cli/grpcClient.test.ts +++ b/packages/browserstack-service/tests/cli/grpcClient.test.ts @@ -51,3 +51,76 @@ describe('GrpcClient.stopBinSession', () => { expect(request.exitReason).toBeUndefined() }) }) + +describe('GrpcClient.stopBinSession customer-visible summary entries', () => { + let client: GrpcClient + let stdoutSpy: ReturnType + let stderrSpy: ReturnType + + const respondWith = (response: unknown) => { + client.client = { + stopBinSession: vi.fn((_req: unknown, cb: (err: unknown, res: unknown) => void) => cb(null, response)) + } as any + } + + beforeEach(() => { + client = new GrpcClient() + client.binSessionId = 'bin-1' + stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + stdoutSpy.mockRestore() + stderrSpy.mockRestore() + vi.clearAllMocks() + }) + + 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 client.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 client.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 client.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 client.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 client.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(client.stopBinSession()).resolves.toMatchObject({ done: true }) + }) +}) From 4d39f2a26ca7d92bb707fe24a7701093fa462cf3 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:06:55 +0000 Subject: [PATCH 2/2] chore(changeset): auto-generate from PR template (minor) --- .changeset/pr-200.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pr-200.md diff --git a/.changeset/pr-200.md b/.changeset/pr-200.md new file mode 100644 index 00000000..b167daeb --- /dev/null +++ b/.changeset/pr-200.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.