Skip to content

feat: monorepo conversion — add @wdio/browserstack-service alongside the gRPC core#37

Open
AakashHotchandani wants to merge 8 commits into
mainfrom
migration/monorepo-add-wdio-browserstack-service
Open

feat: monorepo conversion — add @wdio/browserstack-service alongside the gRPC core#37
AakashHotchandani wants to merge 8 commits into
mainfrom
migration/monorepo-add-wdio-browserstack-service

Conversation

@AakashHotchandani

Copy link
Copy Markdown
Collaborator

What

Converts this repo into an npm workspace and adds the WebdriverIO service so BrowserStack can publish @wdio/browserstack-service from here on its own cadence (WebdriverIO TSC has approved this and will set up OIDC trusted publishing). Nothing changes for end users — same package name, same services: ['browserstack'].

Layout

Package npm Notes
packages/core @browserstack/wdio-browserstack-service the existing gRPC/protobuf core, moved here via git mv (history preserved); build & behavior unchanged
packages/browserstack-service @wdio/browserstack-service the WebdriverIO service (extracted from the monorepo)

The core (owned by @browserstack/sdk-dev) is behavior-unchanged

  • Moved to packages/core/; buf generate + tsc build and the files allowlist are unchanged.
  • Changesets ignores @browserstack/wdio-browserstack-service (.changeset/config.json) — this pipeline never versions or publishes the core. Your manual publish flow continues exactly as before.

Release pipeline (service only)

  • Changesets + npm OIDC trusted publishing (.github/workflows/release.yml) — no long-lived token, provenance-signed.
  • mainlatest (v9); a future v8 branch → v8 dist-tag (via per-branch publishConfig.tag; the workflow hardcodes no tag).
  • An included changeset bumps the first release to 9.29.0 (npm latest is currently 9.28.0).

Test-infra fix

The @wdio/reporter mock imported the real module from inside its own mock (a top-level vi.importActual + a static import), which deadlocked vitest and hung reporter.test.ts. Replaced with runtime stubs (those classes are only used as TS types). Full suite now: 39 files / 980 tests pass and the run exits cleanly.

Verified locally

  • npm ci + npm run build (core via buf+tsc, service via esbuild+tsc) ✅
  • npm test39 files / 980 tests pass, clean exit
  • npm pack → clean 80-file / ~590 kB tarball (only build/ + README + LICENSE + ambient d.ts; no docs/logs/maps) ✅
  • Real BrowserStack session with the packed tarball: services: ['browserstack'] resolved, session created, spec green, webdriverio deduped to a single copy ✅

To go live (after this merges)

  1. An @wdio npm org admin sets a Trusted Publisher on @wdio/browserstack-service: Org browserstack, Repo wdio-browserstack-service, Workflow release.yml, Environment empty.
  2. Merge the Changesets "Version Packages" PR → first publish (9.29.0).
  3. Then the WebdriverIO monorepo PRs remove the in-repo service + update docs links.

Note: docs/ includes the migration plan and architecture notes for reviewers — trim before merge if you'd rather not keep strategy docs in the public repo.

🤖 Generated with Claude Code

…ide the gRPC core

Adds the WebdriverIO service (@wdio/browserstack-service) as packages/browserstack-service
and moves the existing gRPC/protobuf core (@browserstack/wdio-browserstack-service) into
packages/core (git history preserved), so both ship from this repo.

- npm workspace: root build builds core then service; tests scoped to the service.
- service: esbuild bundle (deps external) + tsc declarations; strict files allowlist
  (build + README + LICENSE + ambient d.ts); peerDeps for webdriverio/@wdio/* so the
  consumer keeps a single shared copy.
- release: Changesets + npm OIDC trusted publishing via .github/workflows/release.yml
  (main -> latest, v8 -> v8 dist-tag); the gRPC core is ignored by Changesets and stays
  on the SDK team's manual publish flow.
- fix(tests): the @wdio/reporter mock no longer imports the real module from within its
  own mock (a top-level vi.importActual + static import deadlocked vitest and hung
  reporter.test.ts); stub the stats classes instead. Full suite: 39 files / 980 tests
  pass and the run exits cleanly.
- repo metadata -> browserstack/wdio-browserstack-service; an added changeset bumps the
  first release to 9.29.0 (current npm latest is 9.28.0).

Verified locally: npm ci + build + test green; clean 80-file / ~590kB tarball; a real
BrowserStack session passed with webdriverio deduped to one copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread packages/browserstack-service/src/cli/cliUtils.ts Dismissed
AakashHotchandani and others added 4 commits June 15, 2026 11:01
The committed lockfile was stale (carried entries from the pre-conversion layout), so CI's `npm ci` failed the sync check (EUSAGE: lockfile's @types/node@12.20.55 vs @types/node@25.9.3, missing undici-types). Regenerated against the current npm-workspace package.json set (760 -> 592 packages). `npm ci --dry-run` now passes; toolchain (typescript, vitest, esbuild, ts-proto, @bufbuild/buf + 7 platform optionalDeps) and workspace links (core <-> service) preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous lockfile commit was produced by `npm install --package-lock-only` against a stale node_modules, so npm shortcut it ('up to date, audited ... in 2s') and only pruned the old tree instead of fully resolving. CI's `npm ci` then rebuilt the ideal tree from package.json and found mismatches (Invalid @types/node@12.20.55 vs 25.9.3, Missing esbuild@0.28.1, etc.).

Regenerated with both package-lock.json and node_modules absent so the resolution is computed purely from package.json (598 packages). Verified locally with npm@10.9.8 (CI's npm): `npm ci` installs cleanly, `npm run build` (core buf+tsc / service esbuild+tsc) succeeds, and `npm test` passes 980/980.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-install)

The service declares non-optional peerDependencies (@wdio/cli, @wdio/logger, @wdio/reporter, @wdio/types, webdriverio). npm 7+ auto-installs peers by default, so CI's `npm ci` requires their full closure in the lock (@wdio/cli@9.28.0 -> create-wdio, tsx, inquirer, ejs, execa, esbuild@0.28.1, @vitest/snapshot@2.1.9, ...).

Prior lock commits were generated with a local ~/.npmrc legacy-peer-deps=true (left over from unrelated work), which suppressed peer auto-install and produced a lock missing that closure -> CI EUSAGE 'Missing @wdio/cli@9.28.0 from lock file' etc.

Regenerated with --no-legacy-peer-deps to match CI's default (598 -> 720 packages). Verified CI-exact: `npm ci --no-legacy-peer-deps` installs cleanly, build succeeds, 980/980 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ake timers

CI's Node 18.20 job (supported per engines: node >=18.20.0; wdio v9 supports 18.20) surfaced three Node-18-only failures that Node 20/22 tolerate:

1. PerformanceTester.start/end/measure called performance.mark/measure directly. Under vitest's full fake timers (7 test files) performance.now() goes negative, and Node 18's perf_hooks rejects negative timestamps (ERR_PERFORMANCE_INVALID_TIMESTAMP), throwing out of callers' before() hooks. Routed all mark/measure through safeMark/safeMeasure wrappers — instrumentation must never throw into business logic (no-op on the happy path; transparent on Node 20+).

2. uploadLogs used fs.openAsBlob (Node >=20 only). Added a Node-18 fallback to new Blob([readFileSync]) for the small log archive.

3. util.test.ts faked  via bare useFakeTimers(); scoped to toFake:['Date'] (it never advances timers), matching reporter.test.ts.

Verified locally on Node 18.20.5 AND Node 22: npm ci + build + 980/980 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@AakashHotchandani AakashHotchandani requested review from kamal-kaur04 and removed request for AdityaHirapara June 15, 2026 06:57

@kamal-kaur04 kamal-kaur04 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated PR Review

Verdict: 🔴 Needs human review — build/release/wiring infra is well-built (one warning: SHA-pin the publish-workflow actions). The bulk is migrated service code + a 9.3k-line lockfile whose fidelity can't be verified from the diff.

Summary: 0 critical · 1 warning · 3 suggestions across 158 files (143 added, 11 git-mv renames, 4 modified).

This is a monorepo conversion: the gRPC core moves to packages/core via git mv (behavior unchanged), and @wdio/browserstack-service is extracted into packages/browserstack-service with a new Changesets + npm OIDC trusted-publishing pipeline. End-user contract is unchanged.

Two human checks before merge: (1) confirm the service source matches the upstream WebdriverIO monorepo at a known SHA; (2) confirm @browserstack/wdio-browserstack-service@2.0.2 is published on npm so the service's ^2.0.2 dependency resolves for standalone installs. CI is green across node 18.20/20/22 + CodeQL, and the PR reports a real packed-tarball BrowserStack session.

See inline comments below for detail on each finding.

Generated by Automated PR review.

Comment thread .github/workflows/release.yml Outdated
run: npm test

- name: Create Release PR or publish to npm
uses: changesets/action@v1

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Warning — [SECURITY] Publish workflow pins actions to mutable major tags

Problem

This workflow holds id-token: write plus contents: write / pull-requests: write and performs the actual npm publish. It consumes third-party actions by mutable major tag: changesets/action@v1 (this line), actions/checkout@v4, actions/setup-node@v4.

A mutable tag can be force-moved. If any of these — especially changesets/action, which runs at publish time with the OIDC token in scope — is compromised at the tag, an attacker could exfiltrate the short-lived npm OIDC token or publish a malicious @wdio/browserstack-service. This is exactly the threat model GitHub's Actions hardening guidance addresses for privileged/publish workflows. (ci.yml is lower-risk — contents: read only — so the concern is concentrated here.)

Suggested Fix

Pin the actions in release.yml to full commit SHAs, with the version in a trailing comment:

uses: changesets/action@<40-char-sha>   # v1.x
uses: actions/checkout@<40-char-sha>    # v4.x
uses: actions/setup-node@<40-char-sha>  # v4.x

npm install -g npm@11 is also a mutable floor; acceptable given the documented OIDC-version rationale, but worth noting it's installed fresh each run. Dependabot's github-actions ecosystem can keep SHA pins current.

Confidence: 🟢 Grounded in GitHub's published Actions hardening guidance for workflows with id-token: write + publish capability.

@AakashHotchandani AakashHotchandani Jun 15, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 47a1c8f. Pinned all three to full commit SHAs with version comments:

  • actions/checkout@34e1148… # v4
  • actions/setup-node@49933ea… # v4
  • changesets/action@a45c4d5… # v1

Left ci.yml on major tags as you noted (it's contents: read only). Dependabot github-actions can keep these SHAs current.

(Correction: these edits were accidentally left unstaged in f935a52 — which only removed the docs — so they actually landed in 47a1c8f.)

Comment thread packages/core/package.json Outdated
{
"name": "@browserstack/wdio-browserstack-service",
"version": "2.0.2",
"description": "WebdriverIO service for better Browserstack integration",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Suggestion — [DOCS] Core package metadata is stale

Problem

This is the gRPC/protobuf core package (@browserstack/wdio-browserstack-service), but its description reads "WebdriverIO service for better Browserstack integration" and its keywords include webdriverio — both copied from the original service package. On the public npm listing this misrepresents what the package is.

Suggested Fix

Tighten the description (e.g. "gRPC/protobuf core for the BrowserStack WebdriverIO service") and drop webdriverio from keywords (line 28). Cosmetic, but it's the public npm metadata.

@AakashHotchandani AakashHotchandani Jun 15, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 47a1c8f. description is now "gRPC/protobuf core for the BrowserStack WebdriverIO service" and dropped the webdriverio keyword. Public npm metadata now reflects what the package is.

(Correction: landed in 47a1c8f, not f935a52 — that commit only removed the docs.)

"clean": "rm -rf dist src/generated",
"generate": "buf generate",
"build": "npm run clean && npm run generate && tsc",
"prepare": "npm run build",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Suggestion — [BUILD] Core prepare triggers a redundant build on install

Problem

"prepare": "npm run build" runs clean && buf generate && tsc during npm ci/install of the workspace, and CI then runs npm run build again — so the core is built twice per CI run, and prepare requires the buf binary to be fetchable at install time.

Suggested Fix

It works (CI is green), so this is non-blocking — but consider whether prepare is needed here given the explicit build step in the pipeline. Note this is harmless for published consumers: prepare does not run when installing a registry tarball, only for git/local installs.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping prepare intentionally — replying with rationale rather than changing it.

The core is published manually (it's Changesets-ignored), and prepare is what guarantees build runs before npm publish/npm pack, so a manual publish can never ship a stale/missing dist. buf is a devDependency, so it's present after npm ci. As you noted, it's a no-op for registry-tarball consumers, so the only cost is one extra build during CI's workspace install — acceptable for the publish-safety guarantee.

Comment thread docs/MIGRATION-PLAN.md Outdated
@@ -0,0 +1,61 @@
# Migration plan — taking over `@wdio/browserstack-service`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Suggestion — [SECURITY] Internal strategy/migration docs in a public repo

Problem

Five docs/ files (ARCHITECTURE.md, AUTO-UPDATE-AND-NPM-MECHANICS.md, CODE-CHANGES.md, DEV-TESTING.md, MIGRATION-PLAN.md, ~1,160 lines total) are added. This repo must be public for npm provenance, so these become publicly visible.

Suggested Fix

The PR description already flags this ("trim before merge if you'd rather not keep strategy docs in the public repo"). Decide explicitly before merge — keep if they're intended as contributor docs, or move to an internal location otherwise.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f935a52 — removed all five docs/ strategy files (ARCHITECTURE, AUTO-UPDATE-AND-NPM-MECHANICS, CODE-CHANGES, DEV-TESTING, MIGRATION-PLAN). Their only cross-references were among themselves, so no dangling links remain. Kept internally outside the public repo.

…nternal docs

Addresses automated PR review findings on #37:

- [SECURITY] release.yml: pin changesets/action, actions/checkout, actions/setup-node to full commit SHAs (with version comments) since this workflow holds id-token:write + publish. ci.yml left on tags (contents:read only).
- [DOCS] packages/core/package.json: description now describes the gRPC/protobuf core (not the service); dropped the 'webdriverio' keyword — corrects the public npm listing.
- [SECURITY] removed the five docs/ strategy/migration files from the public repo (kept internally). Their only cross-references were among themselves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread packages/browserstack-service/src/util.ts Dismissed
@AakashHotchandani

Copy link
Copy Markdown
Collaborator Author

What's a verbatim move vs. what actually changed

To separate "moved code" from "real changes", I diffed packages/browserstack-service against the upstream WebdriverIO v9 main source (packages/wdio-browserstack-service, service 9.27.2). Result: 73 of 76 src/ files are byte-identical — the service logic is a faithful move. Everything below is the non-move delta, with permalinks pinned to f935a52.

The published artifact and end-user/runtime behavior are unchanged. All changes are Node-version portability + standalone packaging (the package no longer lives inside the WebdriverIO monorepo's shared toolchain).


A. Source code — 3 files, all Node-18 / standalone-process compatibility

  1. src/request-handler.ts#L57-L59pollEventBatchInterval?.unref?.(). So the polling timer doesn't keep the Node event loop (or a test worker) alive on its own → clean process exit standalone.
  2. src/util.ts#L1457-L1464fs.openAsBlob (Node ≥20) now falls back to new Blob([fs.readFileSync(...)]). engines allows Node 18.20, where openAsBlob doesn't exist; fallback keeps log upload working.
  3. src/instrumentation/performance/performance-tester.ts#L21-L32 — added safeMark/safeMeasure wrappers and routed all performance.mark/measure calls through them. Telemetry must never throw into business logic; Node 18's perf_hooks rejects negative timestamps (seen under faked timers) with ERR_PERFORMANCE_INVALID_TIMESTAMP, which was aborting handlers' before() hooks. No-op on Node 20+.

B. Tests — 2 files, identical one-line fix

Scope vi.useFakeTimers(){ toFake: ['Date'] } (these tests only need a deterministic clock; faking performance made performance.now() negative and tripped the same Node-18 throw):

C. Packaging & build — required because it's no longer in the monorepo

packages/browserstack-service/package.json

  • Dependency restructuring (the key change): the wdio framework packages (@wdio/logger, @wdio/reporter, @wdio/types, webdriverio) moved from workspace:* dependenciespeerDependencies ^9.0.0 (the user's own wdio install provides them — avoids duplicate/mismatched copies) plus devDependencies ^9.0.0 (concrete versions for local build/test). @wdio/cli was already a peer; the gRPC core stays @browserstack/wdio-browserstack-service: ^2.0.2.
  • scripts — standalone build (esbuild + tsc for types) and test (vitest --run); upstream used the monorepo's shared runner.
  • files allowlist (+ .npmignore) — controls the published tarball; publishing was monorepo-managed before.
  • Metadata (author/homepage/repository/bugs) repointed to this repo.

New build/test config (none existed standalone in upstream):

  • scripts/build.mjs — esbuild bundler: one bundle per exports entry, ESM/node18 target, every dep/peerDep marked external so only the package's own src is bundled.
  • tsconfig.prod.json — declaration-file emit for published types.
  • vitest.config.ts + __mocks__/ (browserstack-local, chalk, fetch, fs, vitest.setup) — standalone replacements for the monorepo's shared test setup.

D. New repo infrastructure (a new standalone repo, not "the service")


Reproduce the move/no-move split: diff this packages/browserstack-service/src against webdriverio main:packages/wdio-browserstack-service/src — only the 3 files in §A differ.

Comment thread package.json
"description": "WebdriverIO service for better Browserstack integration",
"author": "Browserstack",
"homepage": "https://github.com/browserstack/wdio-browserstack-service",
"name": "wdio-browserstack-service-monorepo",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want to publish package directly at https://www.npmjs.com/package/@wdio/browserstack-service
But the name of the package in package.json is wdio-browserstack-service-monorepo.
This would break during npm publish.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This won't affect publishing — the root name is never published.

The root package.json (wdio-browserstack-service-monorepo) is "private": true — it's only the npm-workspaces container. The package that actually publishes is the workspace at packages/browserstack-service, whose name is @wdio/browserstack-service. changeset publish (and npm publish -w) publish workspace packages by their own names; a bare npm publish at the root would refuse precisely because it's private. So @wdio/browserstack-service is what lands on npm.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please check: #37 (comment)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tied together with the structure thread above: the root …-monorepo name is private:true/unpublished so it never reaches npm (publishing is per-workspace), and separately we've now confirmed the core has no real external consumers — so a NodeSDK-style flatten is viable as a follow-up, pending an internal-consumer check. Keeping 2 packages for this PR.

Comment on lines +3 to +10
# Publishes @wdio/browserstack-service to npm via OIDC Trusted Publishing
# (no long-lived NPM_TOKEN). One-time setup by an @wdio npm org admin on npmjs.com:
# @wdio/browserstack-service -> Settings -> Trusted Publisher -> GitHub Actions
# Organization/user: browserstack
# Repository: wdio-browserstack-service
# Workflow filename: release.yml
# Environment: (leave empty)
# Requires: PUBLIC repo (for provenance), npm >= 11.5.1, Node >= 22.14.0.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to handle publishing via GitHub CI? It'd be better to use minion for this if feasible.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the publish step on GitHub Actions by design. The pipeline uses npm OIDC trusted publishing (id-token: write, no NPM_TOKEN) for build provenance and to avoid a long-lived npm automation token — a condition from the @wdio org for taking over @wdio/browserstack-service.

npm Trusted Publishing currently only recognizes GitHub Actions and GitLab CI/CD as trusted publishers, so minion can't be registered as one — publishing from minion would mean falling back to a classic NPM_TOKEN secret and losing provenance. Given the OIDC/provenance requirement, the npm publish needs to live here. (Build/test can still be mirrored to minion if useful — it's specifically the publish step that's constrained.)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AakashHotchandani
We'd need EM Approval for this. None of the other SDKs are published to npm using Github Actions.

Comment thread .github/workflows/ci.yml
cache: 'npm'

- name: Install
run: npm ci

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

v8 requries --legacy-peer-deps flag along with the npm i command. So, for v8 we would need npm ci --legacy-peer-deps. For main branch, normal npm i would suffice.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and it's intentional per-line. main/v9 uses plain npm ci — verified: the v9 peer graph resolves cleanly. Adding --legacy-peer-deps here would actually suppress the peerDependency closure and reintroduce the exact npm ci lockfile mismatch that was just fixed on this branch, so it must stay off for v9.

The v8 branch (created later) will set npm ci --legacy-peer-deps in its own ci.yml, since v8's peer ranges need it. No change needed on this (v9) branch.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small correction, if we re-use pnpm used in WDIO v9, then it'd be changed to pnpm ci here for v9 as well

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flagging a small correctness point on this: pnpm ci isn't actually a pnpm command — it errors with ERR_PNPM_CI_NOT_IMPLEMENTED. The frozen-lockfile equivalent is pnpm install --frozen-lockfile (which pnpm does by default in CI when a lockfile is present).

That said — per the package-manager thread, we're staying on npm for v9 (OIDC publish-path risk, not capability), so this line stays npm ci for now.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread .changeset/config.json
@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we re-use the existing yml file-based approach for this repo as well for maintaining consistency across the SDK repositories.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd lean toward keeping Changesets for this package. It's @wdio-scoped and the upstream WebdriverIO project releases with Changesets, so matching that keeps us consistent with the org we're co-publishing under. The OIDC release.yml is also built around changeset version / changeset publish — it drives the per-branch dist-tag and the "Version Packages" PR flow — so switching to the internal yml approach would mean reworking that pipeline and re-deriving the OIDC wiring.

So here it's consistency-with-wdio vs consistency-with-other-SDK-repos, and for a @wdio/* package I'd favor the former. Happy to discuss if the team feels strongly.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, happy to discuss this over a call.

Comment thread README.md
@@ -1,119 +1,51 @@
# @browserstack/wdio-browserstack-service
# wdio-browserstack-service

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to be reviewed by @browserstack/sdk-product

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — the README content should get a @browserstack/sdk-product sign-off before merge. We'll route it to that team separately; it isn't blocking the code review here.

@xxshubhamxx xxshubhamxx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please update BStackAutomation to use this SDK in a custom branch, trigger both sanity as well as complete regression using that branch, and confirm if the output matches with the original repo's regression runs. Feel free to take QA's help for the same as well.

Comment thread packages/browserstack-service/__mocks__/vitest.setup.ts
@@ -0,0 +1,95 @@
{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be moved at the top of the repo while maintaining publishing mechanism similar to NodeSDK instead of maintaining 2 separate packages for packages/browserserstack-service (Actual SDK Handling) and packages/core (Proto files).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dug into this properly before answering. Findings:

Does anything actually depend on the core separately? The core @browserstack/wdio-browserstack-service (v2 line) has exactly one direct dependent on npm — @wdio/browserstack-service itself (deps.dev: 1 direct, 5 indirect, and all 5 indirect resolve it transitively through the service). The v1.x line did have standalone consumers, but it's deprecated ("Use @wdio/browserstack-service instead"). So in the public ecosystem, flattening + no longer publishing the core separately would break nothing real.

NodeSDK comparison — accurate. browserstack-node-sdk is a single root package (no workspaces), generates its proto inline at build time, and depends on no separately published core. So "flatten like NodeSDK" is a real, proven internal pattern.

The one gate: deps.dev / GitHub code search only see public artifacts — an internal/private BrowserStack pipeline importing @browserstack/wdio-browserstack-service@2.x directly wouldn't show up. Before un-publishing the core we'd want a quick internal confirmation that nothing internal consumes it directly.

Plan: keep the 2-package layout for this PR (it's green and consumer-safe), and treat "flatten to a single NodeSDK-style package + inline the proto/core" as a fast follow-up once that internal-consumer check clears — so #37 isn't blocked while we move toward the structure you're suggesting.

(Naming clarity: the package being protected here is the core @browserstack/wdio-browserstack-service, distinct from the service's npm name @wdio/browserstack-service.)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd request this to be done in this iteration itself instead of a fast follow-up as mentioned above. This is because it'd have an impact on handling building job in BStackAutomation.

…tadata

These two review fixes were edited but accidentally left unstaged when f935a52 (docs removal) was committed, so they never landed. Committing them now:

- release.yml: pin changesets/action, actions/checkout, actions/setup-node to full commit SHAs (with version comments).
- packages/core/package.json: description now describes the gRPC/protobuf core; dropped the 'webdriverio' keyword.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread package-lock.json
@@ -1,29 +1,100 @@
{
"name": "@browserstack/wdio-browserstack-service",
"version": "2.0.2",
"name": "wdio-browserstack-service-monorepo",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDIO v9 has pnpm-lock.yaml instead of package-lock.json. My suggestion would be to continue using pnpm for v9 as this would significantly improve the maintainability and development experience.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked into this carefully — and to be fair, pnpm is technically viable here: pnpm has native npm OIDC trusted publishing (pnpm 10.x) with automatic provenance, and changeset publish on a pnpm repo runs pnpm publish — so our one hard requirement (OIDC, no long-lived NPM_TOKEN) is satisfied either way. So this isn't a "pnpm can't" — it's a risk call.

We're keeping npm for the publish/CI path:

  • It's the path npm's trusted-publishing docs actually document (npm ≥ 11.5.1, Node ≥ 22.14), and PR feat: monorepo conversion — add @wdio/browserstack-service alongside the gRPC core #37 is already green on it.
  • pnpm's OIDC support is newer and had a regression in pnpm 11.0.x (fixed, but less battle-tested) — not where we want risk on the publish path.
  • The published tarball is identical to consumers; pnpm-vs-npm here is purely repo dev-ergonomics for a single repo.

If the real goal is local-dev pnpm ergonomics, we can make the repo pnpm-friendly without moving the publish path. If full pnpm is required, we'll do it gated on a throwaway canary OIDC publish + a split publish step so we don't gamble the first real release on it.

Minor correctness note for whoever scripts it: pnpm ci isn't a command (it errors ERR_PNPM_CI_NOT_IMPLEMENTED) — the frozen-lockfile CI equivalent is pnpm install --frozen-lockfile.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pnpm ci is a documented command: https://pnpm.io/cli/ci

While I agree that the repo is already green on npm, moving over to pnpm ideally shouldn't break things. It has a lot of different benefits, and it is what wdio v9 uses as well.

setup-node's `registry-url` writes `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}` into .npmrc; with no NODE_AUTH_TOKEN that empty token line can shadow npm OIDC Trusted Publishing at publish time. npm defaults to registry.npmjs.org and publishConfig.access=public already covers the scoped publish, so the line is unnecessary. Splitting the changesets publish into its own top-level step is noted as a further OIDC-hardening follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@AakashHotchandani

Copy link
Copy Markdown
Collaborator Author

Scope note: This describes the runtime flow of the service code in this PR (v9). The v8 line under extraction behaves identically at runtime — only build tooling and dependency packaging differ (tsc vs esbuild, files allowlist, @types). All file:line references below are to the v9 source.

End-to-end execution flow: from your wdio.conf to BrowserStack

1. TL;DR

The end user adds a single line to their wdio.confservices: [['browserstack', { ...BrowserstackConfig }]] — and runs wdio run. @wdio/cli instantiates the package's named launcher export once in the main process and the default export (the service) once per worker (src/index.ts:9-10). In the main process the launcher (onPrepare) bootstraps the build/session: funnel instrumentation, optional CLI/gRPC core, app upload, BrowserStack Local tunnel, Percy, accessibility, and TestHub build creation. In each worker the service observes the WebdriverIO test lifecycle (session create, suites, tests, hooks, commands) and reports results to BrowserStack either directly over HTTPS (automate session API + observability collector) or, when the gRPC CLI binary is running (mocha + non-multiremote only), over a local gRPC channel to the separately-published @browserstack/wdio-browserstack-service core.

2. End-to-end diagram

                                wdio.conf.js
              services: [['browserstack', { ...BrowserstackConfig }]]
              config.user / config.key   (+ env BROWSERSTACK_USERNAME/_ACCESS_KEY)
              capabilities: bstack:options.* / browserstack.*
                                     │
                                  wdio run
                                     │
                          ┌──────────┴───────────┐
                          │     @wdio/cli         │
                          └──────────┬───────────┘
             instantiates           │            instantiates (× N workers)
          launcher (MAIN proc)      │           default service (per worker)
        src/index.ts:10             │          src/index.ts:9
                 │                   │                       │
   ┌─────────────▼─────────────┐    │      ┌────────────────▼─────────────────┐
   │ BrowserstackLauncher      │    │      │ BrowserstackService               │
   │ onPrepare (launcher.ts)   │    │      │ beforeSession→before→suite/test/  │
   │  • sendStart funnel       │    │      │ hook/cmd→after (service.ts)       │
   │  • CLI/gRPC bootstrap?     │   │      │  • AccessibilityHandler            │
   │  • app upload              │   │      │  • InsightsHandler (TestHub)       │
   │  • buildIdentifier         │   │      │  • PercyHandler                    │
   │  • launchTestSession       │   │      │  • command/result listeners        │
   │  • accessibility wiring    │   │      │  • _updateJob (session status)     │
   │  • Percy start             │   │      └───────┬─────────────────┬──────────┘
   │  • BrowserStack Local      │   │              │                 │
   │  onWorkerStart: BEST_PLAT… │   │   isRunning()? CLI path     direct-API path
   │  onComplete: stop/finish   │   │              │                 │
   └─────────────┬─────────────┘    │     ┌────────▼───────┐  ┌──────▼─────────────────┐
                 │                   │     │ gRPC CLI core  │  │ Listener→RequestQueue  │
                 │                   │     │ (local socket, │  │ (batch, 1000/2000ms)   │
                 │                   │     │ insecure chan) │  └──────┬─────────────────┘
                 │                   │     └────────────────┘         │
                 ▼                   ▼                                 ▼
   ┌──────────────────────────────────────────────────────────────────────────────┐
   │ BrowserStack endpoints                                                          │
   │  api.browserstack.com/sdk/v1/event           (funnel SDKTest*)                  │
   │  collector-observability.browserstack.com    (/api/v2/builds, /api/v1/batch)    │
   │  api.browserstack.com/automate/sessions/…     (session status/name PUT/PATCH)   │
   │  api-cloud.browserstack.com/app-automate/…    (app upload, app-automate)        │
   │  app-accessibility.browserstack.com/automate  (a11y issues polling)             │
   │  upload-observability.browserstack.com        (service-log archive)             │
   │  eds.browserstack.com/send_sdk_events         (performance metrics, cleanup)    │
   └──────────────────────────────────────────────────────────────────────────────┘
                 │
                 ▼
   onComplete (launcher) ─ stop build/CLI, sendFinish funnel, upload logs, stop Percy/Local
   process.on('exit') ─ kill CLI child + spawn detached cleanup.js (flush funnel/o11y/perf)

3. Stage 1 — What the end user adds & triggers

The wdio.conf services entry

services: [['browserstack', { /* BrowserstackConfig */ }]]
  • The options object is typed by WebdriverIO.ServiceOption extends BrowserstackConfig (src/index.ts:20-23); the options shape is defined in src/types.ts:66-221.
  • @wdio/cli passes (options, capabilities, config) to both constructors: launcher (launcher.ts:81-85) and service (service.ts:69-73).
  • Trigger: running wdio run loads the service, which constructs the launcher in the main process and the service in each worker.

Credentials (user / key)

user/key are not BrowserstackConfig fields — they are top-level Testrunner config (config.user / config.key).

  • Resolution order via getBrowserStackUser/getBrowserStackKey (util.ts:1309-1321): env BROWSERSTACK_USERNAME / BROWSERSTACK_ACCESS_KEY first, else config.user / config.key.
  • getBrowserStackUserAndKey (util.ts:1180-1200) adds two more fallbacks for the observability path: testObservabilityOptions.user/key, then top-level config (getObservabilityUser/Key, util.ts:1236-1254).
  • Captured into the central config as userName/accessKey (config.ts:99-100). A session is treated as a BrowserStack session only when config.key length === 20 (util.ts:1137-1142).
  • If neither is set, beforeSession injects 'NotSetUser'/'NotSetKey' so the session request fails loudly (service.ts:126-132).

Key feature options (read from the options object)

Option Where read Effect
testObservability (alias testReporting) service.ts:80; launcher.ts:95-101,216 TestHub/observability; default true unless explicitly false
accessibility (+ accessibilityOptions) service.ts:81,227-237; launcher.ts:210-213,440-453 Accessibility scanning
percy (+ percyCaptureMode, percyOptions) launcher.ts:406,457-469; service.ts:82-83 Visual testing
app (string or AppConfig) launcher.ts:360-387 App-Automate upload
browserstackLocal (+ opts, forcedStop) launcher.ts:531-578,651-655 Local tunnel
buildIdentifier (${BUILD_NUMBER}/${DATE_TIME}) launcher.ts:392-401,1081-1120 Build naming
turboScale service.ts:84,91-93 TurboScale session base URL + PATCH
setSessionName / setSessionStatus / sessionNameFormat service.ts:431,805-835 Session name/status updates
testOrchestrationOptions.runSmartSelection launcher.ts:254,474 Smart test selection/reordering

DEFAULT_OPTIONS = { setSessionName: true, setSessionStatus: true, testObservability: true } (constants.ts:23-27) is merged in the service constructor (service.ts:74).

Capabilities surface

Per-session settings come through capabilities, read by the launcher constructor (launcher.ts:125-213):

  • bstack:options.{buildName, projectName, buildTag, buildIdentifier, accessibility} (launcher.ts:160-169), or legacy browserstack.* / browserstack.buildIdentifier / browserstack.accessibility (launcher.ts:144-155).
  • BSTACK_SERVICE_VERSION is injected as bstack:options.wdioService / browserstack.wdioService, only when isBStackSession (launcher.ts:140,143,150).
  • App-Automate is detected from appium:app / appium:options.app (service.ts:694-702).

Env gates (selected)

BROWSERSTACK_TURBOSCALE (service.ts:91), BROWSERSTACK_IS_MULTIREMOTE (launcher.ts:314), BROWSERSTACK_RERUN/BROWSERSTACK_RERUN_TESTS (launcher.ts:220-223), BROWSERSTACK_CLI_ENV / BROWSERSTACK_CLI_BIN_SESSION_ID (cli/index.ts:88, cliUtils.ts:52), BROWSERSTACK_PERCY/_CAPTURE_MODE, WDIO_WORKER_ID/BEST_PLATFORM_CID (service.ts:103-105), BROWSERSTACK_O11Y_PERF_MEASUREMENT.

4. Stage 2 — How WebdriverIO loads it

Two entry points, two processes

  • export default BrowserstackService (src/index.ts:9, from ./service.js) — the per-worker class; one instance per WebdriverIO worker.
  • export const launcher = BrowserstackLauncher (src/index.ts:10, from ./launcher.js) — the main-process class; one instance.
  • Aux exports: log4jsAppender = { configure } (index.ts:11), BStackTestOpsLogger = logReportingAPI (index.ts:12), PercySDK (index.ts:15), and export * from './types.js' (index.ts:18).
  • A second package entry point ./cleanup (declared in package.json exports, not in index.ts) maps to src/cleanup.ts and is run as its own detached process on exit (cleanup.ts:108).
  • ESM-only: the exports map has only import/types, no require.

Object graph

  • BrowserstackLauncherService (launcher.ts:70) — fields browserstackLocal, _percy, browserStackConfig; constructor (launcher.ts:81-230) builds the BrowserStackConfig singleton, normalizes options, walks caps, registers exit handlers.
  • BrowserStackConfig singleton (config.ts:63-118) — central holder of userName/accessKey/framework/testObservability/percy/accessibility/app/buildName/buildIdentifier/sdkRunID.
  • BrowserstackService (service.ts:44) — constructor (service.ts:69-106) merges DEFAULT_OPTIONS, reads product toggles; lazily creates AccessibilityHandler (service.ts:227), InsightsHandler (service.ts:254), PercyHandler (service.ts:311) in before().
  • BrowserstackCLI singleton (cli/index.ts:35) — wrapper around the spawned gRPC SDK binary; its isRunning() is the master switch between CLI and direct-API paths.
  • Worker-side handlers: InsightsHandler (insights-handler.ts:68), AccessibilityHandler (accessibility-handler.ts:95), PercyHandler (Percy/Percy-Handler.ts:25), TestReporter (reporter.ts), Listener/UsageStats (testOps), PerformanceTester (instrumentation).
  • Backward-compat shim: if _config is falsy, both classes fall back to using the options object as config (launcher.ts:90-92; service.ts:76-78) — WebdriverIO v5 compatibility.

5. Stage 3 — Launcher onPrepare (main process)

onPrepare (launcher.ts:247) is decorated @PerformanceTester.Measure(SDK_PRE_TEST). Steps run in order; most errors are swallowed and the run continues — only app-upload validation throws SevereServiceError (launcher.ts:369,380) and the 60s tunnel timeout can reject (launcher.ts:564-565).

Master gate for many steps: BrowserstackCLI.getInstance().isRunning() (cli/index.ts:452-461). When the CLI binary is running, app upload, launchTestSession, tunnel start/stop are skipped — the binary owns them.

# Step Gate file:line Outbound
1 Funnel SDKTestAttempted via sendStart always (skipped if no user/key) launcher.ts:251; funnelInstrumentation.ts:35-46 POST api.browserstack.com/sdk/v1/event (Basic auth; creds redacted in logged copy)
2 Set BROWSERSTACK_IS_MULTIREMOTE always launcher.ts:313-314
3 Smart-selection glob expansion isValidEnabledValue(runSmartSelection.enabled) + specs array launcher.ts:254-309 none (local)
4 CLI/gRPC bootstrap checkCLISupportedFrameworks('mocha') AND not multiremote launcher.ts:316-323; cli/index.ts:80-105 spawns binary, gRPC startBinSession
5 Self-heal (AiHandler) setup only when not a BrowserStack session launcher.ts:331-353 funnel TCG events
6 App upload !CLI running and _options.app set, valid extension + file exists launcher.ts:359-387,746 POST api-cloud.browserstack.com/app-automate/upload (multipart)
7 buildIdentifier resolution (${DATE_TIME}/${BUILD_NUMBER}) buildName present launcher.ts:392-401,1081-1120 none (CI info / ~/.browserstack/.build-name-cache.json)
8 TestHub build creation (launchTestSession) !CLI running AND (testObservability || accessibility || shouldSetupPercy) launcher.ts:404-419; util.ts:367-461 POST collector-observability.browserstack.com/api/v2/builds; sets BS_TESTOPS_BUILD_COMPLETED, BROWSERSTACK_TESTHUB_JWT, BROWSERSTACK_TESTHUB_UUID
9 Accessibility wiring (extension inject for turboscale/non-bstack; adopt server flag; write a11y options + JWT into caps) isAccessibilityAutomationSession launcher.ts:421-453 none (uses build-start response)
10 Percy setup shouldSetupPercy = percy || (percy undefined && app) launcher.ts:455-469,686-713 token fetch + spawns local Percy CLI (see Stage 5d)
11 Stamp testhubBuildUuid + buildProductMap into caps always launcher.ts:471-472,949-1075
12 Test orchestration reorder isValidEnabledValue(runSmartSelection.enabled) + testObservability launcher.ts:474-524 gRPC or HTTPS split-tests (see Stage 5b/orchestration)
13 BrowserStack Local tunnel start !CLI running AND _options.browserstackLocal launcher.ts:526-578 spawns BrowserStackLocal binary (60s timeout race)

onWorkerStart (launcher.ts:233, @Measure(SDK_SETUP)): only acts when _options.percy && _percyBestPlatformCaps set — if the worker's caps deep-equal the best Percy platform it sets process.env.BEST_PLATFORM_CID = cid (launcher.ts:238) so that one worker takes Percy snapshots. Other worker env (TESTHUB JWT/UUID, OBSERVABILITY, PERCY flags, BIN_SESSION_ID) was set in onPrepare and inherited by spawned workers.

Note: the launcher has no onWorkerEnd — that hook lives in the worker-side service (service.ts:508).

6. Stage 4 — Per-worker session lifecycle (service.ts)

Each worker instantiates one BrowserstackService. Every hook is wrapped by @PerformanceTester.Measure(SDK_HOOK, {hookType}). Throughout, a branch on BrowserstackCLI.getInstance().isRunning() chooses direct action vs trackEvent() into the CLI state machines.

Hook What it does file:line
constructor Merge DEFAULT_OPTIONS; read _observability/_accessibility/_percy/_turboScale; start perf CSV; push TestReporter into config.reporters if TestHub enabled; set PERCY_SNAPSHOT when WDIO_WORKER_ID===BEST_PLATFORM_CID service.ts:69-106
beforeSession Default user/key to NotSet* if absent; CLI bootstrap when checkCLISupportedFrameworks(framework) && BROWSERSTACK_IS_MULTIREMOTE!=='true', then rewrite _config.hostname to nearest hub; under CLI track AutomationFrameworkState.CREATE/PRE and merge resolved caps. No REST call here. service.ts:118-182
before Bind browser; choose _sessionBaseUrl (app-automate / turboscale / automate); construct AccessibilityHandler (always) and call its before unless (bstack session && CLI running); if shouldProcessEventForTesthub('') construct InsightsHandler; when CLI not running register browser.on('command')/on('result') listeners and construct PercyHandler; then _printSessionURL() service.ts:184-327
beforeSuite Store suite title/file; _setSessionName(suite.title) (skipped under CLI+mocha; skips Jasmine synthetic top-level) service.ts:336-347
beforeHook Record _currentTest (non-cucumber); insightsHandler.beforeHook (mocha → HookRunStarted) service.ts:349-355
beforeTest Set _currentTest, derive suite name; CLI → trackEvent TestFramework states; else _setAnnotation, _setSessionName, accessibilityHandler.beforeTest, insightsHandler.beforeTest service.ts:372-400
afterTest _specsRan=true; accumulate fail reasons into _failReasons + _pureTestFailReasons; CLI → trackEvent; else accessibilityHandler.afterTest, insightsHandler.afterTest, percyHandler.afterTest service.ts:402-423
afterHook Push to _hookFailReasons (and _failReasons unless ignoreHooksStatus); insightsHandler.afterHook service.ts:357-370
command capture Via browser.on('command')/on('result') registered in before() (CLI not running): insightsHandler.browserCommand caches command / logs HTTP+screenshot events; percyHandler.browserBefore/AfterCommand service.ts:273-302; insights-handler.ts:552-596
Cucumber beforeFeature/beforeScenario/afterScenario/beforeStep/afterStep delegate to handlers and annotate; failure attribution honors ignoreHooksStatus + hasTestStepFailures service.ts:565-638
after Compute pass/fail (with ignoreHooksStatus three-way logic); _updateJob({status,name?,reason?}) → REST PUT/PATCH (only if setSessionStatus && not CLI); Listener.onWorkerEnd(); percyHandler.teardown(); saveWorkerData(); PerformanceTester.stopAndGenerate('performance-service.html') service.ts:425-559
onReload On session reload: update old session status/name (_update), reset per-session state, re-print new build URL service.ts:640-692

The two REST destinations from the service

  • _update (service.ts:735-760): PUT (or PATCH for turboScale) to ${_sessionBaseUrl}/${sessionId}.json with Basic auth; body {status, name?, reason?}. _sessionBaseUrl is automate (service.ts:45), app-automate (:210), or automate-turboscale/v1 (:214).
  • _printSessionURL (service.ts:762-803): GET the same URL to read automation_session.browser_url (or .url for turboScale) and log the session link.
  • In-session command channel (not REST): _executeCommand('annotate', …) runs browser.executeScript('browserstack_executor: {…}') (service.ts:841-861).

No afterSuite and no named afterCommand/beforeCommand exist in this class; command capture is purely the event listeners above. The legacy synchronous setSessionStatus block at service.ts:442-450 is commented out — the live status update is the measureWrapper one (service.ts:452-504).

7. Stage 5 — Internals deep-dive

(a) CLI binary + gRPC core (@browserstack/wdio-browserstack-service)

When enabled: CLIUtils.checkCLISupportedFrameworks(framework) returns true only for 'mocha' (cliUtils.ts:50,978-983) AND the run is not multiremote (launcher.ts:316; service.ts:139). There is no single isCLIEnabled/isCLIEnabledForFunnel function — the concept is realized by (a) that framework+multiremote gate before bootstrap(), (b) the static BrowserstackCLI.enabled flag set true in bootstrap() (cli/index.ts:82), and (c) the runtime predicate BrowserstackCLI.isRunning() (cli/index.ts:452-461) used at every downstream call site. isCLIEnabled appears only as a boolean threaded into funnel instrumentation in onComplete (launcher.ts:588; funnelInstrumentation.ts:150).

Bootstrap (main process): bootstrap() (cli/index.ts:80-105) → if BROWSERSTACK_CLI_BIN_SESSION_ID present → startChild (worker reconnect), else startMain (cli/index.ts:111-121). start() (cli/index.ts:225-345) resolves the binary (dev-mode shortcut when BROWSERSTACK_CLI_ENV==='development'; SDK_CLI_BIN_PATH override; otherwise version-check + download via cliUtils.ts:178-279), spawns <bin> sdk, parses id=/listen= from stdout, then GrpcClient.startBinSession over an insecure local gRPC channel (grpcClient.ts:112-203). The StartBinSessionResponse drives loadModules (cli/index.ts:127-218): sets BS_TESTOPS_BUILD_COMPLETED, BROWSERSTACK_TESTHUB_JWT/UUID, OBSERVABILITY/ACCESSIBILITY env, rewrites all API base URLs from config.apis via APIUtils.updateURLSForGRR (apiUtils.ts:13-24), and instantiates per-product modules (WebdriverIO/Automate/TestHub/Observability/Accessibility/Percy).

Workers skip spawning; they connect() + connectBinSession using inherited env (cli/index.ts:430-446; grpcClient.ts:209-243).

What the core package provides: runtime VALUES are imported only in cli/grpcClient.ts (the SDKClient, gRPC credentials/channel classes, proto message constructors). Every other file imports TYPES only — the gRPC/protobuf surface is fully encapsulated behind GrpcClient. Transport is grpcCredentials.createInsecure() to a local listen address (often a unix socket); the binary is a local sidecar. Even with CLI enabled, automate session name/status marking still goes out as direct REST PUTs from automateModule (automateModule.ts:198-277).

Direct-API fallback (CLI not enabled): launchTestSession (util.ts:367) POSTs build-start to the collector, and stopBuildUpstream (util.ts:711) PUTs build-stop; session name/status, accessibility, insights, tunnel, and app upload all run their !isRunning() code paths.

(b) Observability / TestOps data path

Precondition: build-start must have set BS_TESTOPS_BUILD_COMPLETED='true' and BROWSERSTACK_TESTHUB_JWT (util.ts:441-449, or cli/index.ts:150-162).

Capture → batch → POST:

  1. Per-worker, WDIO hooks drive two capturers: InsightsHandler (mocha/cucumber — insights-handler.ts:385-426) and TestReporter (a WDIOReporter; jasmine + mocha skips only — reporter.ts:130-240). Both build a normalized TestData/LogData/CBTData payload.
  2. Logs originate from a winston transport (logReportingAPI.ts:31-42) and logPatcher that emit an in-process bs:addLog:<pid> event consumed by the capturers (insights-handler.ts:94-98; reporter.ts:60-64).
  3. Each event passes through Listener (testOps/listener.ts), gated by shouldProcessEventForTesthub() (env BROWSERSTACK_OBSERVABILITY/ACCESSIBILITY/PERCYtestHub/utils.ts:27-38) and shouldSendEvents() = isTrue(BS_TESTOPS_BUILD_COMPLETED).
  4. Events are enqueued in the singleton RequestQueueHandler (request-handler.ts), which flushes on size (>= 1000, DATA_BATCH_SIZE) or via a 2000ms setInterval that is .unref()'d so it never keeps a worker alive (request-handler.ts:55-69).
  5. Each flush calls batchAndPostEvents('api/v1/batch', …)POST collector-observability.browserstack.com/api/v1/batch with Authorization: Bearer <BROWSERSTACK_TESTHUB_JWT>, headers Content-Type: application/json, X-BSTACK-OBS: true (util.ts:1209-1234).
  6. Screenshots bypass batching — POSTed synchronously to .../api/v1/screenshots via uploadEventData/fetchWrap (testOps/requestUtils.ts:13-51), gated on BS_TESTOPS_ALLOW_SCREENSHOTS.
  7. On worker end, service.after() calls Listener.onWorkerEnd()uploadPending() + teardown()shutdown() drains the queue (request-handler.ts:45-53).

Reviewer note: the batch POST uses bare global fetch (util.ts:1221) and does not respect HTTP(S)_PROXY, whereas the screenshot POST uses fetchWrap which does — same host, different proxy behavior.

(c) Accessibility

  • Gate: _accessibilityAutomation is OR-accumulated from caps (browserstack.accessibility / bstack:options.accessibility) and _options.accessibility (launcher.ts:144-213). A live a11y session additionally requires BSTACK_A11Y_JWT, which is only set from the build-start response (util.ts:545-553, set at util.ts:346).
  • The single build-start POST returns the a11y config; processAccessibilityResponse stores the JWT, scanner version, polling timeout, and injected browser scripts (scan/getResults/getResultsSummary/saveResults) + commandsToWrap + chrome extension into ~/.browserstack/commands.json via AccessibilityScripts.update()+store() (util.ts:321-358; scripts/accessibility-scripts.ts:81-112).
  • Per worker, AccessibilityHandler.before re-validates caps and wraps the configured browser commands so each wrapped command runs an in-browser performScan (accessibility-handler.ts:160-271,426-439). beforeTest/beforeScenario decide scope (autoScanning + include/exclude tags); afterTest/afterScenario run a final scan and saveResults in-browser for web (accessibility-handler.ts:441-462).
  • App accessibility has no separate file — it branches inside accessibility-handler.ts/util.ts (isAppAccessibilityAutomationSession) and polls a REST API: GET app-accessibility.browserstack.com/automate/api/v1/issues and /issues-summary with Bearer BSTACK_A11Y_JWT (util.ts:633-687).

Security note: executeAccessibilityScript injects server-provided script bodies into the browser (string-replacing argumentsbstackSdkArgs, util.ts:1818-1832) — the script source is the build-start API response.

(d) Percy

  • Gate: shouldSetupPercy = options.percy || (options.percy undefined && options.app) (launcher.ts:406).
  • Launcher picks the "best" platform (browser preference chrome<firefox<edge<safari) so only one worker captures; setupPercy downloads/caches the percy CLI binary from github.com/percy/cli/releases (Percy/PercyBinary.ts), fetches a project token from GET api.browserstack.com/api/app_percy/get_project_token, spawns exec:start, and healthchecks http://127.0.0.1:5338 (Percy/Percy.ts:68-166).
  • Worker side: onWorkerStart tags BEST_PLATFORM_CID; the service sets PERCY_SNAPSHOT='true' only on that worker (service.ts:103-105); PercyHandler defers/auto-captures snapshots on DOM-changing commands and per testcase, calling @percy/selenium-webdriver / @percy/appium-app via PercySDK (Percy/Percy-Handler.ts:87-186; Percy/PercySDK.ts).
  • Teardown: worker after() awaits percyHandler.teardown() (polls until counter 0); launcher onComplete runs stopPercy()exec:stop.

Reviewer caveats from the trace: PercyBinary.download rejects with the outer err instead of zipErr (likely latent bug); PercyHandler.teardown never clears its poll interval (interval leak); if no cap deep-equals the best platform, no worker gets BEST_PLATFORM_CID and snapshots silently no-op.

(e) Instrumentation & logging

  • Funnel (instrumentation/funnelInstrumentation.ts): SDKTestAttempted at onPrepare, SDKTestSuccessful at onComplete (the latter carries aggregated worker productUsage and optional pollingTimeout), plus self-healing TCG events — all Basic-auth POSTs to api.browserstack.com/sdk/v1/event with credentials redacted before logging.
  • PerformanceTester (instrumentation/performance/performance-tester.ts): static class using node:perf_hooks; safeMark/safeMeasure swallow Node 18 ERR_PERFORMANCE_INVALID_TIMESTAMP; per-worker measures written to logs/performance-report/*.json and uploaded to eds.browserstack.com/send_sdk_events from the detached cleanup process (so per-worker stopAndGenerate intentionally does not upload).
  • Logging: BStackLogger writes redacted, chalk-formatted lines to logs/bstack-wdio-service.log. logPatcher (monkey-patches console) and logReportingAPI (the exported BStackTestOpsLogger) plus the log4jsAppender all emit the same bs:addLog:<pid> TEST_LOG event consumed by InsightsHandler/Reporter.
  • CrashReporter captures PII-filtered config and POSTs crash reports to collector-observability.browserstack.com/api/v1/analytics.

8. Stage 6 — Teardown

Launcher onComplete (launcher.ts:582, @Measure(SDK_CLEANUP))

  1. isCLIEnabled = BrowserstackCLI.isRunning() (:588); await (isCLIEnabled ? CLI.stop() : stopBuildUpstream()) (:592). CLI.stop calls gRPC stopBinSession then kills the binary child (cli/index.ts:351-398); stopBuildUpstream PUTs {stop_time} to collector-observability.browserstack.com/api/v1/builds/<UUID>/stop with Bearer JWT (util.ts:711-758).
  2. Print build report URL if BROWSERSTACK_OBSERVABILITY && BROWSERSTACK_TESTHUB_UUID (:598-600).
  3. Funnel SDKTestSuccessful via sendFinish (:615).
  4. Upload service logs (_uploadServiceLogs, :618): tars+gzips the SDK log files and POSTs multipart to upload-observability.browserstack.com/client-logs/upload with Basic auth (skips if no creds).
  5. Stop Percy (:638); stop/kill BrowserStack Local — forcedStopprocess.kill(pid), else local.stop raced against a 60s timeout (:651-683).
  6. PerformanceTester.stopAndGenerate('performance-launcher.html') on every exit path.

Worker end

onWorkerEnd is implemented in the service, not the launcher: service.after()Listener.getInstance().onWorkerEnd() flushes pending observability batches (service.ts:508).

Exit handlers (exitHandler.ts)

  • setupExitHandlers() (registered from launcher constructor, launcher.ts:88) installs a single process.on('exit') that (a) kills any running CLI child, then (b) spawns a detached, unref()'d cleanup.js with --observability/--funnelData/--performanceData args (exitHandler.ts:15-78). Because 'exit' can't do async I/O, the network flush (stopBuildUpstream, funnel re-send, EDS perf upload) happens in that child (cleanup.ts:11-41).
  • Separately, Percy registers beforeExit/SIGINT/SIGTERM handlers only for stopPercy (launcher.ts:699-712). There is no SIGINT/SIGTERM handler for the funnel/observability/perf cleanup — a hard SIGKILL would skip it.

9. Endpoints & external calls

Host / endpoint Method / auth Sent Where
api.browserstack.com/sdk/v1/event POST, Basic Funnel SDKTestAttempted/SDKTestSuccessful/TCG events (creds redacted in logs, real on wire) launcher.ts:251,615; funnelInstrumentation.ts:88-96
collector-observability.browserstack.com/api/v2/builds POST, Basic TestHub build start (project/build/git/CI/a11y/product_map); returns JWT/UUID util.ts:426-450
collector-observability.browserstack.com/api/v1/builds/<UUID>/stop PUT, Bearer Build stop {stop_time} (non-CLI) util.ts:735-743
collector-observability.browserstack.com/api/v1/batch POST, Bearer Batched test/hook/log events util.ts:1220-1227
collector-observability.browserstack.com/api/v1/screenshots POST, Bearer Single screenshot event (proxy-aware) testOps/requestUtils.ts:33-51
collector-observability.browserstack.com/api/v1/analytics POST, Basic Crash report (PII-filtered config) crash-reporter.ts:77-89
collector-observability.browserstack.com/testorchestration/api/v1/split-tests POST, Bearer Smart-selection split-tests + poll resultUrl/timeoutUrl test-ordering-server.ts:70-222
{automate | app-automate | automate-turboscale/v1}/sessions/<id>.json PUT/PATCH/GET, Basic Session status/name update + build-link fetch service.ts:735-803
api-cloud.browserstack.com/app-automate/upload POST, Basic App upload (multipart) launcher.ts:746
app-accessibility.browserstack.com/automate/api/v1/issues[-summary] GET, Bearer App a11y result polling util.ts:644-684
api.browserstack.com/api/app_percy/get_project_token GET, Basic Percy project token Percy/Percy.ts:144-150
api.browserstack.com/sdk/v1/update_cli GET, Basic CLI binary version check cliUtils.ts:415-433
github.com/percy/cli/releases/... GET (ETag) Percy CLI binary download Percy/PercyBinary.ts:28-36
<response.url> (zip) GET SDK CLI binary download cliUtils.ts:665-667
upload-observability.browserstack.com/client-logs/upload POST, Basic gzipped tar of SDK logs util.ts:1456-1478
eds.browserstack.com/send_sdk_events POST, no auth Performance metrics (from cleanup child) performance-tester.ts:446-452
local gRPC (insecure) to SDK binary gRPC startBinSession/connectBinSession/stopBinSession/event RPCs/testOrchestration cli/grpcClient.ts:134-599
http://127.0.0.1:5338/percy/healthcheck GET Local Percy daemon healthcheck Percy/Percy.ts:56-66

All BrowserStack host URLs in APIUtils are runtime-mutable: APIUtils.updateURLSForGRR (apiUtils.ts:13-24, called from cli/index.ts:141) can replace them with region/GRR endpoints when the CLI provides region config.

10. File map

File Role
src/index.ts Package entry: default = service, launcher = main-process class, plus log4jsAppender/BStackTestOpsLogger/PercySDK and type re-exports
src/launcher.ts BrowserstackLauncherService — main-process onPrepare/onWorkerStart/onComplete, caps mutation, build/tunnel/Percy/app/a11y orchestration
src/service.ts BrowserstackService — per-worker lifecycle hooks, session status/name REST, handler wiring, command listeners
src/config.ts BrowserStackConfig singleton (central config: creds, products, sdkRunID, build info)
src/types.ts BrowserstackConfig/BrowserstackOptions — the end-user options interface
src/util.ts Shared helpers: credential resolution, build start/stop (launchTestSession/stopBuildUpstream), git metadata, nodeRequest, uploadLogs, batch POST, mergeDeep, isValidEnabledValue
src/constants.ts DEFAULT_OPTIONS, endpoint paths, batch size/interval, valid app extensions, capture modes
src/cleanup.ts ./cleanup entry — detached child that flushes observability stop, funnel data, perf metrics on exit
src/exitHandler.ts setupExitHandlers/shouldCallCleanupprocess.on('exit') + cleanup.js spawn
src/cli/index.ts BrowserstackCLI singleton — bootstrap/start/stop/isRunning, loadModules
src/cli/grpcClient.ts GrpcClient — the only file importing core-package runtime values; all gRPC RPCs
src/cli/cliUtils.ts Binary discovery/version-check/download, checkCLISupportedFrameworks (mocha), getBinConfig, dev-env detection
src/cli/apiUtils.ts APIUtils static endpoint registry + updateURLSForGRR
src/cli/modules/*.ts Per-product gRPC event observers (WebdriverIO/Automate/TestHub/Observability/Accessibility/Percy)
src/insights-handler.ts TestHub/observability capturer (mocha/cucumber) building TestData/LogData
src/reporter.ts TestReporter (WDIOReporter) — jasmine + mocha-skip capturer
src/testOps/listener.ts Listener singleton — gating + telemetry marking, gateway to the queue
src/testOps/request-handler.ts RequestQueueHandler — in-memory queue, size/interval flush, drain on teardown
src/accessibility-handler.ts Per-worker a11y: caps validation, command wrapping, scan scope, web vs app branch
src/scripts/accessibility-scripts.ts AccessibilityScripts singleton — server-provided scan/save scripts persisted to ~/.browserstack/commands.json
src/Percy/Percy.ts / PercyBinary.ts / PercyHelper.ts / Percy-Handler.ts / PercySDK.ts Percy daemon control, binary download, best-platform selection, per-worker capture, @percy/* dispatch
src/testorchestration/* Smart selection: apply-orchestration, handler, TestOrderingServer (HTTPS), OrchestrationUtils
src/instrumentation/funnelInstrumentation.ts Funnel event build/POST + self-healing TCG events
src/instrumentation/performance/performance-tester.ts Perf marks/measures, per-worker JSON, EDS upload
src/bstackLogger.ts / logPatcher.ts / logReportingAPI.ts / log4jsAppender.ts Redacted file logging + bs:addLog TEST_LOG capture bridges
src/crash-reporter.ts PII-filtered crash uploader
src/fetchWrapper.ts Proxy-aware _fetch/fetchWrap (undici ProxyAgent) used by most outbound calls

@AakashHotchandani

Copy link
Copy Markdown
Collaborator Author

How the move to this repo changes the wdio ↔ service path (and what it doesn't)

(This is the structure-focused companion to the runtime hook-by-hook flow I posted above. Here the question is: now that the service is published from this standalone repo instead of bundled inside the WebdriverIO monorepo, how does WebdriverIO actually reach it, and what changed in the wiring? Described for v9 (#37); v8 is identical in shape.)

One-sentence answer

WebdriverIO never talks to the repo. At runtime it loads the published npm package @wdio/browserstack-service, whose name, services entry, exports, and behavior are unchanged. This repo only changes how that package is built, dependency-wired, and published — and it co-locates the gRPC core as a second package in the same monorepo. The consumption contract and the runtime path are byte-for-byte the same.

The structural change in one picture

BEFORE  (bundled in the WebdriverIO monorepo)
  webdriverio/webdriverio  ──packages/wdio-browserstack-service──▶ npm: @wdio/browserstack-service
        │ (workspace deps @wdio/*,webdriverio → pinned at publish, shipped as deps)
        │ released on WebdriverIO's release train
  browserstack/wdio-browserstack-service (OLD, single pkg) ─────▶ npm: @browserstack/wdio-browserstack-service (gRPC core)

AFTER  (this repo — one monorepo, two published packages)
  browserstack/wdio-browserstack-service  (monorepo)
        ├─ packages/browserstack-service ─ esbuild bundle + tsc d.ts ─▶ npm: @wdio/browserstack-service
        │     @wdio/*,webdriverio = peerDependencies (host-provided)
        │     @browserstack/wdio-browserstack-service ^2.0.2 = dependency (the core)
        └─ packages/core ──────────────── buf(protoc) + tsc ──────────▶ npm: @browserstack/wdio-browserstack-service (gRPC core)
        released independently via Changesets + npm OIDC trusted publishing (dist-tags: latest=v9, v8=v8)

The end user's side of the line is identical before and after. Everything that changed is on the supply-chain side (build / deps / publish) plus the service↔core co-location.

Walkthrough: end user → internals, with “what changed” at each layer

Stage 1 — What the user adds & triggers — UNCHANGED

// wdio.conf.js
services: [['browserstack', { /* options */ }]]   // + config.user / config.key, features, capabilities
  • npm i -D @wdio/browserstack-service (the user already has webdriverio + @wdio/cli).
  • Trigger: wdio run.
  • Same package name, same config surface, no migration for users. (index.ts exports are unchanged — see Stage 3.)

Stage 2 — What npm install pulls now — CHANGED: dependency wiring

This is the most concrete difference in a consumer's node_modules:

In the published package.json Before (WebdriverIO-published) After (this repo)
webdriverio, @wdio/logger, @wdio/reporter, @wdio/types dependencies, pinned (e.g. webdriverio: 8.46.0) — the service shipped its own copies peerDependencies (^9 v9 / ^8 v8) — satisfied by the user's wdio install
@browserstack/wdio-browserstack-service (gRPC core) dependency ^2.0.0 (from a separate repo) dependency ^2.0.2 (now co-published from packages/core)
HTTP client etc. got (v8) undici/native fetch (v9), got (v8) — product difference, not a repo difference
  • Effect: the service now uses the host's WebdriverIO (peer) instead of a bundled copy → removes the chance of a duplicate webdriverio in the tree. The gRPC core still arrives automatically as a normal transitive dependency (resolves to latest 2.x).
  • Resulting tree: @wdio/browserstack-service + @browserstack/wdio-browserstack-service (core) + the runtime deps; webdriverio/@wdio/* are shared with the user's own install rather than duplicated.

Stage 3 — How @wdio/cli loads it — UNCHANGED mechanism

  • @wdio/cli resolves services: ['browserstack'] → the npm package @wdio/browserstack-service → its exports["."].import = build/index.js.
  • index.ts:9 export default BrowserstackService (the per-worker service) and index.ts:10 export const launcher = BrowserstackLauncher (the main-process launcher).
  • Only difference: the bytes of build/ now come from this repo's standalone esbuild bundle + tsc types, instead of the monorepo's shared build. The exports map, entry, and class shapes are identical — so @wdio/cli's resolution and instantiation are exactly as before.

Stage 4 — The service ↔ core communication — the part that involves “the new repo’s other package”

This is where the two packages from this monorepo actually interact at runtime:

  • The service imports the core's runtime gRPC primitives — SDKClient, grpcChannel, grpcCredentials (cli/grpcClient.ts, top import block) — and the proto-generated message types (StartBinSessionResponse, TestSessionEventRequest, LogCreatedEventRequest, …) across cli/* and util.ts. The core package is the compiled protobuf/gRPC client.
  • The standalone esbuild build marks the core external (it's a dependency, every dep/peerDep is externalized — scripts/build.mjs), so the service and core stay two separate installed packages on the user's machine; the service requires the core at runtime (not bundled in).
  • When the CLI binary path is active (CLI-supported framework + non-multiremote — launcher.ts:316 checkCLISupportedFrameworks(framework) && !isMultiremote):
    1. The launcher bootstraps a CLI binary (downloaded on demand) and calls startBinSession, which returns a binSessionId + a local listenAddress (cli/grpcClient.ts, stored and also stashed in BROWSERSTACK_CLI_BIN_LISTEN_ADDR / BROWSERSTACK_CLI_BIN_SESSION_ID so workers can reconnect).
    2. GrpcClient.connect() opens an insecure local gRPC channel to that listenAddressnew grpcChannel(listenAddress, grpcCredentials.createInsecure(), { 'grpc.keepalive_time_ms': 10000 }) and new SDKClient(listenAddress, grpcCredentials.createInsecure()) — both from the core package.
    3. So at runtime: service (in-process JS) → core's SDKClient over local insecure gRPC → CLI binary's local gRPC server (separate process) → BrowserStack.
  • What changed structurally: the core is now co-published from the same monorepo as the service rather than from a separate repo. To the consumer it's still just two npm packages installed together, and the wire protocol (service ↔ core ↔ CLI binary ↔ BrowserStack) is unchanged.
  • When the CLI path is not active, the service talks to BrowserStack directly over HTTPS (observability collector + automate session APIs) — also unchanged. (Full hook-level detail in the runtime-flow comment.)

Stage 5 — Which version a user gets / publishing — CHANGED: independent release

  • Before: the service shipped on WebdriverIO's release cadence (it rode the monorepo's release).
  • After: released independently from this repo via Changesets + npm OIDC trusted publishing (no long-lived NPM_TOKEN, with provenance attestation). dist-tags: latest → v9 (9.x), v8 → v8 (8.x). A consumer range like ^9/^8 resolves to the standalone-published version.
  • Effect on the user: npm i @wdio/browserstack-service still just works; they transparently get the standalone-published build. No config or import change.

Side-by-side summary

Layer Before (in WebdriverIO monorepo) After (this repo) Visible to the wdio user?
Package name / services entry / exports @wdio/browserstack-service (default + launcher) same No
Build WebdriverIO shared tsc standalone esbuild bundle + tsc types No
@wdio/* / webdriverio pinned dependencies (bundled copies) peerDependencies (host-provided) Only as a cleaner node_modules
gRPC core separate repo, dep ^2.0.0 co-published packages/core, dep ^2.0.2 No
Service ↔ core wire path service → core gRPC → CLI binary → BrowserStack identical No
Publishing WebdriverIO release train independent Changesets + OIDC + provenance; dist-tags latest/v8 Only “versions ship independently now”
Runtime hooks / endpoints identical No

Bottom line

wdio doesn't communicate with the repo — it consumes the published npm package, exactly as before. This repo changes the supply chain (standalone build, peer-dep wiring, independent OIDC publishing) and co-locates the gRPC core as a second package. The consumption contract (services: ['browserstack'], exports, options) and the runtime path (service → gRPC core → CLI binary → BrowserStack, or the direct-HTTPS fallback) are unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants