Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
2552924
feat(SDK-7250): capture the wdio config file in auto-captured logs
AakashHotchandani Aug 11, 2026
aa16aa1
chore(changeset): auto-generate from PR template (minor)
github-actions[bot] Aug 11, 2026
e9999dd
feat(SDK-7250): log the full auto-capture archive manifest
AakashHotchandani Aug 11, 2026
97e2f83
fix(SDK-7250): address PR review - compound secret keys, package.json…
AakashHotchandani Aug 11, 2026
894bd10
fix(SDK-7250): scrub SCREAMING_SNAKE keys, PEM blocks and basic-auth …
AakashHotchandani Aug 11, 2026
e593093
chore(changeset): auto-generate from PR template (minor)
github-actions[bot] Aug 11, 2026
45d25d7
fix(SDK-7250): bound the URL userinfo scan, catch single-token userin…
AakashHotchandani Aug 11, 2026
e5b649e
docs(SDK-7250): keep the best-effort redaction wording in the changeset
AakashHotchandani Aug 11, 2026
5ebc318
fix(SDK-7250): manifest into the archive, acronym secret keys, disclo…
AakashHotchandani Aug 13, 2026
89a8c42
fix(SDK-7250): drop the config._ resolution rung — dead and actively …
AakashHotchandani Aug 13, 2026
812c85a
refactor(SDK-7250): resolve the config the way @wdio/cli does; keep m…
AakashHotchandani Aug 13, 2026
927c837
fix(SDK-7250): render a project-root manifest dir as "." instead of i…
AakashHotchandani Aug 13, 2026
02c66b5
Merge branch 'main' into feat/sdk-7250-capture-wdio-conf
AakashHotchandani Aug 13, 2026
22946ba
fix(SDK-7250): only follow imported files that are themselves configs
AakashHotchandani Aug 13, 2026
b8d39c1
fix(SDK-7250): drop capture-manifest.txt; put the capture summary in …
AakashHotchandani Aug 13, 2026
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
6 changes: 6 additions & 0 deletions .changeset/pr-128.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@wdio/browserstack-service": minor
---

- The debug logs the service uploads at the end of a run now include a copy of your `wdio.conf` file (and the local config files it imports) plus your `package.json`, with values under known credential keys removed on a best-effort basis, so BrowserStack support can investigate configuration issues without asking you to reproduce them.
- Set `disableAutoCaptureLogs: true` in the service options, or `BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS=true`, to turn this upload off entirely.
28 changes: 28 additions & 0 deletions packages/browserstack-service/src/bstackLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,34 @@ export class BStackLogger {
log.trace(redactedMessage)
}

/**
* Drain whatever is still sitting in the log stream's buffer onto disk.
*
* `logToFile` writes to an async `fs.WriteStream`, so a line logged immediately before the
* archive is built is very likely NOT in the file yet — the stream's `open` is async too,
* so early on the file may not exist at all. Anything that snapshots the log (the debug-log
* upload) must flush first or it ships a truncated copy.
*
* A zero-length write's callback fires only after every chunk queued ahead of it has been
* handed to the fs layer, which drains the buffer without ending the stream — unlike
* `clearLogger()`, logging continues to work afterwards.
*/
public static async flushLogFile(timeoutMs = 2000): Promise<void> {
const stream = this.logFileStream
if (!stream || !stream.writable) {
return
}
// Never let a stuck stream hold up the upload; a truncated log beats no log at all.
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, timeoutMs)
timer.unref?.()
stream.write('', () => {
clearTimeout(timer)
resolve()
})
})
}

public static clearLogger() {
if (this.logFileStream) {
this.logFileStream.end()
Expand Down
508 changes: 508 additions & 0 deletions packages/browserstack-service/src/configCapture.ts

Large diffs are not rendered by default.

90 changes: 90 additions & 0 deletions packages/browserstack-service/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,96 @@ export const UPLOAD_LOGS_ENDPOINT = 'client-logs/upload'

export const PERCY_LOGS_FILE = 'logs/percy.log'

/**
* Auto-capture of the user's wdio config file (SDK-7250).
*/

/*
* Shown once per run when auto-capture is active. The Node SDK does the same
* (BrowserStackSetup.js -> AUTOLOGCAPTURE_NOTIFICATION); without it a wdio customer gets no
* runtime disclosure that their config file is collected, only a changeset entry and a JSDoc
* comment. Since the redaction is key-name driven and best-effort, the notice is part of the
* control rather than decoration.
*/
export const AUTOLOGCAPTURE_NOTIFICATION = 'Your wdio config file, the local config files it imports and package.json are captured with the debug logs at the end of the run, with values under known credential keys removed. To disable, set disableAutoCaptureLogs: true in the browserstack service options.'

/* Absolute path of the resolved wdio config, published once so the upload path never re-derives it */
export const BROWSERSTACK_WDIO_CONFIG_FILE_PATH = 'BROWSERSTACK_WDIO_CONFIG_FILE_PATH'
export const BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS = 'BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS'
/* Which ladder rung resolved the config, kept so the upload path reports the TRUE rung
instead of re-reading its own env var and always saying 'env_override' */
export const BROWSERSTACK_WDIO_CONFIG_STRATEGY = 'BROWSERSTACK_WDIO_CONFIG_STRATEGY'

/* Mirrors create-wdio's SUPPORTED_CONFIG_FILE_EXTENSION (identical in wdio v8 and v9) */
export const SUPPORTED_WDIO_CONFIG_EXTENSIONS = ['.js', '.ts', '.mjs', '.mts', '.cjs', '.cts']
export const DEFAULT_WDIO_CONFIG_BASENAME = 'wdio.conf'
/* `wdio <cmd>` verbs that must never be mistaken for the config positional */
export const WDIO_CLI_SUBCOMMANDS = ['run', 'install', 'repl', 'config']

/* Configs are kilobytes; the cap only exists so a mislabelled path cannot bloat the archive */
export const MAX_CAPTURED_CONFIG_FILE_BYTES = 1024 * 1024
export const MAX_CAPTURED_CONFIG_FILES = 6
/* How far to follow relative imports out of the entry config (1 = direct imports only) */
export const CAPTURE_CONFIG_IMPORT_DEPTH = 1
/* How far to walk up from the config dir looking for the project's package.json */
export const MAX_PACKAGE_JSON_WALK_UP = 5

/**
* Keys whose line is scrubbed before a config file enters the archive.
* `user` / `key` are WDIO's own top-level credential options, hence the bare entries.
*/
/**
* Word families that make an identifier sensitive when they appear as its SUFFIX —
* `clientSecret`, `refreshToken`, `privateKey`, `client_secret`. Split by case so the
* camelCase form requires a capital (distinguishing `privateKey` from `hotkey`) and the
* snake_case form requires an explicit `_` (distinguishing `client_secret` from `keyword`).
*/
export const COMPOUND_SECRET_SUFFIXES_CAMEL = 'Key|Token|Secret|Password|Passwd|Credential'
export const COMPOUND_SECRET_SUFFIXES_SNAKE = 'key|token|secret|password|passwd|credential'

/**
* Secrets that span lines or hide inside a value, which a line/key-anchored scrub cannot
* reach. Applied as whole-block passes before the line passes.
*/
/* an inline PEM: the key bytes sit on lines that carry no key name at all */
/*
* The body is TEMPERED so it cannot cross a second `-----BEGIN`: with a plain `[\s\S]*?`,
* an UNTERMINATED block earlier in the file matches through to a later, unrelated block's
* END marker and everything in between is replaced — silently destroying unrelated config.
* Bounded as well, so the scan stays linear.
*/
export const PEM_BLOCK_REGEX = /(-----BEGIN [^-\r\n]+-----)(?:(?!-----BEGIN)[\s\S]){0,65536}?(-----END [^-\r\n]+-----)/g
/*
* A PEM opened but never closed. The block regex above needs the END marker, so without it
* the key bytes survive every pass. Matched as BEGIN + the run of base64-only lines that
* follows. A run must be at least 20 characters and end at a non-base64 character: letters
* are valid base64, so a shorter/unbounded rule matches part of an ordinary line such as
* `nextOption: 1` and eats it. Stops at the first line that does not qualify, so a malformed
* block cannot swallow the rest of the file. The upper bound is generous (8 KB) because a
* key written unwrapped on ONE line would otherwise exceed it and fail OPEN, leaving the body
* in the bundle.
*/
export const PEM_UNTERMINATED_REGEX = /(-----BEGIN [^-\r\n]+-----)(?:\r?\n[A-Za-z0-9+/=]{20,8192}(?=[^A-Za-z0-9+/=]|$))+/g
/*
* Userinfo in ANY url value, not just the `proxyUrl` key. The password half is optional so
* single-token forms (`https://ghp_xxx@github.com`, common in CI git/npm remotes) are caught
* too. Quantifiers are BOUNDED: the unbounded form was measurably quadratic (100 KB of
* word characters took 6.1 s, 4x per doubling) because both halves scan forward for an `@`
* that never arrives. Real userinfo is short, so the bounds change no real-world match.
*/
export const URL_USERINFO_REGEX = /([a-zA-Z][a-zA-Z0-9+.-]{0,64}:\/\/)[^\s/@:]{1,256}(?::[^\s/@]{0,256})?@/g

export const REDACTED_KEYS = [
'user', 'key',
'userName', 'accessKey',
'browserstack.user', 'browserstack.key',
'browserstack.userName', 'browserstack.accessKey',
'password', 'proxyPassword', 'proxyUser', 'proxyPass',
'localProxyUser', 'localProxyPass', 'proxyUrl',
'authToken', 'apiKey', 'accessToken', 'secret', 'token',
'customVariables', 'user_data', 'httpProxy', 'httpsProxy'
]

export const PERCY_DOM_CHANGING_COMMANDS_ENDPOINTS = [
'/session/:sessionId/url',
'/session/:sessionId/forward',
Expand Down
5 changes: 4 additions & 1 deletion packages/browserstack-service/src/exitHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'
import PerformanceTester from './instrumentation/performance/performance-tester.js'
import TestOpsConfig from './testOps/testOpsConfig.js'
import { BStackLogger } from './bstackLogger.js'
import { isAutoCaptureLogsDisabled } from './configCapture.js'
import { BrowserstackCLI } from './cli/index.js'
import { BROWSERSTACK_TESTHUB_JWT, BROWSERSTACK_TESTHUB_UUID, BROWSERSTACK_KILL_SIGNAL } from './constants.js'

Expand Down Expand Up @@ -108,8 +109,10 @@ export function shouldCallCleanup(config: BrowserStackConfig, isCLIEnabled = fal

// A signal-terminated run never reaches onComplete's log upload, leaving the
// build with no SDK-log object — rescue it from the detached cleanup process.
// Opting out leaves logsUploaded false, so without this guard the rescue below fires
// on EVERY opted-out run and uploads exactly what the user opted out of.
const clientBuildUuid = process.env[BROWSERSTACK_TESTHUB_UUID] || config.sdkRunID
if (!config.logsUploaded && config.userName && config.accessKey && clientBuildUuid) {
if (!isAutoCaptureLogsDisabled() && !config.logsUploaded && config.userName && config.accessKey && clientBuildUuid) {
args.push('--uploadLogs', clientBuildUuid)
}

Expand Down
19 changes: 18 additions & 1 deletion packages/browserstack-service/src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type { BrowserstackConfig, BrowserstackOptions, App, AppConfig, AppUpload
import {
BSTACK_SERVICE_VERSION,
NOT_ALLOWED_KEYS_IN_CAPS, PERF_MEASUREMENT_ENV, RERUN_ENV, RERUN_TESTS_ENV,
AUTOLOGCAPTURE_NOTIFICATION,
BROWSERSTACK_TESTHUB_UUID,
VALID_APP_EXTENSION,
BROWSERSTACK_PERCY,
Expand Down Expand Up @@ -50,6 +51,7 @@ import {
validateSkipAppOverride
} from './util.js'
import CrashReporter from './crash-reporter.js'
import { initWdioConfigPath, isAutoCaptureLogsDisabled, publishAutoCaptureDisabled } from './configCapture.js'
import { finalizeOrphanedRuns } from './testOps/openRunsJournal.js'
import { BStackLogger } from './bstackLogger.js'
import { PercyLogger } from './Percy/PercyLogger.js'
Expand Down Expand Up @@ -250,6 +252,16 @@ export default class BrowserstackLauncherService implements Services.ServiceInst
async onPrepare (config: Options.Testrunner, capabilities: Capabilities.TestrunnerCapabilities | WebdriverIO.Capabilities) {
PerformanceTester.start(PERFORMANCE_SDK_EVENTS.FRAMEWORK_EVENTS.INIT)

// Resolve the user's wdio config path ONCE, here, while the freshly parsed config
// still carries the CLI's `config-path` positional, and publish it on the env for
// the upload path. Re-deriving it at archive time from cwd is the exact bug
// SDK-5993 fixed in the Node SDK (silently dropped the config on every monorepo /
// subdir CI run). Best-effort: never blocks the run.
if (!publishAutoCaptureDisabled(this._options)) {
BStackLogger.info(AUTOLOGCAPTURE_NOTIFICATION)
initWdioConfigPath(config)
}

// skipAppOverride: emit the fixed warning once + handle the 3 edge cases before anything
// else. Runs once here in the launcher (main process). Edge-2 (explicit false + no app) is a
// deliberate pre-session config error, surfaced as SevereServiceError so the run aborts cleanly.
Expand Down Expand Up @@ -838,7 +850,12 @@ export default class BrowserstackLauncherService implements Services.ServiceInst
// return path (no creds, archive failure, upload no-response, exception), so
// measureWrapper is no longer needed here.
const clientBuildUuid = this._getClientBuildUuid()
const response = await uploadLogs(getBrowserStackUser(this._config), getBrowserStackKey(this._config), clientBuildUuid)
const response = await uploadLogs(
getBrowserStackUser(this._config),
getBrowserStackKey(this._config),
clientBuildUuid,
{ disableAutoCaptureLogs: isAutoCaptureLogsDisabled(this._options), config: this._config }
)
// Treat a truthy response carrying a non-success status as a server-side
// rejection, not a delivery — a delivered upload must not be repeated by
// the exit-time cleanup rescue; failed/skipped uploads stay eligible for it.
Expand Down
16 changes: 16 additions & 0 deletions packages/browserstack-service/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,22 @@ export interface BrowserstackConfig {
* Currently supports testPlanId.
*/
testManagementOptions?: TestManagementOptions;
/**
* By default the service uploads its own debug logs, your `package.json` and a copy of
* your wdio config file at the end of a run, so BrowserStack support can debug issues
* without asking you to reproduce them.
*
* Values under known credential keys are removed before upload on a best-effort basis:
* BrowserStack credentials, common third-party secret names (`clientSecret`,
* `AWS_SECRET_ACCESS_KEY`, …), inline PEM blocks and basic-auth URLs. It is key-name
* driven, so a secret stored under an unrecognised name can still be included — if your
* config holds secrets you would rather not send, set this to true.
*
* Set this to true to disable that upload entirely.
* Can also be set with the `BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS=true` env var.
* @default false
*/
disableAutoCaptureLogs?: boolean;
/**
* Set this to true to enable BrowserStack Percy which will take screenshots
* and snapshots for your tests run on Browserstack
Expand Down
Loading
Loading