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-190.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@wdio/browserstack-service": patch
---

- Fixed screenshots taken during a Mocha test not appearing in Test Reporting's consolidated logs.
Original file line number Diff line number Diff line change
Expand Up @@ -266,12 +266,14 @@ export default class WdioMochaTestFramework extends TestFramework {
*/
loadLogEntries(instance: TestFrameworkInstance, testFrameworkState: State, hookState: State, logEntry: Record<string, unknown>) {
const logRecord: Record<string, unknown> = {}
const { level, message, timestamp } = logEntry
const { level, message, timestamp, kind } = logEntry

if (CLIUtils.matchHookRegex(instance.getCurrentTestState().toString().split('.')[1])) {
logRecord[TestFrameworkConstants.KEY_HOOK_ID] = TestFramework.getState(instance, TestFrameworkConstants.KEY_HOOK_ID)
}
logRecord.kind = TestFrameworkConstants.KIND_LOG
// Console logs carry no kind and stay KIND_LOG; a producer that sets one (a screenshot,
// say) keeps it, or the entry would reach Observability labelled as a console log.
logRecord.kind = kind ?? TestFrameworkConstants.KIND_LOG
logRecord.message = Buffer.from(message as string)
logRecord.level = level
logRecord.timestamp = timestamp
Expand Down
39 changes: 31 additions & 8 deletions packages/browserstack-service/src/insights-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ import {
removeAnsiColors,
getObservabilityProduct,
generateHashCodeFromFields,
isTrue
isTrue,
isFalse
} from './util.js'
import type {
TestData,
Expand All @@ -45,6 +46,7 @@ import { TESTOPS_SCREENSHOT_ENV } from './constants.js'
import { BrowserstackCLI } from './cli/index.js'
import { TestFrameworkState } from './cli/states/testFrameworkState.js'
import { HookState } from './cli/states/hookState.js'
import { TestFrameworkConstants } from './cli/frameworks/constants/testFrameworkConstants.js'
import PerformanceTester from './instrumentation/performance/performance-tester.js'
import * as PERFORMANCE_SDK_EVENTS from './instrumentation/performance/constants.js'
import CustomTagsHandler from './custom-tags-handler.js'
Expand Down Expand Up @@ -790,13 +792,34 @@ class _InsightsHandler {
// log screenshot
const body = 'body' in args ? args.body : undefined
const result = 'result' in args ? args.result as { value: string } : undefined
if (Boolean(process.env[TESTOPS_SCREENSHOT_ENV]) && isScreenshotCommand(args) && result?.value) {
await this.listener.onScreenshot([{
test_run_uuid: testMeta.uuid,
timestamp: new Date().toISOString(),
message: result.value,
kind: 'TEST_SCREENSHOT'
}])
// `allow_screenshots` is an optional *string* on the wire, so a denial arrives as the
// string 'false' — which Boolean() reads as permission granted. Honour it explicitly.
const allowScreenshots = process.env[TESTOPS_SCREENSHOT_ENV]
if (Boolean(allowScreenshots) && !isFalse(allowScreenshots) && isScreenshotCommand(args) && result?.value) {
// On the binary path the direct screenshot endpoint answers 401 to the binary's JWT,
// so ride the same LOG rail appendTestItemLog uses: the CLI stamps the test uuid and
// forwards the entry over gRPC, where the binary owns reporting. The framework can be
// unset while isRunning() is true — the dev-env short-circuit returns true before
// setupTestFramework() has run, and it only assigns for webdriverio-mocha — so resolve
// it rather than assert, and let an untracked framework fall through to the direct
// upload instead of throwing the screenshot away.
const cliTestFramework = BrowserstackCLI.getInstance().isRunning()
? BrowserstackCLI.getInstance().getTestFramework()
: undefined
await (cliTestFramework
? cliTestFramework.trackEvent(TestFrameworkState.LOG, HookState.POST, {
logEntry: {
kind: TestFrameworkConstants.KIND_SCREENSHOT,
message: result.value,
timestamp: new Date().toISOString()
}
})
: this.listener.onScreenshot([{
test_run_uuid: testMeta.uuid,
timestamp: new Date().toISOString(),
message: result.value,
kind: 'TEST_SCREENSHOT'
}]))
}

const requestData = this._commands[dataKey]
Expand Down
19 changes: 19 additions & 0 deletions packages/browserstack-service/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,25 @@ export default class BrowserstackService implements Services.ServiceInstance {
BStackLogger.info(`CLI is running, tracking insights event for before: ${sessionId}`)
await BrowserstackCLI.getInstance().getAutomationFramework()!.trackEvent(AutomationFrameworkState.CREATE, HookState.POST, { browser: this._browser, hubUrl: this._config.hostname })
this._insightsHandler.setGitConfigPath()
/**
* `browserCommand` is the only producer of TEST_SCREENSHOT logs — the binary
* has no screenshot producer of its own — so the result event has to be
* registered on this path too, or a screenshot taken mid-test never reaches
* Observability (SDK-4177). The `command` (beforeCommand) event is
* deliberately NOT registered: it only fills the map that browserCommand's
* HTTP-log half reads, and that half emits on the JS listener pipeline the
* binary owns here. Leaving it unregistered keeps the screenshot upload —
* which rides its own JWT-authenticated endpoint — as the single effect.
*/
this._browser.on('result', (result) => {
if (shouldProcessEventForTesthub('')) {
this._insightsHandler?.browserCommand(
'client:afterCommand',
Object.assign(result, { sessionId }),
this._currentTest
)
}
})
PerformanceTester.end(PERFORMANCE_SDK_EVENTS.DRIVER_EVENT.PRE_INITIALIZE)
return
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import * as bstackLogger from '../../src/bstackLogger.js'

import WdioMochaTestFramework from '../../src/cli/frameworks/wdioMochaTestFramework.js'
import TestFramework from '../../src/cli/frameworks/testFramework.js'
import { TestFrameworkConstants } from '../../src/cli/frameworks/constants/testFrameworkConstants.js'
import { TestFrameworkState } from '../../src/cli/states/testFrameworkState.js'
import { HookState } from '../../src/cli/states/hookState.js'

vi.spyOn(bstackLogger.BStackLogger, 'logToFile').mockImplementation(() => {})

describe('SDK-4177 — loadLogEntries must not relabel a log that carries its own kind', () => {
let entries: unknown[]
let instance: any

beforeEach(() => {
entries = []
instance = {
getCurrentTestState: () => TestFrameworkState.TEST,
updateMultipleEntries: vi.fn(),
}
vi.spyOn(TestFramework, 'getState').mockReturnValue(entries)
vi.spyOn(WdioMochaTestFramework, 'lastActiveHook').mockReturnValue(null)
})

afterEach(() => {
vi.restoreAllMocks()
})

const load = (logEntry: Record<string, unknown>) => {
WdioMochaTestFramework.prototype.loadLogEntries.call(
WdioMochaTestFramework.prototype,
instance,
TestFrameworkState.LOG,
HookState.POST,
logEntry
)
return entries[0] as Record<string, unknown>
}

it('keeps TEST_SCREENSHOT, which the kind was previously hardcoded over', () => {
// Hardcoding KIND_LOG here meant a screenshot reached Observability labelled as a
// console log, so no screenshots manifest was ever built for the test run.
const record = load({
kind: TestFrameworkConstants.KIND_SCREENSHOT,
message: 'aBase64Screenshot',
timestamp: '2020-01-01T00:00:00.000Z',
})

expect(record.kind).toBe('TEST_SCREENSHOT')
expect(record.message).toEqual(Buffer.from('aBase64Screenshot'))
})

it('still labels a console log TEST_LOG', () => {
const record = load({
kind: TestFrameworkConstants.KIND_LOG,
message: 'hello',
level: 'info',
timestamp: '2020-01-01T00:00:00.000Z',
})

expect(record.kind).toBe('TEST_LOG')
expect(record.level).toBe('info')
})

it('falls back to TEST_LOG for an entry with no kind', () => {
expect(load({ message: 'hello', timestamp: '2020-01-01T00:00:00.000Z' }).kind).toBe('TEST_LOG')
})
})
28 changes: 28 additions & 0 deletions packages/browserstack-service/tests/insights-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import InsightsHandler from '../src/insights-handler.js'
import * as utils from '../src/util.js'
import * as bstackLogger from '../src/bstackLogger.js'
import { TESTOPS_SCREENSHOT_ENV } from '../src/constants.js'
import { BrowserstackCLI } from '../src/cli/index.js'

const log = logger('test')
let insightsHandler: InsightsHandler
Expand Down Expand Up @@ -697,6 +698,33 @@ describe('browserCommand', () => {
delete process.env[TESTOPS_SCREENSHOT_ENV]
})

it('client:afterCommand - falls back to the direct upload when the CLI has no test framework', async () => {
// isRunning() can be true before setupTestFramework() has assigned one (dev-env
// short-circuit, or a framework the CLI doesn't track). Asserting non-null there threw the
// screenshot away; resolving it keeps the direct upload as the fallback.
process.env[TESTOPS_SCREENSHOT_ENV] = 'true'
commandSpy.mockImplementation(() => { return true })
const getInstanceSpy = vi.spyOn(BrowserstackCLI, 'getInstance').mockReturnValue({
isRunning: () => true,
getTestFramework: () => undefined
} as any)

await insightsHandler.browserCommand('client:afterCommand', { sessionId: 's', method: 'm', endpoint: 'e', result: { value: 'random' } } as any, {} as any)

expect(uploadEventDataSpy).toBeCalled()
getInstanceSpy.mockRestore()
delete process.env[TESTOPS_SCREENSHOT_ENV]
})

it('client:afterCommand - screenshot not uploaded when screenshots are denied', () => {
// arrives as the string 'false' off the wire, which Boolean() would read as granted
process.env[TESTOPS_SCREENSHOT_ENV] = 'false'
commandSpy.mockImplementation(() => { return true })
insightsHandler.browserCommand('client:afterCommand', { sessionId: 's', method: 'm', endpoint: 'e', result: { value: 'random' } } as any, {} as any)
expect(uploadEventDataSpy).toBeCalledTimes(0)
delete process.env[TESTOPS_SCREENSHOT_ENV]
})

it('return if test not in _tests', () => {
insightsHandler.browserCommand('client:afterCommand', { sessionId: 's', method: 'm', endpoint: 'e', result: { value: 'random' } } as any, {} as any)
insightsHandler['_tests'] = { 'test title not there': { 'uuid': 'uuid' } }
Expand Down
28 changes: 28 additions & 0 deletions packages/browserstack-service/tests/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,34 @@ describe('before', () => {
expect(service['_sessionBaseUrl']).toEqual(sessionBaseUrl)
})

it('registers the result event on the CLI path, and only that event', async () => {
// browserCommand is the only producer of TEST_SCREENSHOT logs, so the binary flow has to
// register the result event too (SDK-4177). `command` must stay unregistered — it only
// feeds browserCommand's HTTP-log half, which the binary owns on this path.
process.env.BROWSERSTACK_OBSERVABILITY = 'true'
const getInstanceSpy = vi.spyOn(BrowserstackCLI, 'getInstance').mockReturnValue({
isRunning: () => true,
getTestFramework: () => null,
getAutomationFramework: () => ({
trackEvent: vi.fn().mockResolvedValue(undefined)
})
} as any)
const service = new BrowserstackService({} as any, [{}] as any, {
user: 'foo',
key: 'bar',
capabilities: {}
})

await service.before(service['_config'] as any, [], browser)

const events = vi.mocked(browser.on).mock.calls.map(([event]) => event)
expect(events).toContain('result')
expect(events).not.toContain('command')

getInstanceSpy.mockRestore()
delete process.env.BROWSERSTACK_OBSERVABILITY
})

it('should initialize correctly for multiremote', () => {
const service = new BrowserstackService(
{} as any,
Expand Down
Loading