diff --git a/.changeset/pr-128.md b/.changeset/pr-128.md new file mode 100644 index 0000000..13773db --- /dev/null +++ b/.changeset/pr-128.md @@ -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. diff --git a/packages/browserstack-service/src/bstackLogger.ts b/packages/browserstack-service/src/bstackLogger.ts index b397fd8..e33c121 100644 --- a/packages/browserstack-service/src/bstackLogger.ts +++ b/packages/browserstack-service/src/bstackLogger.ts @@ -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 { + 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((resolve) => { + const timer = setTimeout(resolve, timeoutMs) + timer.unref?.() + stream.write('', () => { + clearTimeout(timer) + resolve() + }) + }) + } + public static clearLogger() { if (this.logFileStream) { this.logFileStream.end() diff --git a/packages/browserstack-service/src/configCapture.ts b/packages/browserstack-service/src/configCapture.ts new file mode 100644 index 0000000..521560b --- /dev/null +++ b/packages/browserstack-service/src/configCapture.ts @@ -0,0 +1,508 @@ +import fs from 'node:fs' +import path from 'node:path' + +import type { Options } from '@wdio/types' + +import { BStackLogger } from './bstackLogger.js' +import { + BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS, + COMPOUND_SECRET_SUFFIXES_CAMEL, + COMPOUND_SECRET_SUFFIXES_SNAKE, + PEM_BLOCK_REGEX, + PEM_UNTERMINATED_REGEX, + URL_USERINFO_REGEX, + BROWSERSTACK_WDIO_CONFIG_FILE_PATH, + BROWSERSTACK_WDIO_CONFIG_STRATEGY, + CAPTURE_CONFIG_IMPORT_DEPTH, + DEFAULT_WDIO_CONFIG_BASENAME, + MAX_CAPTURED_CONFIG_FILES, + MAX_CAPTURED_CONFIG_FILE_BYTES, + MAX_PACKAGE_JSON_WALK_UP, + REDACTED_KEYS, + SUPPORTED_WDIO_CONFIG_EXTENSIONS, + WDIO_CLI_SUBCOMMANDS +} from './constants.js' + +export interface CapturedFile { + /* archive entry name (basename, de-duplicated) */ + name: string + /* absolute path the content came from */ + sourcePath: string + content: string +} + +export interface ConfigPathResolution { + configPath?: string + /* which ladder rung answered — recorded on the SDK_UPLOAD_LOGS event */ + strategy?: string + /* why nothing was found, when configPath is undefined */ + reason?: string +} + +const isReadableFile = (filePath: string): boolean => { + try { + return fs.existsSync(filePath) && fs.statSync(filePath).isFile() + } catch { + return false + } +} + +/** + * Accept a config path the way `@wdio/cli` does: the value as spelled, else the same stem + * probed across the supported extensions. + * + * The probe is not defensive padding — it is required. `commands/run.ts` hands `Launcher` the + * path that `canAccessConfigPath` found, but leaves `config-path` set to what the USER typed, + * and a TypeScript project legally spells a `.ts` config with `.js`. Verified against a real + * wdio run: `wdio run ./configs/a.conf.js` with only `configs/a.conf.ts` on disk starts fine + * and reports `config-path: './configs/a.conf.js'`, a path that does not exist. + */ +const resolveCandidate = (value: unknown): string | undefined => { + if (typeof value !== 'string' || !value.trim()) { + return undefined + } + try { + const full = path.resolve(process.cwd(), value.trim()) + const ext = path.extname(full) + const stem = SUPPORTED_WDIO_CONFIG_EXTENSIONS.includes(ext) ? full.slice(0, -ext.length) : full + return [full, ...SUPPORTED_WDIO_CONFIG_EXTENSIONS.map((e) => `${stem}${e}`)] + .find(isReadableFile) + } catch { + return undefined + } +} + +/** + * cwd-relative form of a FILE path, for anything that gets uploaded. + * + * The basename fallback is safe here only because `path.relative` is never empty for a file — + * a file is never equal to cwd. Do NOT pass a directory: see `relativeDirToCwd`. + */ +export const relativeToCwd = (filePath: string): string => { + try { + return path.relative(process.cwd(), filePath) || path.basename(filePath) + } catch { + return path.basename(filePath) + } +} + +/** + * cwd-relative form of a DIRECTORY path. + * + * `path.relative(cwd, cwd)` is `''`, which is the COMMON case here (a manifest at the project + * root), so a basename fallback would report the folder name — and for a project checked out + * directly in `$HOME` that folder name is the OS username, which is the exact exposure the + * relative-path handling exists to prevent. Empty means "cwd", so render it as `.`. + */ +export const relativeDirToCwd = (dir: string): string => { + try { + return path.relative(process.cwd(), dir) || '.' + } catch { + return '.' + } +} + +/** + * Resolve the absolute path of the user's wdio config file. + * + * WDIO keeps the real path in `ConfigParser`'s private `#configFilePath`, which no service can + * reach, so this reconstructs it from the values the CLI does leave on the merged config — + * mirroring how `@wdio/cli` itself resolves it. The CLI never searches: it takes one candidate + * and probes one stem across the supported extensions, so neither does this. + * + * 1. BROWSERSTACK_WDIO_CONFIG_FILE_PATH — explicit override / support escape hatch + * 2. config['config-path'] — yargs' kebab alias of the `run ` + * positional (v8 and v9 alike) + * 3. config._[0] — the bare `wdio ` form, which `run.ts` + * itself resolves from `params._[0]` + * 4. rootDir + wdio.conf. — no-arg `wdio`, and programmatic `new Launcher()` + * 5. cwd + wdio.conf. — when the user overrides `rootDir` in their config + * + * `config._` is read rather than raw `process.argv`: it is the same positional AFTER yargs has + * applied wdio's own option declarations. Scanning argv means re-implementing that with a + * heuristic that cannot know which flags are boolean — and `wdio --watch ./a.conf.ts` puts the + * real config in `_[0]` while any "skip a flag's value" rule throws it away. + */ +export function resolveWdioConfigPath(config?: Options.Testrunner): ConfigPathResolution { + const record = (config || {}) as Record + + try { + const positionals = Array.isArray(record._) ? record._ as unknown[] : [] + const positional = positionals.filter( + (entry): entry is string => typeof entry === 'string' && !WDIO_CLI_SUBCOMMANDS.includes(entry) + )[0] + const rootDir = typeof record.rootDir === 'string' ? record.rootDir : undefined + + const rungs: Array<[string, unknown]> = [ + ['env_override', process.env[BROWSERSTACK_WDIO_CONFIG_FILE_PATH]], + ['cli_config_path', record['config-path']], + ['config_positional', positional], + ['root_dir_default', rootDir && path.join(rootDir, DEFAULT_WDIO_CONFIG_BASENAME)], + ['cwd_default', DEFAULT_WDIO_CONFIG_BASENAME] + ] + + for (const [strategy, value] of rungs) { + const configPath = resolveCandidate(value) + if (configPath) { + return { configPath, strategy } + } + } + + return { reason: 'config_not_found' } + } catch { + return { reason: 'config_resolve_exception' } + } +} + +/** + * Resolve once, as early as possible, and publish the answer on the environment so the + * upload path (and any worker) reads the SAME value instead of re-deriving it from cwd. + * + * Re-resolving at archive time is precisely the bug SDK-5993 fixed in the Node SDK: the + * archive step read `cwd/browserstack.yml` while startup had resolved a different path, + * silently dropping the config for every monorepo / subdir CI run. + */ +export function initWdioConfigPath(config?: Options.Testrunner): ConfigPathResolution { + try { + const alreadyResolved = process.env[BROWSERSTACK_WDIO_CONFIG_FILE_PATH] + if (alreadyResolved) { + // Report the rung that ORIGINALLY answered, not the env var this function + // itself wrote — otherwise every run reports `env_override` and the metric + // can never tell us how often the fallbacks are carrying customers. + return { + configPath: alreadyResolved, + strategy: process.env[BROWSERSTACK_WDIO_CONFIG_STRATEGY] || 'env_override' + } + } + + const resolution = resolveWdioConfigPath(config) + if (resolution.configPath) { + process.env[BROWSERSTACK_WDIO_CONFIG_FILE_PATH] = resolution.configPath + if (resolution.strategy) { + process.env[BROWSERSTACK_WDIO_CONFIG_STRATEGY] = resolution.strategy + } + // Relative to cwd: this log file is itself uploaded, and an absolute path leaks + // the OS username and directory layout (`/Users/jane.doe/...`). `path.relative` + // still yields `../../shared/wdio.conf.ts` for a config outside cwd, so the + // monorepo/subdir diagnostic — which is the whole point of logging it — survives. + // Path before strategy: BStackLogger scrubs any `<...>key:`/`<...>user:` prefixed + // value, so a strategy name ending in `key`/`user` here would redact the path. + BStackLogger.debug(`Resolved wdio config file ${relativeToCwd(resolution.configPath)} for auto-capture (strategy ${resolution.strategy})`) + } else { + BStackLogger.debug(`Could not resolve wdio config file for auto-capture: ${resolution.reason}`) + } + return resolution + } catch (error) { + BStackLogger.debug(`Error while resolving wdio config file: ${error}`) + return { reason: 'config_resolve_exception' } + } +} + +/** + * Opt-out for auto-captured logs. Service option first, env var as the CI escape hatch + * (customers cannot always edit a committed config). Name matches the Node SDK's + * `disableAutoCaptureLogs` so the flag means the same thing across BrowserStack SDKs. + */ +export function isAutoCaptureLogsDisabled(options?: { disableAutoCaptureLogs?: boolean }): boolean { + if (options?.disableAutoCaptureLogs === true) { + return true + } + return String(process.env[BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS] || '').toLowerCase() === 'true' +} + +/** + * Mirror the service option onto the environment so the opt-out survives into the + * DETACHED cleanup process, which gets no options object. + * + * Without this the opt-out is worse than useless: skipping the upload leaves + * `logsUploaded` false, which is exactly the condition that arms the exit-time + * `--uploadLogs` rescue — so every opted-out run had its config read and POSTed by the + * cleanup child. Returns whether auto-capture is disabled. + */ +export function publishAutoCaptureDisabled(options?: { disableAutoCaptureLogs?: boolean }): boolean { + const disabled = isAutoCaptureLogsDisabled(options) + if (disabled) { + process.env[BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS] = 'true' + } + return disabled +} + +/** + * Line-level credential scrub, ported from the Node SDK's `redactSensitiveContent`. + * + * Any line mentioning a sensitive key collapses to `: [REDACTED]`. Word boundaries + * keep `hotkey` / `keyword` from tripping the bare `key` entry that WDIO's top-level + * credential options force us to carry. `.` is intentionally NOT part of the boundary + * class so `bstackOptions.accessKey = '...'` still matches. + */ +export function redactSensitiveContent(text: string): string { + if (!text) { + return text + } + + const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + // longest first: alternation returns the first match, not the longest one + const keys = [...REDACTED_KEYS] + .sort((a, b) => b.length - a.length) + .map(escapeRegex) + .join('|') + const redactRegex = new RegExp(`^.*?(?` + // immediately before an assignment. + const compoundSnakeRegex = new RegExp( + `^.*?(? { + try { + const size = fs.statSync(filePath).size + if (size > MAX_CAPTURED_CONFIG_FILE_BYTES) { + return { reason: `${path.basename(filePath)}: too_large (${size} bytes)` } + } + return { content: fs.readFileSync(filePath, 'utf8') } + } catch (error) { + return { reason: `${path.basename(filePath)}: ${(error as Error)?.message || String(error)}` } + } +} + +/** + * Resolve a relative import specifier against the importing file. + * + * Handles the TypeScript-ESM convention where `./shared.conf.js` on disk is actually + * `./shared.conf.ts`, plus extension-less and directory (`/index.*`) specifiers. + */ +/** + * Only files that are themselves configs are followed. + * + * A relative specifier resolves to whatever the config happens to import, which is frequently + * ordinary application source (`./helpers/utils.js`) rather than configuration. Capturing the + * split-config case (`base.conf.js`, `wdio.shared.conf.ts`) is the point; shipping a + * customer's application modules is not. + */ +const isConfigFileName = (filePath: string): boolean => /\.conf(ig)?\.[^.]+$/i.test(path.basename(filePath)) + +const resolveRelativeImport = (specifier: string, fromFile: string): string | undefined => { + const base = path.resolve(path.dirname(fromFile), specifier) + + if (isReadableFile(base) && SUPPORTED_WDIO_CONFIG_EXTENSIONS.includes(path.extname(base))) { + return isConfigFileName(base) ? base : undefined + } + + const withoutExt = SUPPORTED_WDIO_CONFIG_EXTENSIONS.includes(path.extname(base)) + ? base.slice(0, -path.extname(base).length) + : base + + for (const ext of SUPPORTED_WDIO_CONFIG_EXTENSIONS) { + const candidate = `${withoutExt}${ext}` + if (isReadableFile(candidate) && isConfigFileName(candidate)) { + return candidate + } + } + for (const ext of SUPPORTED_WDIO_CONFIG_EXTENSIONS) { + const candidate = path.join(base, `index${ext}`) + if (isReadableFile(candidate) && isConfigFileName(candidate)) { + return candidate + } + } + return undefined +} + +/** + * Collect local files a config pulls in (`wdio.shared.conf.ts` style splits), so a + * captured config is not just a two-line file that spreads a base config we never see. + * + * Only RELATIVE specifiers are followed — bare specifiers are npm packages, never the + * customer's own config. Depth and count are capped so this can never walk a source tree. + */ +const collectLocalImports = (entryPath: string, entryContent: string, budget: number): Array<{ filePath: string, content: string }> => { + const found: Array<{ filePath: string, content: string }> = [] + const seen = new Set([entryPath]) + let frontier: Array<{ filePath: string, content: string }> = [{ filePath: entryPath, content: entryContent }] + + for (let depth = 0; depth < CAPTURE_CONFIG_IMPORT_DEPTH && found.length < budget; depth++) { + const next: Array<{ filePath: string, content: string }> = [] + + for (const { filePath, content } of frontier) { + // `from './x'`, `import './x'`, `import('./x')`, `require('./x')` + const importRegex = /(?:from|import|require)\s*\(?\s*['"](\.[^'"]*)['"]/g + let match: RegExpExecArray | null + + while ((match = importRegex.exec(content)) !== null) { + if (found.length >= budget) { + break + } + const resolved = resolveRelativeImport(match[1], filePath) + if (!resolved || seen.has(resolved) || resolved.includes(`${path.sep}node_modules${path.sep}`)) { + continue + } + seen.add(resolved) + + const { content: importedContent } = readCappedFile(resolved) + if (importedContent === undefined) { + continue + } + found.push({ filePath: resolved, content: importedContent }) + next.push({ filePath: resolved, content: importedContent }) + } + } + + if (next.length === 0) { + break + } + frontier = next + } + + return found +} + +/** + * Give every archive entry a unique name. Two configs can share a basename + * (`configs/wdio.conf.ts` + `shared/wdio.conf.ts`); without this the second silently + * overwrites the first, since archive entries are keyed by basename. + * + * Shared with `uploadLogs`, which de-dupes the copied log files against these entries — + * one implementation so the two can never disagree. + */ +export function dedupeEntryName(filePath: string, taken: Set): string { + const base = path.basename(filePath) + if (!taken.has(base)) { + taken.add(base) + return base + } + + const ext = path.extname(base) + const stem = ext ? base.slice(0, -ext.length) : base + let index = 1 + let candidate = `${stem}.${index}${ext}` + while (taken.has(candidate)) { + index++ + candidate = `${stem}.${index}${ext}` + } + taken.add(candidate) + return candidate +} + +/** + * Build the redacted config entries added to the auto-captured log archive. + * + * Best effort by contract: any failure returns what was gathered so far and a reason + * string for the SDK_UPLOAD_LOGS event. It must never throw — a debug artifact is never + * worth failing a customer's test run over. + */ +export function collectConfigFilesForUpload(config?: Options.Testrunner): { files: CapturedFile[], failures: string[], strategy?: string } { + const failures: string[] = [] + const files: CapturedFile[] = [] + const takenNames = new Set() + + try { + const resolution = initWdioConfigPath(config) + if (!resolution.configPath) { + failures.push(resolution.reason || 'config_not_found') + return { files, failures } + } + + const { content, reason } = readCappedFile(resolution.configPath) + if (content === undefined) { + failures.push(reason || 'config_read_failed') + return { files, failures, strategy: resolution.strategy } + } + + files.push({ + name: dedupeEntryName(resolution.configPath, takenNames), + sourcePath: resolution.configPath, + content: redactSensitiveContent(content) + }) + + const remaining = MAX_CAPTURED_CONFIG_FILES - files.length + if (remaining > 0) { + // content comes back from the discovery pass — re-reading here would be a second + // disk read per file and could archive different bytes than were scanned. + for (const imported of collectLocalImports(resolution.configPath, content, remaining)) { + files.push({ + name: dedupeEntryName(imported.filePath, takenNames), + sourcePath: imported.filePath, + content: redactSensitiveContent(imported.content) + }) + } + } + + return { files, failures, strategy: resolution.strategy } + } catch (error) { + failures.push(`config_capture_exception: ${(error as Error)?.message || String(error)}`) + return { files, failures } + } +} + +/** + * Walk up from `startDir` looking for `fileName`, stopping at the filesystem root or + * after MAX_PACKAGE_JSON_WALK_UP levels. + */ +const findUpwards = (startDir: string, fileName: string): string | undefined => { + let current = startDir + for (let depth = 0; depth <= MAX_PACKAGE_JSON_WALK_UP; depth++) { + const candidate = path.join(current, fileName) + if (isReadableFile(candidate)) { + return candidate + } + const parent = path.dirname(current) + if (parent === current) { + break + } + current = parent + } + return undefined +} + +/** + * `package.json` for the project the config belongs to — framework and service versions + * are the first thing triage needs, and the archive carried neither before. + * + * Walks UP from the config's directory, because `configs/wdio.conf.ts` (a very common + * layout) puts the manifest one or more levels above the config, not beside it. + * Archived verbatim: it is a manifest, not a secret store. + */ +export function findPackageJsonForUpload(): string | undefined { + const configPath = process.env[BROWSERSTACK_WDIO_CONFIG_FILE_PATH] + const startDirs = [configPath ? path.dirname(configPath) : undefined, process.cwd()] + .filter((dir): dir is string => Boolean(dir)) + + for (const dir of Array.from(new Set(startDirs))) { + const found = findUpwards(dir, 'package.json') + if (found) { + return found + } + } + return undefined +} diff --git a/packages/browserstack-service/src/constants.ts b/packages/browserstack-service/src/constants.ts index 71e2f49..00dc2dd 100644 --- a/packages/browserstack-service/src/constants.ts +++ b/packages/browserstack-service/src/constants.ts @@ -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 ` 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', diff --git a/packages/browserstack-service/src/exitHandler.ts b/packages/browserstack-service/src/exitHandler.ts index 09e6783..c33438b 100644 --- a/packages/browserstack-service/src/exitHandler.ts +++ b/packages/browserstack-service/src/exitHandler.ts @@ -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' @@ -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) } diff --git a/packages/browserstack-service/src/launcher.ts b/packages/browserstack-service/src/launcher.ts index 00fb918..390e4e8 100644 --- a/packages/browserstack-service/src/launcher.ts +++ b/packages/browserstack-service/src/launcher.ts @@ -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, @@ -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' @@ -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. @@ -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. diff --git a/packages/browserstack-service/src/types.ts b/packages/browserstack-service/src/types.ts index d01b9d8..d8d8fab 100644 --- a/packages/browserstack-service/src/types.ts +++ b/packages/browserstack-service/src/types.ts @@ -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 diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index 972ce45..8012f07 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -48,6 +48,7 @@ import { } from './constants.js' import CrashReporter from './crash-reporter.js' import { BStackLogger } from './bstackLogger.js' +import { collectConfigFilesForUpload, dedupeEntryName, findPackageJsonForUpload, isAutoCaptureLogsDisabled, redactSensitiveContent, relativeDirToCwd } from './configCapture.js' import UsageStats from './testOps/usageStats.js' import TestOpsConfig from './testOps/testOpsConfig.js' import type { StartBinSessionResponse } from './grpc/index.js' @@ -1516,7 +1517,14 @@ export function getFailureObject(error: string|Error) { export const sleep = (ms = 100) => new Promise((resolve) => setTimeout(resolve, ms)) -export async function uploadLogs(user: string | undefined, key: string | undefined, clientBuildUuid: string) { +export interface UploadLogsOptions { + /* opt-out, mirroring the Node SDK's `disableAutoCaptureLogs` */ + disableAutoCaptureLogs?: boolean + /* parsed wdio config, used to locate the user's config file */ + config?: Options.Testrunner +} + +export async function uploadLogs(user: string | undefined, key: string | undefined, clientBuildUuid: string, options: UploadLogsOptions = {}) { // Manual instrumentation: tag every return path on the SDK_UPLOAD_LOGS event so // the metric identifies the specific reason logs were not uploaded (no creds, // per-file copy failure, upload no-response, exception). measureWrapper would @@ -1524,9 +1532,24 @@ export async function uploadLogs(user: string | undefined, key: string | undefin const eventName = PERFORMANCE_SDK_EVENTS.EVENTS.SDK_UPLOAD_LOGS let success = true let failure: string | undefined + // Staging dir for the archive. Created per run: the previous fixed `tmpdir()/logs.tar` + // names meant two concurrent wdio runs on one CI host clobbered and unlinked each + // other's archives, and a source file that already lived in tmpdir was copied onto + // itself and then deleted by cleanup. + let stagingDir: string | undefined PerformanceTester.start(eventName) try { + // isAutoCaptureLogsDisabled (not options.disableAutoCaptureLogs) so the detached + // cleanup process — which calls this with no options — still honours the opt-out + // via the env var the launcher publishes. + if (isAutoCaptureLogsDisabled(options)) { + success = false + failure = 'skipped: disableAutoCaptureLogs=true' + BStackLogger.debug('Skipping log upload, auto-capture is disabled') + return + } + if (!user || !key) { success = false failure = 'skipped: missing_credentials' @@ -1534,33 +1557,106 @@ export async function uploadLogs(user: string | undefined, key: string | undefin return } - const tmpDir = tmpdir() + stagingDir = fs.mkdtempSync(path.join(tmpdir(), 'bstack-wdio-logs-')) + const tmpDir = stagingDir const tarPath = path.join(tmpDir, 'logs.tar') const tarGzPath = path.join(tmpDir, 'logs.tar.gz') + // Archive entries are keyed by basename, so two sources sharing one basename would + // silently overwrite each other — reachable now that user-supplied config paths + // (e.g. configs/wdio.conf.ts + shared/wdio.conf.ts) join the archive. + // Same helper collectConfigFilesForUpload uses, so the two cannot disagree. + const takenNames = new Set(['logs.tar', 'logs.tar.gz']) + const copiedFileNames: string[] = [] + const archiveAddFailures: string[] = [] + const uniqueName = (filePath: string): string => dedupeEntryName(filePath, takenNames) + + // SDK-7250: the user's wdio config (and the local files it imports), credential-scrubbed. + // Soft-failure by design — a config we cannot read or locate must never stop the + // log upload; the reason is recorded on the event instead. + const { files: configFiles, failures: configFailures, strategy } = collectConfigFilesForUpload(options.config) + + // package.json gives triage the framework/service versions, which the archive carried + // before this change. It goes through the same redaction as the configs rather than + // being copied verbatim: `scripts` routinely embed tokens (`--token=ghp_...`), and the + // walk-up can select a monorepo-root manifest broader than the test project. Ordinary + // dependency/version lines are unaffected by the scrub. + // snapshot before package.json joins the list, so counts and names stay honest + const capturedConfigNames = configFiles.map(f => f.name) + + const packageJsonPath = findPackageJsonForUpload() + if (packageJsonPath) { + try { + configFiles.push({ + name: path.basename(packageJsonPath), + sourcePath: packageJsonPath, + content: redactSensitiveContent(fs.readFileSync(packageJsonPath, 'utf8')) + }) + } catch (pkgErr) { + configFailures.push(`package.json: ${(pkgErr as Error)?.message || String(pkgErr)}`) + } + } + for (const configFile of configFiles) { + try { + const entryName = uniqueName(configFile.name) + fs.writeFileSync(path.join(tmpDir, entryName), configFile.content) + copiedFileNames.push(entryName) + } catch (writeErr) { + const msg = (writeErr as Error)?.message || String(writeErr) + configFailures.push(`${configFile.name}: ${msg}`) + } + } + // Logged separately from package.json: `configFiles` has the manifest appended to it, + // and `strategy` is undefined on every failure path, so a combined line reads + // "Auto-captured 1 config file(s) via undefined: package.json" — i.e. it claims a + // capture succeeded in exactly the case where none did. + if (capturedConfigNames.length > 0) { + BStackLogger.debug(`Auto-captured ${capturedConfigNames.length} config file(s) via ${strategy}: ${capturedConfigNames.join(', ')}`) + } else { + BStackLogger.debug(`No wdio config captured${strategy ? ` (strategy ${strategy})` : ''}`) + } + if (packageJsonPath) { + BStackLogger.debug(`Auto-captured package.json from ${relativeDirToCwd(path.dirname(packageJsonPath))}`) + } + if (configFailures.length > 0 && failure === undefined) { + // Warning only — `success` stays true so a missing config never reads as a + // failed log upload (same contract as the Node SDK's `redaction_failed`). + failure = `config_capture: ${configFailures.join('; ')}`.substring(0, 300) + } + + // The service log is snapshotted LAST, deliberately. Everything above -- the resolution + // strategy, what was captured, and any capture failures -- is written to that log + // first, so it is inside the copy that ships. Copying earlier is why those lines used + // to exist only in the developer's local file and never in the downloaded bundle. + // + // Ordering alone is not enough: BStackLogger writes through an async WriteStream, so + // those lines are still buffered at this point. Flush before copying or the shipped + // copy is silently missing its tail (verified against a downloaded bundle). + await BStackLogger.flushLogFile() + const filesToArchive = [ BStackLogger.logFilePath, CLI_DEBUG_LOGS_FILE, - ].filter(f => fs.existsSync(f)) + ].filter((f): f is string => Boolean(f) && fs.existsSync(f as string)) - const copiedFileNames: string[] = [] - const archiveAddFailures: string[] = [] for (const f of filesToArchive) { try { - const dest = path.join(tmpDir, path.basename(f)) - fs.copyFileSync(f, dest) - copiedFileNames.push(path.basename(f)) + const entryName = uniqueName(f) + fs.copyFileSync(f, path.join(tmpDir, entryName)) + copiedFileNames.push(entryName) } catch (copyErr) { const msg = (copyErr as Error)?.message || String(copyErr) archiveAddFailures.push(`${path.basename(f)}: ${msg}`) } } - if (archiveAddFailures.length > 0 && failure === undefined) { + if (archiveAddFailures.length > 0) { success = false failure = `archive_add_failed [${archiveAddFailures.length}]: ${archiveAddFailures.join('; ')}`.substring(0, 300) } + BStackLogger.debug(`Auto-capture archive entries: ${copiedFileNames.join(', ')}`) + await create( { file: tarPath, @@ -1605,14 +1701,7 @@ export async function uploadLogs(user: string | undefined, key: string | undefin 'POST', UPLOAD_LOGS_ENDPOINT, requestOptions, APIUtils.UPLOAD_LOGS_ADDRESS ) - fs.unlinkSync(tarPath) - fs.unlinkSync(tarGzPath) - for (const f of copiedFileNames) { - const filePath = path.join(tmpDir, f) - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath) - } - } + // Staging dir (archive + every copied/redacted entry) is removed in `finally`. // Delete the SDK CLI log file after upload if (fs.existsSync(CLI_DEBUG_LOGS_FILE)) { @@ -1638,6 +1727,13 @@ export async function uploadLogs(user: string | undefined, key: string | undefin BStackLogger.error(`Error while uploading logs: ${getErrorString(error)}`) return null } finally { + if (stagingDir) { + try { + fs.rmSync(stagingDir, { recursive: true, force: true }) + } catch (cleanupErr) { + BStackLogger.debug(`Failed to clean up log staging dir ${stagingDir}: ${getErrorString(cleanupErr)}`) + } + } PerformanceTester.end(eventName, success, failure) } } diff --git a/packages/browserstack-service/tests/configCapture.test.ts b/packages/browserstack-service/tests/configCapture.test.ts new file mode 100644 index 0000000..0224db9 --- /dev/null +++ b/packages/browserstack-service/tests/configCapture.test.ts @@ -0,0 +1,616 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' + +import { + collectConfigFilesForUpload, + relativeDirToCwd, + findPackageJsonForUpload, + initWdioConfigPath, + isAutoCaptureLogsDisabled, + redactSensitiveContent, + resolveWdioConfigPath +} from '../src/configCapture.js' +import { + BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS, + BROWSERSTACK_WDIO_CONFIG_FILE_PATH, + BROWSERSTACK_WDIO_CONFIG_STRATEGY +} from '../src/constants.js' + +const CONFIG_SRC = ` +import { shared } from './shared.conf.js' + +export const config = { + ...shared, + user: 'my-real-username', + key: 'my-real-access-key', + hostname: 'hub.browserstack.com', + capabilities: [{ + 'bstack:options': { userName: 'someone', accessKey: 'sk_live_abcdef' } + }], + services: [['browserstack', { accessibility: true }]] +} +` + +let tmpRoot: string +let cwdSpy: ReturnType +const originalArgv = process.argv + +const useCwd = (dir: string) => { + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(dir) +} + +beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'bstack-cfg-test-')) + delete process.env[BROWSERSTACK_WDIO_CONFIG_FILE_PATH] + delete process.env[BROWSERSTACK_WDIO_CONFIG_STRATEGY] + delete process.env[BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS] + process.argv = ['node', 'wdio'] + useCwd(tmpRoot) +}) + +afterEach(() => { + cwdSpy?.mockRestore() + process.argv = originalArgv + delete process.env[BROWSERSTACK_WDIO_CONFIG_FILE_PATH] + delete process.env[BROWSERSTACK_WDIO_CONFIG_STRATEGY] + delete process.env[BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS] + fs.rmSync(tmpRoot, { recursive: true, force: true }) +}) + +const write = (relativePath: string, content = 'export const config = {}') => { + const absolute = path.join(tmpRoot, relativePath) + fs.mkdirSync(path.dirname(absolute), { recursive: true }) + fs.writeFileSync(absolute, content) + return absolute +} + +describe('resolveWdioConfigPath', () => { + it('prefers the BROWSERSTACK_WDIO_CONFIG_FILE_PATH override', () => { + const override = write('somewhere/custom.conf.ts') + write('wdio.conf.js') + process.env[BROWSERSTACK_WDIO_CONFIG_FILE_PATH] = override + + expect(resolveWdioConfigPath({} as never)).toEqual({ configPath: override, strategy: 'env_override' }) + }) + + it('resolves a relative `config-path` against cwd (wdio run ./x.conf.ts)', () => { + const expected = write('configs/wdio.bstack.conf.ts') + + expect(resolveWdioConfigPath({ 'config-path': './configs/wdio.bstack.conf.ts' } as never)) + .toEqual({ configPath: expected, strategy: 'cli_config_path' }) + }) + + it('resolves an absolute `config-path`', () => { + const expected = write('wdio.conf.ts') + + expect(resolveWdioConfigPath({ 'config-path': expected } as never)) + .toEqual({ configPath: expected, strategy: 'cli_config_path' }) + }) + + it('ignores a `config-path` that no longer exists and falls through', () => { + const expected = write('wdio.conf.js') + + expect(resolveWdioConfigPath({ 'config-path': './deleted.conf.ts' } as never)) + .toEqual({ configPath: expected, strategy: 'cwd_default' }) + }) + + it('resolves the bare `wdio ` form from config._', () => { + const expected = write('custom.conf.mjs') + + expect(resolveWdioConfigPath({ _: ['./custom.conf.mjs'] } as never)) + .toEqual({ configPath: expected, strategy: 'config_positional' }) + }) + + it('resolves `wdio --watch `, where the config lands in _ after a boolean flag', () => { + // yargs consumes nothing for a boolean flag, so `_[0]` IS the config. A raw-argv scan + // that skips "a flag's value" throws this away; reading config._ cannot. + const expected = write('configs/a.conf.ts') + + expect(resolveWdioConfigPath({ _: ['./configs/a.conf.ts'], watch: true } as never)) + .toEqual({ configPath: expected, strategy: 'config_positional' }) + }) + + it('probes the stem when the CLI value carries a .js spelling for a .ts file', () => { + // Real wdio behaviour: `wdio run ./configs/a.conf.js` starts fine with only + // configs/a.conf.ts on disk, and leaves the non-existent spelling on config-path. + const expected = write('configs/a.conf.ts') + + expect(resolveWdioConfigPath({ 'config-path': './configs/a.conf.js' } as never)) + .toEqual({ configPath: expected, strategy: 'cli_config_path' }) + }) + + it('cannot be fooled by a --spec value, because yargs never leaves it in _', () => { + // Measured with wdio's own declaration (spec: {type:'array'}): `wdio --spec ./a.js` + // leaves _ EMPTY, so there is no positional to mistake, and resolution falls through + // to the default config. This is why reading config._ needs no flag-value heuristic. + const expected = write('wdio.conf.js') + write('a.js') + + expect(resolveWdioConfigPath({ _: [], spec: ['./a.js'] } as never)) + .toEqual({ configPath: expected, strategy: 'cwd_default' }) + }) + + it('ignores the `run` subcommand when reading positionals', () => { + write('run') + const expected = write('wdio.conf.cts') + + expect(resolveWdioConfigPath({ _: ['run', './wdio.conf.cts'] } as never).configPath) + .toBe(expected) + }) + + it('probes rootDir for wdio.conf with every supported extension', () => { + const nested = path.join(tmpRoot, 'nested') + fs.mkdirSync(nested) + const expected = write('nested/wdio.conf.mts') + + expect(resolveWdioConfigPath({ rootDir: nested } as never)) + .toEqual({ configPath: expected, strategy: 'root_dir_default' }) + }) + + it('falls back to cwd when the user overrides rootDir', () => { + const expected = write('wdio.conf.js') + + expect(resolveWdioConfigPath({ rootDir: '/definitely/not/here' } as never)) + .toEqual({ configPath: expected, strategy: 'cwd_default' }) + }) + + it('does not guess when a custom-named config exists and the CLI gave nothing', () => { + // The directory-scan rung is gone: wdio itself never searches, so neither do we. + // Stem-probing the CLI value covers what that rung used to rescue, deterministically. + write('e2e.conf.ts') + + expect(resolveWdioConfigPath({} as never)).toEqual({ reason: 'config_not_found' }) + }) + + it('reports config_not_found on an empty project', () => { + expect(resolveWdioConfigPath({} as never)).toEqual({ reason: 'config_not_found' }) + }) +}) + +describe('initWdioConfigPath', () => { + it('publishes the resolved path on the environment', () => { + const expected = write('wdio.conf.js') + + initWdioConfigPath({} as never) + + expect(process.env[BROWSERSTACK_WDIO_CONFIG_FILE_PATH]).toBe(expected) + }) + + it('reports the original rung on later calls, not env_override', () => { + write('configs/wdio.custom.conf.ts') + + const first = initWdioConfigPath({ 'config-path': './configs/wdio.custom.conf.ts' } as never) + const second = initWdioConfigPath({} as never) + + expect(first.strategy).toBe('cli_config_path') + expect(second.strategy).toBe('cli_config_path') + expect(second.configPath).toBe(first.configPath) + }) + + it('reports env_override when the user set the env var themselves', () => { + process.env[BROWSERSTACK_WDIO_CONFIG_FILE_PATH] = write('wdio.conf.js') + + expect(initWdioConfigPath({} as never).strategy).toBe('env_override') + }) + + it('leaves the env untouched when nothing resolves', () => { + initWdioConfigPath({} as never) + + expect(process.env[BROWSERSTACK_WDIO_CONFIG_FILE_PATH]).toBeUndefined() + }) +}) + +describe('redactSensitiveContent', () => { + it('scrubs wdio top-level credentials and bstack:options credentials', () => { + const redacted = redactSensitiveContent(CONFIG_SRC) + + expect(redacted).not.toContain('my-real-username') + expect(redacted).not.toContain('my-real-access-key') + expect(redacted).not.toContain('sk_live_abcdef') + expect(redacted).not.toContain('someone') + }) + + it('keeps non-credential config intact', () => { + const redacted = redactSensitiveContent(CONFIG_SRC) + + expect(redacted).toContain('accessibility: true') + expect(redacted).toContain('hub.browserstack.com') + }) + + it('does not over-redact identifiers that merely contain key/user', () => { + const redacted = redactSensitiveContent([ + 'const hotkey = "ctrl+a"', + 'const keyword = "search"', + 'const username_suffix_thing = 1', + 'monkeypatch()' + ].join('\n')) + + expect(redacted).toContain('ctrl+a') + expect(redacted).toContain('search') + expect(redacted).toContain('monkeypatch()') + }) + + it('scrubs dotted property assignment (bstackOptions.accessKey = ...)', () => { + expect(redactSensitiveContent('bstackOptions.accessKey = "leaked-key"')) + .not.toContain('leaked-key') + }) + + it('scrubs compound camelCase secret keys (PR review, SDK-7250)', () => { + // The whole-word pass cannot see these: its lookbehind rejects the letter before + // `Secret`/`Token`/`Key`, so third-party secrets under compound names survived it. + const redacted = redactSensitiveContent([ + "clientSecret: 'cs_live_leak'", + "refreshToken: 'ya29_leak'", + "privateKey: '-----BEGIN PRIVATE KEY-----'", + "sessionSecret: 'sess_leak'", + 'bearerToken = "bt_leak"' + ].join('\n')) + + expect(redacted).not.toContain('cs_live_leak') + expect(redacted).not.toContain('ya29_leak') + expect(redacted).not.toContain('BEGIN PRIVATE KEY') + expect(redacted).not.toContain('sess_leak') + expect(redacted).not.toContain('bt_leak') + }) + + it('scrubs snake_case secret keys', () => { + const redacted = redactSensitiveContent([ + "client_secret: 'snake_leak'", + "refresh_token: 'snake_token_leak'", + "private_key: 'snake_key_leak'" + ].join('\n')) + + expect(redacted).not.toContain('snake_leak') + expect(redacted).not.toContain('snake_token_leak') + expect(redacted).not.toContain('snake_key_leak') + }) + + it('still does not over-redact lookalike identifiers', () => { + // Case sensitivity is what buys this: a bare /key|token|secret/ pass would take + // all of these with it. + const redacted = redactSensitiveContent([ + "hotkey: 'ctrl+a'", + "keyword: 'search'", + "monkeypatch: 'enabled'", + "tokenizer: 'default'", + "secretary: 'name'" + ].join('\n')) + + expect(redacted).toContain('ctrl+a') + expect(redacted).toContain('search') + expect(redacted).toContain('enabled') + expect(redacted).toContain('default') + expect(redacted).toContain('name') + }) + + it('scrubs SCREAMING_SNAKE_CASE secret keys (PR review round 2)', () => { + // The dominant convention for secrets in config/env files. The snake branch is + // matched case-insensitively, which is safe because it requires an explicit `_`. + const redacted = redactSensitiveContent([ + "CLIENT_SECRET: 'screaming_leak'", + "AWS_SECRET_ACCESS_KEY: 'AKIA_leak'", + "GITHUB_TOKEN = 'ghp_screaming_leak'", + "REFRESH_TOKEN: 'rt_screaming_leak'", + "export const DB_PASSWORD = 'pw_screaming_leak'" + ].join('\n')) + + expect(redacted).not.toContain('screaming_leak') + expect(redacted).not.toContain('AKIA_leak') + expect(redacted).not.toContain('ghp_screaming_leak') + expect(redacted).not.toContain('rt_screaming_leak') + expect(redacted).not.toContain('pw_screaming_leak') + }) + + it('does not over-redact uppercase lookalikes', () => { + const redacted = redactSensitiveContent([ + "HOTKEY: 'ctrl+a'", + "KEYWORD: 'search'", + "my_secretary: 'jane'" + ].join('\n')) + + expect(redacted).toContain('ctrl+a') + expect(redacted).toContain('search') + expect(redacted).toContain('jane') + }) + + it('scrubs a multi-line PEM block, not just the line naming it', () => { + const redacted = redactSensitiveContent([ + 'credentials: {', + ' privateKey: `-----BEGIN PRIVATE KEY-----', + 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCsecretbytes', + '-----END PRIVATE KEY-----`', + '}' + ].join('\n')) + + expect(redacted).not.toContain('MIIEvQIBADANBgkqhkiG9w0BAQEFAASCsecretbytes') + }) + + it('scrubs basic-auth credentials embedded in any URL, not just proxyUrl', () => { + const redacted = redactSensitiveContent([ + "baseUrl: 'https://admin:s3cr3tPass@example.com'", + "mongoUri: 'mongodb://dbuser:dbP4ss@cluster0.example.net'" + ].join('\n')) + + expect(redacted).not.toContain('s3cr3tPass') + expect(redacted).not.toContain('dbP4ss') + expect(redacted).toContain('example.com') + }) + + it('leaves a port-bearing URL with no userinfo alone', () => { + expect(redactSensitiveContent("baseUrl: 'https://example.com:8080/path'")) + .toContain('https://example.com:8080/path') + }) + + it('scrubs single-token userinfo in a URL, not just user:pass (PR review round 3)', () => { + // Common in CI git remotes and npm registry auth. + const redacted = redactSensitiveContent([ + "repoUrl: 'https://ghp_TOKENLEAK@github.com/x/y.git'", + "registry: 'https://npm_TOKENLEAK2@registry.example.com'" + ].join('\n')) + + expect(redacted).not.toContain('ghp_TOKENLEAK') + expect(redacted).not.toContain('npm_TOKENLEAK2') + expect(redacted).toContain('github.com') + }) + + it('scrubs an unterminated PEM block without eating the rest of the file', () => { + const redacted = redactSensitiveContent([ + 'credentials: {', + ' privateKey: `-----BEGIN PRIVATE KEY-----', + 'MIIEvQIBADANunterminatedbytes', + 'nextOption: 1', + '}' + ].join('\n')) + + expect(redacted).not.toContain('MIIEvQIBADANunterminatedbytes') + // a malformed block must not swallow everything after it + expect(redacted).toContain('nextOption') + }) + + it('does not let an unterminated PEM swallow a later, unrelated PEM block', () => { + // Found live: with an untempered body the FIRST (unterminated) BEGIN matched through + // to the SECOND block's END marker, replacing every unrelated line in between. + const redacted = redactSensitiveContent([ + 'openPem: `-----BEGIN RSA PRIVATE KEY-----', + 'MIIEUNTERMINATEDBYTESMUSTNOTAPPEAR`,', + "afterPem: 'STILL_READABLE_MARKER',", + 'pem: `-----BEGIN PRIVATE KEY-----', + 'MIIEvQTERMINATEDBYTESMUSTNOTAPPEAR', + '-----END PRIVATE KEY-----`,' + ].join('\n')) + + expect(redacted).not.toContain('MIIEUNTERMINATEDBYTESMUSTNOTAPPEAR') + expect(redacted).not.toContain('MIIEvQTERMINATEDBYTESMUSTNOTAPPEAR') + // the line between the two blocks must survive + expect(redacted).toContain('STILL_READABLE_MARKER') + }) + + it('stays linear on pathological input (ReDoS guard)', () => { + // The unbounded URL userinfo pass was measurably quadratic: 100 KB of word + // characters took ~6.1s, 4x per doubling, and redactSensitiveContent runs + // synchronously inside uploadLogs. Bounded quantifiers keep it flat. + const build = (n: number) => `baseUrl: 'https://${'a'.repeat(n)}` + '\n' + `k${'b'.repeat(n)}Key` + + const started = Date.now() + redactSensitiveContent(build(200_000)) + const elapsed = Date.now() - started + + // generous ceiling: the unbounded form did not finish 1 MB in 120s + expect(elapsed).toBeLessThan(2_000) + }) + + it('scrubs acronym-prefixed camelCase secret keys (PR review round 4)', () => { + // The old core required a lowercase char before the suffix, so an uppercase acronym + // prefix slipped past BOTH passes: the camel core rejected `APIToken` on the `I`, and + // the whole-word pass rejected `Token` for the same preceding `I`. + const redacted = redactSensitiveContent([ + "APIToken: 'API_LEAK'", + "JWTSecret: 'JWT_LEAK'", + "SSHKey: 'SSH_LEAK'", + "AWSSecret: 'AWS_LEAK'", + "OTPKey = 'OTP_LEAK'" + ].join('\n')) + + for (const leak of ['API_LEAK', 'JWT_LEAK', 'SSH_LEAK', 'AWS_LEAK', 'OTP_LEAK']) { + expect(redacted).not.toContain(leak) + } + }) + + it('still keeps lookalikes after relaxing the camel core', () => { + const redacted = redactSensitiveContent([ + "hotkey: 'ctrl+a'", "HOTKEY: 'ctrl+b'", "keyword: 'search'", + 'monkeypatch: 1', "tokenizer: 'x'", "secretary: 'jane'", "donkey: 'y'" + ].join('\n')) + + for (const keep of ['ctrl+a', 'ctrl+b', 'search', 'monkeypatch', 'tokenizer', 'secretary', 'donkey']) { + expect(redacted).toContain(keep) + } + }) + + it('scrubs an unterminated PEM whose body is one long unwrapped line', () => { + // The 200-char per-line bound made this fail OPEN: a key written unwrapped on a + // single line exceeded it, so nothing matched and the body shipped. + const body = 'B'.repeat(3000) + const redacted = redactSensitiveContent(`-----BEGIN PRIVATE KEY-----\n${body}`) + + expect(redacted).not.toContain(body) + }) + + it('scrubs a token embedded in a package.json script', () => { + expect(redactSensitiveContent('"deploy": "gh release upload --token=ghp_leak"')) + .not.toContain('ghp_leak') + }) + + it('returns falsy input unchanged', () => { + expect(redactSensitiveContent('')).toBe('') + }) +}) + +describe('collectConfigFilesForUpload', () => { + it('captures the entry config, redacted', () => { + write('wdio.conf.ts', CONFIG_SRC) + write('shared.conf.ts', 'export const shared = { maxInstances: 5 }') + + const { files, failures } = collectConfigFilesForUpload({} as never) + + expect(failures).toEqual([]) + const entry = files.find((f) => f.name === 'wdio.conf.ts') + expect(entry).toBeDefined() + expect(entry!.content).not.toContain('my-real-access-key') + expect(entry!.content).toContain('accessibility: true') + }) + + it('follows relative imports, resolving a .js specifier to the .ts file on disk', () => { + write('wdio.conf.ts', CONFIG_SRC) + write('shared.conf.ts', 'export const shared = { maxInstances: 5 }') + + const { files } = collectConfigFilesForUpload({} as never) + + expect(files.map((f) => f.name).sort()).toEqual(['shared.conf.ts', 'wdio.conf.ts']) + expect(files.find((f) => f.name === 'shared.conf.ts')!.content).toContain('maxInstances: 5') + }) + + it('redacts imported config files too', () => { + write('wdio.conf.ts', 'import "./creds.conf.ts"\nexport const config = {}') + write('creds.conf.ts', 'export const accessKey = "leaked-from-import"') + + const { files } = collectConfigFilesForUpload({} as never) + + expect(files.map((f) => f.content).join('\n')).not.toContain('leaked-from-import') + }) + + it('follows an imported CONFIG file but not ordinary source', () => { + write('wdio.conf.ts', [ + "import { base } from './base.conf.js'", + "import { helper } from './helpers/utils.js'", + 'export const config = {}' + ].join('\n')) + write('base.conf.ts', 'export const base = { maxInstances: 5 }') + write('helpers/utils.js', 'export const helper = 1 // ORDINARY_SOURCE') + + const { files } = collectConfigFilesForUpload({} as never) + + expect(files.map((f) => f.name).sort()).toEqual(['base.conf.ts', 'wdio.conf.ts']) + expect(files.map((f) => f.content).join('\n')).not.toContain('ORDINARY_SOURCE') + }) + + it('does not follow a config-shaped path that is not named like a config', () => { + write('wdio.conf.ts', "import './lib/setup.ts'\nexport const config = {}") + write('lib/setup.ts', 'export const x = 1 // NOT_A_CONFIG') + + const { files } = collectConfigFilesForUpload({} as never) + + expect(files.map((f) => f.name)).toEqual(['wdio.conf.ts']) + }) + + it('never follows bare (npm package) specifiers', () => { + write('wdio.conf.ts', 'import { x } from "@wdio/globals"\nimport y from "dotenv"\nexport const config = {}') + + const { files } = collectConfigFilesForUpload({} as never) + + expect(files.map((f) => f.name)).toEqual(['wdio.conf.ts']) + }) + + it('de-duplicates archive names when two captured files share a basename', () => { + write('wdio.conf.ts', 'import "./nested/wdio.conf.ts"\nexport const config = {}') + write('nested/wdio.conf.ts', 'export const config = {}') + + const { files } = collectConfigFilesForUpload({} as never) + + expect(files).toHaveLength(2) + expect(new Set(files.map((f) => f.name)).size).toBe(2) + }) + + it('reports a soft failure and captures nothing when no config is found', () => { + const { files, failures } = collectConfigFilesForUpload({} as never) + + expect(files).toEqual([]) + expect(failures).toEqual(['config_not_found']) + }) + + it('skips an oversized config instead of bloating the archive', () => { + write('wdio.conf.js', 'x'.repeat(1024 * 1024 + 10)) + + const { files, failures } = collectConfigFilesForUpload({} as never) + + expect(files).toEqual([]) + expect(failures[0]).toContain('too_large') + }) + + it('never throws on an unreadable config path', () => { + process.env[BROWSERSTACK_WDIO_CONFIG_FILE_PATH] = path.join(tmpRoot, 'gone.conf.ts') + + expect(() => collectConfigFilesForUpload({} as never)).not.toThrow() + }) +}) + +describe('relativeDirToCwd', () => { + it('renders the cwd itself as "." and never as its folder name', () => { + // path.relative(cwd, cwd) === '', which is the COMMON case for a project-root + // manifest. A basename fallback would print the folder name — and for a project + // checked out directly in $HOME that folder name is the OS username. + expect(relativeDirToCwd(tmpRoot)).toBe('.') + expect(relativeDirToCwd(tmpRoot)).not.toBe(path.basename(tmpRoot)) + }) + + it('keeps the diagnostic for a directory outside cwd', () => { + expect(relativeDirToCwd(path.dirname(tmpRoot))).toBe('..') + }) + + it('is relative for a nested directory', () => { + expect(relativeDirToCwd(path.join(tmpRoot, 'packages', 'e2e'))).toBe(path.join('packages', 'e2e')) + }) +}) + +describe('findPackageJsonForUpload', () => { + it('prefers the package.json next to the resolved config', () => { + const expected = write('project/package.json', '{"name":"app"}') + process.env[BROWSERSTACK_WDIO_CONFIG_FILE_PATH] = write('project/wdio.conf.ts') + + expect(findPackageJsonForUpload()).toBe(expected) + }) + + it('falls back to cwd', () => { + const expected = write('package.json', '{"name":"app"}') + + expect(findPackageJsonForUpload()).toBe(expected) + }) + + it('walks up from a config kept in a subdirectory (configs/wdio.conf.ts)', () => { + const expected = write('package.json', '{"name":"app"}') + process.env[BROWSERSTACK_WDIO_CONFIG_FILE_PATH] = write('configs/env/wdio.conf.ts') + + expect(findPackageJsonForUpload()).toBe(expected) + }) + + it('picks the closest package.json when nested ones exist', () => { + write('package.json', '{"name":"monorepo-root"}') + const closest = write('packages/e2e/package.json', '{"name":"e2e"}') + process.env[BROWSERSTACK_WDIO_CONFIG_FILE_PATH] = write('packages/e2e/configs/wdio.conf.ts') + + expect(findPackageJsonForUpload()).toBe(closest) + }) + + it('returns undefined when there is none', () => { + expect(findPackageJsonForUpload()).toBeUndefined() + }) +}) + +describe('isAutoCaptureLogsDisabled', () => { + it('is off by default', () => { + expect(isAutoCaptureLogsDisabled({})).toBe(false) + expect(isAutoCaptureLogsDisabled(undefined)).toBe(false) + }) + + it('honours the service option', () => { + expect(isAutoCaptureLogsDisabled({ disableAutoCaptureLogs: true })).toBe(true) + }) + + it('honours the env var for CI where the config cannot be edited', () => { + process.env[BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS] = 'TRUE' + + expect(isAutoCaptureLogsDisabled({})).toBe(true) + }) +}) diff --git a/packages/browserstack-service/tests/uploadLogsArchive.test.ts b/packages/browserstack-service/tests/uploadLogsArchive.test.ts new file mode 100644 index 0000000..91b9184 --- /dev/null +++ b/packages/browserstack-service/tests/uploadLogsArchive.test.ts @@ -0,0 +1,302 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import zlib from 'node:zlib' +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' +import { list } from 'tar' + +const fsMkdtempOriginal = fs.mkdtempSync + +import { uploadLogs } from '../src/util.js' +import { BStackLogger } from '../src/bstackLogger.js' +import { BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS, BROWSERSTACK_WDIO_CONFIG_FILE_PATH } from '../src/constants.js' +import { _fetch as fetch } from '../src/fetchWrapper.js' + +vi.mock('../src/fetchWrapper.js', () => ({ _fetch: vi.fn() })) +vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger'))) + +/** the gzipped tarball actually handed to the upload endpoint */ +let uploadedArchive: Buffer | undefined +let tmpProject: string +let cwdSpy: ReturnType +let originalLogFilePath: string +let originalLogFolderPath: string + +const readArchiveEntry = async (gz: Buffer, entryName: string): Promise => { + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bstack-untar-one-')) + const tarPath = path.join(workDir, 'logs.tar') + fs.writeFileSync(tarPath, zlib.gunzipSync(gz)) + + let content = '' + await list({ + file: tarPath, + onentry: (e) => { + if (String(e.path) !== entryName) { + return + } + e.on('data', (chunk: Buffer) => { content += chunk.toString('utf8') }) + } + }) + fs.rmSync(workDir, { recursive: true, force: true }) + return content +} + +const readArchiveEntries = async (gz: Buffer) => { + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bstack-untar-')) + const tarPath = path.join(workDir, 'logs.tar') + fs.writeFileSync(tarPath, zlib.gunzipSync(gz)) + + const entries: string[] = [] + await list({ file: tarPath, onentry: (e) => entries.push(String(e.path)) }) + fs.rmSync(workDir, { recursive: true, force: true }) + return entries +} + +beforeEach(() => { + uploadedArchive = undefined + tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'bstack-upload-test-')) + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tmpProject) + originalLogFilePath = BStackLogger.logFilePath + originalLogFolderPath = BStackLogger.logFolderPath + // BStackLogger caches its WriteStream on a static; drop it so it reopens at THIS test's + // path. Without this the stream still points at the previous test's (deleted) directory. + BStackLogger.clearLogger() + BStackLogger.logFolderPath = tmpProject + BStackLogger.logFilePath = path.join(tmpProject, 'bstack-wdio-service.log') + fs.writeFileSync(BStackLogger.logFilePath, 'service log content') + delete process.env[BROWSERSTACK_WDIO_CONFIG_FILE_PATH] + delete process.env[BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS] + + vi.mocked(fetch).mockImplementation((async (_url: string, init: RequestInit) => { + // read the archive here: uploadLogs removes its staging dir right after + const body = init?.body as FormData + const blob = body?.get?.('data') as Blob | null + if (blob && typeof blob.arrayBuffer === 'function') { + uploadedArchive = Buffer.from(await blob.arrayBuffer()) + } + return Response.json({ status: 'success' }) + }) as never) +}) + +afterEach(() => { + cwdSpy.mockRestore() + BStackLogger.clearLogger() + BStackLogger.logFilePath = originalLogFilePath + BStackLogger.logFolderPath = originalLogFolderPath + delete process.env[BROWSERSTACK_WDIO_CONFIG_FILE_PATH] + delete process.env[BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS] + fs.rmSync(tmpProject, { recursive: true, force: true }) + vi.mocked(fetch).mockReset() +}) + +describe('uploadLogs archive contents (SDK-7250)', () => { + it('ships the wdio config, its local import and package.json alongside the service log', async () => { + fs.writeFileSync(path.join(tmpProject, 'package.json'), '{"name":"customer-app","version":"1.2.3"}') + fs.writeFileSync(path.join(tmpProject, 'shared.conf.js'), 'export const shared = { maxInstances: 7 }') + fs.writeFileSync(path.join(tmpProject, 'wdio.conf.js'), [ + 'import { shared } from "./shared.conf.js"', + 'export const config = {', + ' ...shared,', + " user: 'LEAKED_USER_VALUE',", + " key: 'LEAKED_KEY_VALUE',", + ' services: [["browserstack", { accessibility: true }]]', + '}' + ].join('\n')) + + await uploadLogs('some_user', 'some_key', 'some_uuid', {}) + + expect(fetch).toHaveBeenCalled() + expect(uploadedArchive).toBeDefined() + + const entries = await readArchiveEntries(uploadedArchive!) + expect(entries).toContain('wdio.conf.js') + expect(entries).toContain('shared.conf.js') + expect(entries).toContain('package.json') + expect(entries).toContain('bstack-wdio-service.log') + }) + + it('redacts package.json instead of archiving it verbatim (PR review, SDK-7250)', async () => { + fs.writeFileSync(path.join(tmpProject, 'package.json'), JSON.stringify({ + name: 'customer-app', + version: '1.2.3', + scripts: { deploy: 'gh release upload --token=ghp_LEAKED_IN_MANIFEST' }, + dependencies: { webdriverio: '^9.0.0' } + }, null, 2)) + fs.writeFileSync(path.join(tmpProject, 'wdio.conf.js'), 'export const config = {}') + + await uploadLogs('some_user', 'some_key', 'some_uuid', {}) + + const raw = zlib.gunzipSync(uploadedArchive!).toString('binary') + expect(raw).not.toContain('ghp_LEAKED_IN_MANIFEST') + // the reason we ship it at all must survive the scrub + expect(raw).toContain('webdriverio') + expect(raw).toContain('1.2.3') + expect(await readArchiveEntries(uploadedArchive!)).toContain('package.json') + }) + + it('scrubs credentials out of the archived config', async () => { + fs.writeFileSync(path.join(tmpProject, 'wdio.conf.js'), [ + 'export const config = {', + " user: 'LEAKED_USER_VALUE',", + " key: 'LEAKED_KEY_VALUE',", + ' services: [["browserstack", { accessibility: true }]]', + '}' + ].join('\n')) + + await uploadLogs('some_user', 'some_key', 'some_uuid', {}) + + const raw = zlib.gunzipSync(uploadedArchive!).toString('binary') + expect(raw).not.toContain('LEAKED_USER_VALUE') + expect(raw).not.toContain('LEAKED_KEY_VALUE') + // non-credential config must survive, else the artifact is useless for triage + expect(raw).toContain('accessibility: true') + }) + + it('puts the capture summary inside the ARCHIVED log, not just the local one', async () => { + // Two separate regressions are pinned here, both found against a real downloaded + // bundle. (1) The log is snapshotted LAST, so the resolution strategy and captured + // names are inside the copy that ships -- copying earlier left them local-only. + // (2) BStackLogger writes through an async WriteStream, so ordering alone still + // shipped a truncated log; uploadLogs must flush before it copies. + fs.writeFileSync(path.join(tmpProject, 'package.json'), '{"name":"app"}') + fs.writeFileSync(path.join(tmpProject, 'wdio.conf.js'), 'export const config = {}') + await uploadLogs('some_user', 'some_key', 'some_uuid', {}) + + const archivedLog = await readArchiveEntry(uploadedArchive!, 'bstack-wdio-service.log') + expect(archivedLog).toContain('Auto-captured 1 config file(s) via cwd_default') + expect(archivedLog).toContain('wdio.conf.js') + expect(archivedLog).toContain('Auto-captured package.json from') + // The trailing "archive entries" line lists what actually landed, so it can only be + // written after the copy -- the log file is itself one of those entries. It stays + // local-only on purpose; whoever holds the bundle can just list the tarball. + }) + + it('records in the ARCHIVED log when no config was found', async () => { + await uploadLogs('some_user', 'some_key', 'some_uuid', {}) + + const archivedLog = await readArchiveEntry(uploadedArchive!, 'bstack-wdio-service.log') + expect(archivedLog).toContain('No wdio config captured') + }) + + it('still uploads the logs when no config can be found', async () => { + await uploadLogs('some_user', 'some_key', 'some_uuid', {}) + + expect(fetch).toHaveBeenCalled() + const entries = await readArchiveEntries(uploadedArchive!) + expect(entries).toContain('bstack-wdio-service.log') + expect(entries).not.toContain('wdio.conf.js') + }) + + it('uploads nothing when disableAutoCaptureLogs is set', async () => { + fs.writeFileSync(path.join(tmpProject, 'wdio.conf.js'), 'export const config = {}') + + await uploadLogs('some_user', 'some_key', 'some_uuid', { disableAutoCaptureLogs: true }) + + expect(fetch).not.toHaveBeenCalled() + }) + + it('honours the opt-out env var when called with no options (cleanup process)', async () => { + // The detached cleanup rescue calls uploadLogs(user, key, uuid) with NO options. + // Before the fix it uploaded the config of every user who had opted out, because + // opting out leaves `logsUploaded` false, which is what arms the rescue. + fs.writeFileSync(path.join(tmpProject, 'wdio.conf.js'), 'export const config = {}') + process.env[BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS] = 'true' + + await uploadLogs('some_user', 'some_key', 'some_uuid') + + expect(fetch).not.toHaveBeenCalled() + }) + + it('leaves no staging directory behind', async () => { + // Spy on the creation rather than diffing os.tmpdir(): vitest runs test FILES in + // parallel workers and util.test.ts also calls uploadLogs, so a listing-based check + // races against staging dirs another worker is creating and removing. + fs.writeFileSync(path.join(tmpProject, 'wdio.conf.js'), 'export const config = {}') + const created: string[] = [] + const mkdtempSpy = vi.spyOn(fs, 'mkdtempSync').mockImplementation(((prefix: string) => { + const dir = fsMkdtempOriginal(prefix) as string + created.push(dir) + return dir + }) as never) + + await uploadLogs('some_user', 'some_key', 'some_uuid', {}) + mkdtempSpy.mockRestore() + + expect(created.length).toBeGreaterThan(0) + for (const dir of created) { + expect(fs.existsSync(dir)).toBe(false) + } + }) + + it('keeps concurrent runs from clobbering each other', async () => { + fs.writeFileSync(path.join(tmpProject, 'wdio.conf.js'), 'export const config = {}') + + const results = await Promise.all([ + uploadLogs('some_user', 'some_key', 'uuid-1', {}), + uploadLogs('some_user', 'some_key', 'uuid-2', {}), + uploadLogs('some_user', 'some_key', 'uuid-3', {}) + ]) + + expect(results.every((r) => r && r.status === 'success')).toBe(true) + expect(fetch).toHaveBeenCalledTimes(3) + }) +}) + +describe('BStackLogger.flushLogFile', () => { + // Lives here rather than in bstackLogger.test.ts, which mocks node:fs wholesale -- + // buffering is the behaviour under test, so it needs a real stream and a real file. + let dir: string + let originalPath: string + let originalFolder: string + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bstack-flush-')) + originalPath = BStackLogger.logFilePath + originalFolder = BStackLogger.logFolderPath + BStackLogger.clearLogger() + BStackLogger.logFolderPath = dir + BStackLogger.logFilePath = path.join(dir, 'bstack-wdio-service.log') + }) + + afterEach(() => { + BStackLogger.clearLogger() + BStackLogger.logFilePath = originalPath + BStackLogger.logFolderPath = originalFolder + fs.rmSync(dir, { recursive: true, force: true }) + }) + + it('puts a just-logged line on disk, which an unflushed write does not', () => { + BStackLogger.debug('LINE_BEFORE_FLUSH') + // Baseline: without the flush the stream has not even opened the file yet, so anything + // that snapshots the log at this instant ships a truncated copy (or none at all). + const beforeFlush = fs.existsSync(BStackLogger.logFilePath) + ? fs.readFileSync(BStackLogger.logFilePath, 'utf8') + : '' + expect(beforeFlush).not.toContain('LINE_BEFORE_FLUSH') + }) + + it('drains the buffer without ending the stream', async () => { + BStackLogger.debug('FIRST_LINE') + await BStackLogger.flushLogFile() + expect(fs.readFileSync(BStackLogger.logFilePath, 'utf8')).toContain('FIRST_LINE') + + // still writable afterwards -- unlike clearLogger(), which ends the stream + BStackLogger.debug('SECOND_LINE') + await BStackLogger.flushLogFile() + expect(fs.readFileSync(BStackLogger.logFilePath, 'utf8')).toContain('SECOND_LINE') + }) + + it('resolves without a stream open, and cannot hang the upload', async () => { + BStackLogger.clearLogger() + await expect(BStackLogger.flushLogFile()).resolves.toBeUndefined() + + // a stream that never invokes the write callback must still resolve, via the timeout + BStackLogger.debug('x') + const stuck = { writable: true, write: vi.fn(), end: vi.fn() } as unknown as typeof BStackLogger['logFileStream'] + // @ts-expect-error -- reaching into the private static is the point of the test + BStackLogger.logFileStream = stuck + await expect(BStackLogger.flushLogFile(50)).resolves.toBeUndefined() + // @ts-expect-error -- drop the fake so afterEach does not call end() on it + BStackLogger.logFileStream = null + }) +}) diff --git a/packages/browserstack-service/tests/util.test.ts b/packages/browserstack-service/tests/util.test.ts index af292e2..03d1109 100644 --- a/packages/browserstack-service/tests/util.test.ts +++ b/packages/browserstack-service/tests/util.test.ts @@ -1676,10 +1676,13 @@ describe('uploadLogs', function () { it('should upload the logs', async function () { await uploadLogs('some_user', 'some_key', 'some_uuid') expect(fetch).toHaveBeenCalled() + // The suite runs with no resolvable wdio config, so config capture records the soft + // `config_not_found` warning. `success` staying true is the contract that matters: + // a missing config must never read as a failed log upload. expect(endSpy).toHaveBeenCalledWith( PERFORMANCE_SDK_EVENTS.EVENTS.SDK_UPLOAD_LOGS, true, - undefined + 'config_capture: config_not_found' ) })