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-200.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.
52 changes: 52 additions & 0 deletions packages/browserstack-service/src/cli/grpcClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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<TestSessionEventRequest, 'binSessionId'>) {
PerformanceTester.start(PERFORMANCE_SDK_EVENTS.DISPATCHER_EVENTS.TEST_SESSION)
const workerId = this.getClientWorkerIdFromContext(data.executionContext)
Expand Down Expand Up @@ -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)
}
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
73 changes: 73 additions & 0 deletions packages/browserstack-service/tests/cli/grpcClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,76 @@ describe('GrpcClient.stopBinSession', () => {
expect(request.exitReason).toBeUndefined()
})
})

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

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 })
})
})