feat: monorepo conversion — add @wdio/browserstack-service alongside the gRPC core#37
feat: monorepo conversion — add @wdio/browserstack-service alongside the gRPC core#37AakashHotchandani wants to merge 8 commits into
Conversation
…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>
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>
kamal-kaur04
left a comment
There was a problem hiding this comment.
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.
| run: npm test | ||
|
|
||
| - name: Create Release PR or publish to npm | ||
| uses: changesets/action@v1 |
There was a problem hiding this comment.
⚠️ 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.xnpm 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.
There was a problem hiding this comment.
Fixed in 47a1c8f. Pinned all three to full commit SHAs with version comments:
actions/checkout@34e1148…# v4actions/setup-node@49933ea…# v4changesets/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.)
| { | ||
| "name": "@browserstack/wdio-browserstack-service", | ||
| "version": "2.0.2", | ||
| "description": "WebdriverIO service for better Browserstack integration", |
There was a problem hiding this comment.
💡 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.
There was a problem hiding this comment.
| "clean": "rm -rf dist src/generated", | ||
| "generate": "buf generate", | ||
| "build": "npm run clean && npm run generate && tsc", | ||
| "prepare": "npm run build", |
There was a problem hiding this comment.
💡 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.
There was a problem hiding this comment.
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.
| @@ -0,0 +1,61 @@ | |||
| # Migration plan — taking over `@wdio/browserstack-service` | |||
There was a problem hiding this comment.
💡 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.
There was a problem hiding this comment.
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>
What's a verbatim move vs. what actually changedTo separate "moved code" from "real changes", I diffed
A. Source code — 3 files, all Node-18 / standalone-process compatibility
B. Tests — 2 files, identical one-line fixScope C. Packaging & build — required because it's no longer in the monorepo
New build/test config (none existed standalone in upstream):
D. New repo infrastructure (a new standalone repo, not "the service")
Reproduce the move/no-move split: diff this |
| "description": "WebdriverIO service for better Browserstack integration", | ||
| "author": "Browserstack", | ||
| "homepage": "https://github.com/browserstack/wdio-browserstack-service", | ||
| "name": "wdio-browserstack-service-monorepo", |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| # 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. |
There was a problem hiding this comment.
Do we need to handle publishing via GitHub CI? It'd be better to use minion for this if feasible.
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
@AakashHotchandani
We'd need EM Approval for this. None of the other SDKs are published to npm using Github Actions.
| cache: 'npm' | ||
|
|
||
| - name: Install | ||
| run: npm ci |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Small correction, if we re-use pnpm used in WDIO v9, then it'd be changed to pnpm ci here for v9 as well
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
| @@ -0,0 +1,11 @@ | |||
| { | |||
| "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", | |||
There was a problem hiding this comment.
Could we re-use the existing yml file-based approach for this repo as well for maintaining consistency across the SDK repositories.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Sure, happy to discuss this over a call.
| @@ -1,119 +1,51 @@ | |||
| # @browserstack/wdio-browserstack-service | |||
| # wdio-browserstack-service | |||
There was a problem hiding this comment.
This needs to be reviewed by @browserstack/sdk-product
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
| @@ -0,0 +1,95 @@ | |||
| { | |||
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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>
| @@ -1,29 +1,100 @@ | |||
| { | |||
| "name": "@browserstack/wdio-browserstack-service", | |||
| "version": "2.0.2", | |||
| "name": "wdio-browserstack-service-monorepo", | |||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
End-to-end execution flow: from your wdio.conf to BrowserStack1. TL;DRThe end user adds a single line to their 2. End-to-end diagram3. Stage 1 — What the end user adds & triggersThe wdio.conf services entryservices: [['browserstack', { /* BrowserstackConfig */ }]]
Credentials (user / key)
Key feature options (read from the options object)
Capabilities surfacePer-session settings come through capabilities, read by the launcher constructor (
Env gates (selected)
4. Stage 2 — How WebdriverIO loads itTwo entry points, two processes
Object graph
5. Stage 3 — Launcher
|
| # | 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(orPATCHfor turboScale) to${_sessionBaseUrl}/${sessionId}.jsonwith Basic auth; body{status, name?, reason?}._sessionBaseUrlisautomate(service.ts:45),app-automate(:210), orautomate-turboscale/v1(:214)._printSessionURL(service.ts:762-803):GETthe same URL to readautomation_session.browser_url(or.urlfor turboScale) and log the session link.- In-session command channel (not REST):
_executeCommand('annotate', …)runsbrowser.executeScript('browserstack_executor: {…}')(service.ts:841-861).
No
afterSuiteand no namedafterCommand/beforeCommandexist in this class; command capture is purely the event listeners above. The legacy synchronoussetSessionStatusblock atservice.ts:442-450is commented out — the live status update is themeasureWrapperone (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:
- Per-worker, WDIO hooks drive two capturers:
InsightsHandler(mocha/cucumber —insights-handler.ts:385-426) andTestReporter(aWDIOReporter; jasmine + mocha skips only —reporter.ts:130-240). Both build a normalizedTestData/LogData/CBTDatapayload. - Logs originate from a winston transport (
logReportingAPI.ts:31-42) andlogPatcherthat emit an in-processbs:addLog:<pid>event consumed by the capturers (insights-handler.ts:94-98;reporter.ts:60-64). - Each event passes through
Listener(testOps/listener.ts), gated byshouldProcessEventForTesthub()(envBROWSERSTACK_OBSERVABILITY/ACCESSIBILITY/PERCY—testHub/utils.ts:27-38) andshouldSendEvents()=isTrue(BS_TESTOPS_BUILD_COMPLETED). - Events are enqueued in the singleton
RequestQueueHandler(request-handler.ts), which flushes on size (>= 1000,DATA_BATCH_SIZE) or via a2000mssetIntervalthat is.unref()'d so it never keeps a worker alive (request-handler.ts:55-69). - Each flush calls
batchAndPostEvents('api/v1/batch', …)→ POSTcollector-observability.browserstack.com/api/v1/batchwithAuthorization: Bearer <BROWSERSTACK_TESTHUB_JWT>, headersContent-Type: application/json,X-BSTACK-OBS: true(util.ts:1209-1234). - Screenshots bypass batching — POSTed synchronously to
.../api/v1/screenshotsviauploadEventData/fetchWrap(testOps/requestUtils.ts:13-51), gated onBS_TESTOPS_ALLOW_SCREENSHOTS. - On worker end,
service.after()callsListener.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 respectHTTP(S)_PROXY, whereas the screenshot POST usesfetchWrapwhich does — same host, different proxy behavior.
(c) Accessibility
- Gate:
_accessibilityAutomationis OR-accumulated from caps (browserstack.accessibility/bstack:options.accessibility) and_options.accessibility(launcher.ts:144-213). A live a11y session additionally requiresBSTACK_A11Y_JWT, which is only set from the build-start response (util.ts:545-553, set atutil.ts:346). - The single build-start POST returns the a11y config;
processAccessibilityResponsestores the JWT, scanner version, polling timeout, and injected browser scripts (scan/getResults/getResultsSummary/saveResults) +commandsToWrap+ chrome extension into~/.browserstack/commands.jsonviaAccessibilityScripts.update()+store()(util.ts:321-358;scripts/accessibility-scripts.ts:81-112). - Per worker,
AccessibilityHandler.beforere-validates caps and wraps the configured browser commands so each wrapped command runs an in-browserperformScan(accessibility-handler.ts:160-271,426-439).beforeTest/beforeScenariodecide scope (autoScanning + include/exclude tags);afterTest/afterScenariorun a final scan andsaveResultsin-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/issuesand/issues-summarywithBearer BSTACK_A11Y_JWT(util.ts:633-687).
Security note:
executeAccessibilityScriptinjects server-provided script bodies into the browser (string-replacingarguments→bstackSdkArgs,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;
setupPercydownloads/caches thepercyCLI binary fromgithub.com/percy/cli/releases(Percy/PercyBinary.ts), fetches a project token fromGET api.browserstack.com/api/app_percy/get_project_token, spawnsexec:start, and healthcheckshttp://127.0.0.1:5338(Percy/Percy.ts:68-166). - Worker side:
onWorkerStarttagsBEST_PLATFORM_CID; the service setsPERCY_SNAPSHOT='true'only on that worker (service.ts:103-105);PercyHandlerdefers/auto-captures snapshots on DOM-changing commands and per testcase, calling@percy/selenium-webdriver/@percy/appium-appviaPercySDK(Percy/Percy-Handler.ts:87-186;Percy/PercySDK.ts). - Teardown: worker
after()awaitspercyHandler.teardown()(polls until counter 0); launcheronCompleterunsstopPercy()→exec:stop.
Reviewer caveats from the trace:
PercyBinary.downloadrejects with the outererrinstead ofzipErr(likely latent bug);PercyHandler.teardownnever clears its poll interval (interval leak); if no cap deep-equals the best platform, no worker getsBEST_PLATFORM_CIDand snapshots silently no-op.
(e) Instrumentation & logging
- Funnel (
instrumentation/funnelInstrumentation.ts):SDKTestAttemptedatonPrepare,SDKTestSuccessfulatonComplete(the latter carries aggregated workerproductUsageand optionalpollingTimeout), plus self-healing TCG events — all Basic-auth POSTs toapi.browserstack.com/sdk/v1/eventwith credentials redacted before logging. - PerformanceTester (
instrumentation/performance/performance-tester.ts): static class usingnode:perf_hooks;safeMark/safeMeasureswallow Node 18ERR_PERFORMANCE_INVALID_TIMESTAMP; per-worker measures written tologs/performance-report/*.jsonand uploaded toeds.browserstack.com/send_sdk_eventsfrom the detached cleanup process (so per-workerstopAndGenerateintentionally does not upload). - Logging:
BStackLoggerwrites redacted, chalk-formatted lines tologs/bstack-wdio-service.log.logPatcher(monkey-patches console) andlogReportingAPI(the exportedBStackTestOpsLogger) plus thelog4jsAppenderall emit the samebs: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))
isCLIEnabled = BrowserstackCLI.isRunning()(:588);await (isCLIEnabled ? CLI.stop() : stopBuildUpstream())(:592).CLI.stopcalls gRPCstopBinSessionthen kills the binary child (cli/index.ts:351-398);stopBuildUpstreamPUTs{stop_time}tocollector-observability.browserstack.com/api/v1/builds/<UUID>/stopwith Bearer JWT (util.ts:711-758).- Print build report URL if
BROWSERSTACK_OBSERVABILITY && BROWSERSTACK_TESTHUB_UUID(:598-600). - Funnel
SDKTestSuccessfulviasendFinish(:615). - Upload service logs (
_uploadServiceLogs,:618): tars+gzips the SDK log files and POSTs multipart toupload-observability.browserstack.com/client-logs/uploadwith Basic auth (skips if no creds). - Stop Percy (
:638); stop/kill BrowserStack Local —forcedStop→process.kill(pid), elselocal.stopraced against a 60s timeout (:651-683). 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 singleprocess.on('exit')that (a) kills any running CLI child, then (b) spawns a detached,unref()'dcleanup.jswith--observability/--funnelData/--performanceDataargs (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/SIGTERMhandlers only forstopPercy(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
APIUtilsare runtime-mutable:APIUtils.updateURLSForGRR(apiUtils.ts:13-24, called fromcli/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/shouldCallCleanup — process.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 |
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 answerWebdriverIO never talks to the repo. At runtime it loads the published npm package The structural change in one pictureThe 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 layerStage 1 — What the user adds & triggers — UNCHANGED// wdio.conf.js
services: [['browserstack', { /* options */ }]] // + config.user / config.key, features, capabilities
Stage 2 — What
|
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
webdriverioin the tree. The gRPC core still arrives automatically as a normal transitivedependency(resolves to latest2.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/cliresolvesservices: ['browserstack']→ the npm package@wdio/browserstack-service→ itsexports["."].import=build/index.js.index.ts:9export default BrowserstackService(the per-worker service) andindex.ts:10export const launcher = BrowserstackLauncher(the main-process launcher).- Only difference: the bytes of
build/now come from this repo's standalone esbuild bundle +tsctypes, instead of the monorepo's shared build. Theexportsmap, 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, …) acrosscli/*andutil.ts. The core package is the compiled protobuf/gRPC client. - The standalone esbuild build marks the core
external(it's adependency, every dep/peerDep is externalized —scripts/build.mjs), so the service and core stay two separate installed packages on the user's machine; the servicerequires the core at runtime (not bundled in). - When the CLI binary path is active (CLI-supported framework + non-multiremote —
launcher.ts:316checkCLISupportedFrameworks(framework) && !isMultiremote):- The launcher bootstraps a CLI binary (downloaded on demand) and calls
startBinSession, which returns abinSessionId+ a locallistenAddress(cli/grpcClient.ts, stored and also stashed inBROWSERSTACK_CLI_BIN_LISTEN_ADDR/BROWSERSTACK_CLI_BIN_SESSION_IDso workers can reconnect). GrpcClient.connect()opens an insecure local gRPC channel to thatlistenAddress—new grpcChannel(listenAddress, grpcCredentials.createInsecure(), { 'grpc.keepalive_time_ms': 10000 })andnew SDKClient(listenAddress, grpcCredentials.createInsecure())— both from the core package.- So at runtime: service (in-process JS) → core's
SDKClientover local insecure gRPC → CLI binary's local gRPC server (separate process) → BrowserStack.
- The launcher bootstraps a CLI binary (downloaded on demand) and calls
- 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/^8resolves to the standalone-published version. - Effect on the user:
npm i @wdio/browserstack-servicestill 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.
What
Converts this repo into an npm workspace and adds the WebdriverIO service so BrowserStack can publish
@wdio/browserstack-servicefrom 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, sameservices: ['browserstack'].Layout
packages/core@browserstack/wdio-browserstack-servicegit mv(history preserved); build & behavior unchangedpackages/browserstack-service@wdio/browserstack-serviceThe core (owned by
@browserstack/sdk-dev) is behavior-unchangedpackages/core/;buf generate+tscbuild and thefilesallowlist are unchanged.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)
.github/workflows/release.yml) — no long-lived token, provenance-signed.main→latest(v9); a futurev8branch →v8dist-tag (via per-branchpublishConfig.tag; the workflow hardcodes no tag).latestis currently9.28.0).Test-infra fix
The
@wdio/reportermock imported the real module from inside its own mock (a top-levelvi.importActual+ a static import), which deadlocked vitest and hungreporter.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 test→ 39 files / 980 tests pass, clean exit ✅npm pack→ clean 80-file / ~590 kB tarball (onlybuild/ + README + LICENSE + ambient d.ts; no docs/logs/maps) ✅services: ['browserstack']resolved, session created, spec green,webdriveriodeduped to a single copy ✅To go live (after this merges)
@wdionpm org admin sets a Trusted Publisher on@wdio/browserstack-service: Orgbrowserstack, Repowdio-browserstack-service, Workflowrelease.yml, Environment empty.9.29.0).🤖 Generated with Claude Code