Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pr-201.md
Original file line number Diff line number Diff line change
@@ -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.
47 changes: 47 additions & 0 deletions packages/browserstack-service/src/cli/grpcClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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<TestSessionEventRequest, 'binSessionId'>) {
PerformanceTester.start(PERFORMANCE_SDK_EVENTS.DISPATCHER_EVENTS.TEST_SESSION)
const workerId = this.getClientWorkerIdFromContext(data.executionContext)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
69 changes: 69 additions & 0 deletions packages/browserstack-service/tests/cli/grpcClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,75 @@ describe('GrpcClient', () => {
expect(request.exitSignal).toBe('')
expect(request.exitReason).toBe('')
})

describe('customer-visible summary entries', () => {
let stdoutSpy: ReturnType<typeof vi.spyOn>
let stderrSpy: ReturnType<typeof vi.spyOn>

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