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. 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', () => {