From 157ec47287e147b5ebfb4a636d12ab662b283546 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 20:13:41 +0000 Subject: [PATCH 1/7] ci: report test results to BrowserStack TRA via our own SDK Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WUdwzfDSUhTEqqEWDhi6tB --- .github/workflows/main.yml | 18 ++++++++-- .gitignore | 1 + scripts/ci-report/ci-report.mjs | 49 +++++++++++++++++++++++++ scripts/ci-report/ci-report.test.ts | 55 +++++++++++++++++++++++++++++ vitest.config.ts | 2 ++ 5 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 scripts/ci-report/ci-report.mjs create mode 100644 scripts/ci-report/ci-report.test.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7c7b327..de081b8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -254,7 +254,14 @@ jobs: chmod +x packages/cli/golang/dist/browserstack-client-linux-* - name: Test - run: pnpm test + run: pnpm test --reporter=default --reporter=junit --outputFile.junit=junit.xml + + # Dogfooding: report this run's results to BrowserStack Test Reporting & Analytics via our own SDK. + # Best-effort only; never affects the job result. + - name: Report results to BrowserStack TRA + if: always() + continue-on-error: true + run: node scripts/ci-report/ci-report.mjs junit.xml linux # ── 2b. CLI binary tests on macOS and Windows ──────────────────────────────── test-cli: @@ -301,7 +308,14 @@ jobs: chmod +x packages/cli/golang/dist/browserstack-client-darwin-* - name: Test CLI - run: pnpm test -- --project cli -t "LocalCLI" + run: pnpm test -- --project cli -t "LocalCLI" --reporter=default --reporter=junit --outputFile.junit=junit.xml + + # Dogfooding: report this run's results to BrowserStack Test Reporting & Analytics via our own SDK. + # Best-effort only; never affects the job result. + - name: Report results to BrowserStack TRA + if: always() + continue-on-error: true + run: node scripts/ci-report/ci-report.mjs junit.xml ${{ matrix.os }} # ── 3. Publish docs (main branch only) ─────────────────────────────────────── publish-docs: diff --git a/.gitignore b/.gitignore index 5f32427..8da55fe 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,4 @@ docs/superpowers/ *.sw? sonarqube-reports/ .antigravitycli/ +junit.xml diff --git a/scripts/ci-report/ci-report.mjs b/scripts/ci-report/ci-report.mjs new file mode 100644 index 0000000..e1e2bfa --- /dev/null +++ b/scripts/ci-report/ci-report.mjs @@ -0,0 +1,49 @@ +// Uploads the vitest JUnit report of a CI run to BrowserStack Test Reporting & Analytics using our own +// TestReportingClient (dogfooding). Reporting is best-effort: it never throws and the CLI always exits 0, +// so an unreachable BrowserStack can't fail the build. +import { readFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; + +export function buildUploadParams(env, label) { + const repo = env.GITHUB_REPOSITORY; + const branch = env.GITHUB_REF_NAME; + const sha = env.GITHUB_SHA; + const runId = env.GITHUB_RUN_ID; + const params = { + projectName: repo ?? "browserstack-client", + buildName: runId ? `${env.GITHUB_WORKFLOW ?? "CI"} #${env.GITHUB_RUN_NUMBER} (${branch})` : `local ${new Date().toISOString()}`, + tags: ["ci", label, branch].filter(Boolean).join(","), + format: "junit", + }; + if (runId) { + params.buildIdentifier = `${runId}-${env.GITHUB_RUN_ATTEMPT ?? "1"}`; + params.ci = `${env.GITHUB_SERVER_URL ?? "https://github.com"}/${repo}/actions/runs/${runId}`; + } + if (sha) params.versionControl = { sha, shortSha: sha.slice(0, 7), branch }; + return params; +} + +export async function reportJUnit({ client, file, env, label, read = async (f) => new Blob([await readFile(f)], { type: "text/xml" }) }) { + try { + const blob = await read(file); + await client.uploadReport({ ...buildUploadParams(env, label), file: blob, fileName: file.split(/[\\/]/).pop() }); + return { ok: true }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } +} + +async function main() { + const [file = "junit.xml", label = "ci"] = process.argv.slice(2); + if (!process.env.BROWSERSTACK_USERNAME || !(process.env.BROWSERSTACK_ACCESS_KEY || process.env.BROWSERSTACK_KEY)) { + console.log("ci-report: BrowserStack credentials not available (fork PR?); skipping."); + return; + } + const { TestReportingClient } = await import("../../packages/test-reporting/dist/index.js"); + const res = await reportJUnit({ client: new TestReportingClient(), file, env: process.env, label }); + console.log(res.ok ? `ci-report: uploaded ${file} to BrowserStack TRA` : `ci-report: upload failed (ignored): ${res.error}`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((e) => console.log(`ci-report: failed (ignored): ${e}`)); +} diff --git a/scripts/ci-report/ci-report.test.ts b/scripts/ci-report/ci-report.test.ts new file mode 100644 index 0000000..796d9d4 --- /dev/null +++ b/scripts/ci-report/ci-report.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildUploadParams, reportJUnit } from "./ci-report.mjs"; + +const env = { + GITHUB_REPOSITORY: "shirish87/browserstack-client", + GITHUB_RUN_ID: "123", + GITHUB_RUN_NUMBER: "45", + GITHUB_RUN_ATTEMPT: "2", + GITHUB_REF_NAME: "main", + GITHUB_SHA: "abcdef1234567890", + GITHUB_SERVER_URL: "https://github.com", + GITHUB_WORKFLOW: "CI", +}; + +describe("buildUploadParams", () => { + it("derives project, build name, identifier, CI url and VCS metadata from the GitHub env", () => { + const p = buildUploadParams(env, "linux"); + expect(p.projectName).toBe("shirish87/browserstack-client"); + expect(p.buildName).toBe("CI #45 (main)"); + expect(p.buildIdentifier).toBe("123-2"); + expect(p.ci).toBe("https://github.com/shirish87/browserstack-client/actions/runs/123"); + expect(p.tags).toBe("ci,linux,main"); + expect(p.versionControl).toMatchObject({ sha: "abcdef1234567890", shortSha: "abcdef1", branch: "main" }); + }); + + it("falls back to local defaults outside GitHub Actions", () => { + const p = buildUploadParams({}, "local"); + expect(p.projectName).toBe("browserstack-client"); + expect(p.buildName).toMatch(/^local /); + }); +}); + +describe("reportJUnit", () => { + const read = async () => new Blob([""]); + + it("uploads the junit file and reports success", async () => { + const uploadReport = vi.fn(async () => ({ status: "success" })); + const res = await reportJUnit({ client: { uploadReport }, file: "junit.xml", env, label: "linux", read }); + expect(res.ok).toBe(true); + expect(uploadReport).toHaveBeenCalledWith(expect.objectContaining({ fileName: "junit.xml", format: "junit", projectName: "shirish87/browserstack-client" })); + }); + + it("never throws when the upload fails", async () => { + const uploadReport = vi.fn(async () => { throw new Error("BrowserStack unreachable"); }); + const res = await reportJUnit({ client: { uploadReport }, file: "junit.xml", env, label: "linux", read }); + expect(res).toEqual({ ok: false, error: "BrowserStack unreachable" }); + }); + + it("never throws when the report file is missing", async () => { + const uploadReport = vi.fn(); + const res = await reportJUnit({ client: { uploadReport }, file: "nope.xml", env, label: "linux", read: async () => { throw new Error("ENOENT"); } }); + expect(res.ok).toBe(false); + expect(uploadReport).not.toHaveBeenCalled(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 7063929..11a6d7f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -85,6 +85,8 @@ export default defineConfig({ project("test-reporting", "./packages/test-reporting", "packages/test-reporting/package.json"), project("sdk", "./packages/sdk", "packages/sdk/package.json"), project("router", "./packages/router", "packages/router/package.json"), + // CI reporting script (uploads vitest JUnit results to Test Reporting & Analytics). + { test: { name: "ci-report", root: "./scripts/ci-report", globals: true, environment: "node" } }, // Offline checks of the contract tooling (sanitiser, fixtures vs generated models). { ...project("contract-tests", "./packages/contract-tests", "packages/contract-tests/package.json"), From 71a9a9c4c501d1b853859f99aaef2d65ae045124 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 20:17:33 +0000 Subject: [PATCH 2/7] ci: stream test results live to BrowserStack TRA with a vitest reporter Replaces the JUnit upload. Reports CI/PR/commit info and per-test errors and stack traces. Note: builds/start currently returns 400 against the live API. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WUdwzfDSUhTEqqEWDhi6tB --- .github/workflows/main.yml | 16 +-- .gitignore | 1 - scripts/ci-report/ci-report.mjs | 49 -------- scripts/ci-report/ci-report.test.ts | 55 --------- scripts/ci-report/tra-reporter.mjs | 152 +++++++++++++++++++++++++ scripts/ci-report/tra-reporter.test.ts | 147 ++++++++++++++++++++++++ 6 files changed, 307 insertions(+), 113 deletions(-) delete mode 100644 scripts/ci-report/ci-report.mjs delete mode 100644 scripts/ci-report/ci-report.test.ts create mode 100644 scripts/ci-report/tra-reporter.mjs create mode 100644 scripts/ci-report/tra-reporter.test.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index de081b8..739a451 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -253,15 +253,13 @@ jobs: chmod +x packages/cli/typescript/dist-binary/browserstack-client-ts-linux-* chmod +x packages/cli/golang/dist/browserstack-client-linux-* + # Dogfooding: results stream live to BrowserStack Test Reporting & Analytics via our own SDK + # (see scripts/ci-report/tra-reporter.mjs); reporting errors never fail the job. - name: Test - run: pnpm test --reporter=default --reporter=junit --outputFile.junit=junit.xml + env: + TRA_LABEL: linux + run: pnpm test --reporter=default --reporter=./scripts/ci-report/tra-reporter.mjs - # Dogfooding: report this run's results to BrowserStack Test Reporting & Analytics via our own SDK. - # Best-effort only; never affects the job result. - - name: Report results to BrowserStack TRA - if: always() - continue-on-error: true - run: node scripts/ci-report/ci-report.mjs junit.xml linux # ── 2b. CLI binary tests on macOS and Windows ──────────────────────────────── test-cli: @@ -308,7 +306,9 @@ jobs: chmod +x packages/cli/golang/dist/browserstack-client-darwin-* - name: Test CLI - run: pnpm test -- --project cli -t "LocalCLI" --reporter=default --reporter=junit --outputFile.junit=junit.xml + env: + TRA_LABEL: ${{ matrix.os }} + run: pnpm test -- --project cli -t "LocalCLI" --reporter=default --reporter=./scripts/ci-report/tra-reporter.mjs # Dogfooding: report this run's results to BrowserStack Test Reporting & Analytics via our own SDK. # Best-effort only; never affects the job result. diff --git a/.gitignore b/.gitignore index 8da55fe..5f32427 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,3 @@ docs/superpowers/ *.sw? sonarqube-reports/ .antigravitycli/ -junit.xml diff --git a/scripts/ci-report/ci-report.mjs b/scripts/ci-report/ci-report.mjs deleted file mode 100644 index e1e2bfa..0000000 --- a/scripts/ci-report/ci-report.mjs +++ /dev/null @@ -1,49 +0,0 @@ -// Uploads the vitest JUnit report of a CI run to BrowserStack Test Reporting & Analytics using our own -// TestReportingClient (dogfooding). Reporting is best-effort: it never throws and the CLI always exits 0, -// so an unreachable BrowserStack can't fail the build. -import { readFile } from "node:fs/promises"; -import { pathToFileURL } from "node:url"; - -export function buildUploadParams(env, label) { - const repo = env.GITHUB_REPOSITORY; - const branch = env.GITHUB_REF_NAME; - const sha = env.GITHUB_SHA; - const runId = env.GITHUB_RUN_ID; - const params = { - projectName: repo ?? "browserstack-client", - buildName: runId ? `${env.GITHUB_WORKFLOW ?? "CI"} #${env.GITHUB_RUN_NUMBER} (${branch})` : `local ${new Date().toISOString()}`, - tags: ["ci", label, branch].filter(Boolean).join(","), - format: "junit", - }; - if (runId) { - params.buildIdentifier = `${runId}-${env.GITHUB_RUN_ATTEMPT ?? "1"}`; - params.ci = `${env.GITHUB_SERVER_URL ?? "https://github.com"}/${repo}/actions/runs/${runId}`; - } - if (sha) params.versionControl = { sha, shortSha: sha.slice(0, 7), branch }; - return params; -} - -export async function reportJUnit({ client, file, env, label, read = async (f) => new Blob([await readFile(f)], { type: "text/xml" }) }) { - try { - const blob = await read(file); - await client.uploadReport({ ...buildUploadParams(env, label), file: blob, fileName: file.split(/[\\/]/).pop() }); - return { ok: true }; - } catch (e) { - return { ok: false, error: e instanceof Error ? e.message : String(e) }; - } -} - -async function main() { - const [file = "junit.xml", label = "ci"] = process.argv.slice(2); - if (!process.env.BROWSERSTACK_USERNAME || !(process.env.BROWSERSTACK_ACCESS_KEY || process.env.BROWSERSTACK_KEY)) { - console.log("ci-report: BrowserStack credentials not available (fork PR?); skipping."); - return; - } - const { TestReportingClient } = await import("../../packages/test-reporting/dist/index.js"); - const res = await reportJUnit({ client: new TestReportingClient(), file, env: process.env, label }); - console.log(res.ok ? `ci-report: uploaded ${file} to BrowserStack TRA` : `ci-report: upload failed (ignored): ${res.error}`); -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main().catch((e) => console.log(`ci-report: failed (ignored): ${e}`)); -} diff --git a/scripts/ci-report/ci-report.test.ts b/scripts/ci-report/ci-report.test.ts deleted file mode 100644 index 796d9d4..0000000 --- a/scripts/ci-report/ci-report.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { buildUploadParams, reportJUnit } from "./ci-report.mjs"; - -const env = { - GITHUB_REPOSITORY: "shirish87/browserstack-client", - GITHUB_RUN_ID: "123", - GITHUB_RUN_NUMBER: "45", - GITHUB_RUN_ATTEMPT: "2", - GITHUB_REF_NAME: "main", - GITHUB_SHA: "abcdef1234567890", - GITHUB_SERVER_URL: "https://github.com", - GITHUB_WORKFLOW: "CI", -}; - -describe("buildUploadParams", () => { - it("derives project, build name, identifier, CI url and VCS metadata from the GitHub env", () => { - const p = buildUploadParams(env, "linux"); - expect(p.projectName).toBe("shirish87/browserstack-client"); - expect(p.buildName).toBe("CI #45 (main)"); - expect(p.buildIdentifier).toBe("123-2"); - expect(p.ci).toBe("https://github.com/shirish87/browserstack-client/actions/runs/123"); - expect(p.tags).toBe("ci,linux,main"); - expect(p.versionControl).toMatchObject({ sha: "abcdef1234567890", shortSha: "abcdef1", branch: "main" }); - }); - - it("falls back to local defaults outside GitHub Actions", () => { - const p = buildUploadParams({}, "local"); - expect(p.projectName).toBe("browserstack-client"); - expect(p.buildName).toMatch(/^local /); - }); -}); - -describe("reportJUnit", () => { - const read = async () => new Blob([""]); - - it("uploads the junit file and reports success", async () => { - const uploadReport = vi.fn(async () => ({ status: "success" })); - const res = await reportJUnit({ client: { uploadReport }, file: "junit.xml", env, label: "linux", read }); - expect(res.ok).toBe(true); - expect(uploadReport).toHaveBeenCalledWith(expect.objectContaining({ fileName: "junit.xml", format: "junit", projectName: "shirish87/browserstack-client" })); - }); - - it("never throws when the upload fails", async () => { - const uploadReport = vi.fn(async () => { throw new Error("BrowserStack unreachable"); }); - const res = await reportJUnit({ client: { uploadReport }, file: "junit.xml", env, label: "linux", read }); - expect(res).toEqual({ ok: false, error: "BrowserStack unreachable" }); - }); - - it("never throws when the report file is missing", async () => { - const uploadReport = vi.fn(); - const res = await reportJUnit({ client: { uploadReport }, file: "nope.xml", env, label: "linux", read: async () => { throw new Error("ENOENT"); } }); - expect(res.ok).toBe(false); - expect(uploadReport).not.toHaveBeenCalled(); - }); -}); diff --git a/scripts/ci-report/tra-reporter.mjs b/scripts/ci-report/tra-reporter.mjs new file mode 100644 index 0000000..5192109 --- /dev/null +++ b/scripts/ci-report/tra-reporter.mjs @@ -0,0 +1,152 @@ +// Vitest reporter that streams this repo's CI test results to BrowserStack Test Reporting & Analytics, live, +// through our own TestReportingClient (dogfooding): the build starts with the run, each test is started when it +// is ready and finished when it has a result, and the build is finished at the end. +// Best-effort: every API error is logged and swallowed, so an unreachable BrowserStack never fails CI. +import { relative } from "node:path"; +import os from "node:os"; +import { readFileSync } from "node:fs"; + +const warn = (what, e) => console.warn(`[tra] ${what} failed (ignored): ${e instanceof Error ? e.message : e}`); +const iso = () => new Date().toISOString(); + +export class TraReporter { + constructor({ client, env = process.env, cwd = process.cwd(), event } = {}) { + this.client = client; + this.env = env; + this.cwd = cwd; + // The GitHub Actions event payload (push / pull_request) has the commit and PR details env vars lack. + this.event = event ?? (env.GITHUB_EVENT_PATH ? JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf-8")) : {}); + this.enabled = Boolean(client); + this.build = undefined; // Promise (buildHashedId) + this.tests = new Map(); // test id -> Promise<{ buildId, uuid } | undefined> + } + + static async fromEnv(env = process.env) { + const username = env.BROWSERSTACK_USERNAME; + const accessKey = env.BROWSERSTACK_ACCESS_KEY || env.BROWSERSTACK_KEY; + if (!username || !accessKey) return new TraReporter({ env }); + const { TestReportingClient } = await import("../../packages/test-reporting/dist/index.js"); + return new TraReporter({ client: new TestReportingClient({ username, accessKey }), env }); + } + + buildRequest() { + const e = this.env; + const ev = this.event; + const pr = ev.pull_request; + const commit = ev.head_commit; + const repo = e.GITHUB_REPOSITORY; + const runId = e.GITHUB_RUN_ID; + const label = e.TRA_LABEL; + // On pull_request runs GITHUB_SHA / GITHUB_REF_NAME describe the synthetic merge commit; report the PR head. + const branch = pr?.head?.ref ?? e.GITHUB_HEAD_REF ?? e.GITHUB_REF_NAME; + const sha = pr?.head?.sha ?? e.GITHUB_SHA; + const server = e.GITHUB_SERVER_URL ?? "https://github.com"; + const req = { + name: runId ? `${e.GITHUB_WORKFLOW ?? "CI"} #${e.GITHUB_RUN_NUMBER} (${branch})${label ? ` [${label}]` : ""}` : `local ${iso()}`, + projectName: repo ?? "browserstack-client", + startedAt: iso(), + tags: [...new Set(["ci", label, branch, e.GITHUB_EVENT_NAME].filter(Boolean))], + framework: { name: "vitest", version: this.vitestVersion ?? "unknown" }, + hostInfo: { hostname: os.hostname(), platform: os.platform(), arch: os.arch(), version: os.release(), type: e.RUNNER_OS }, + }; + if (runId) { + req.buildRunIdentifier = [runId, e.GITHUB_RUN_ATTEMPT ?? "1", label].filter(Boolean).join("-"); + req.ciInfo = { + name: "GitHub Actions", + buildUrl: `${server}/${repo}/actions/runs/${runId}`, + url: pr?.html_url ?? `${server}/${repo}/commit/${sha}`, + buildNumber: e.GITHUB_RUN_NUMBER, + jobName: e.GITHUB_JOB, + }; + } + if (sha) { + req.versionControl = { + name: "git", + sha, + branch, + commitMessage: (pr?.title ?? commit?.message)?.split("\n")[0], + committerName: commit?.committer?.name ?? e.GITHUB_ACTOR, + committerEmail: commit?.committer?.email, + }; + } + return req; + } + + async onTestRunStart() { + if (!this.enabled) return; + this.build = this.client + .startBuild(this.buildRequest()) + .then((res) => res?.buildHashedId ?? res?.build_hashed_id) + .catch((e) => (warn("startBuild", e), undefined)); + await this.build; + } + + async onTestCaseReady(testCase) { + if (!this.enabled) return; + const started = (async () => { + const buildId = await this.build; + if (!buildId) return undefined; + try { + const parts = testCase.fullName.split(" > "); + const res = await this.client.startTestRun(buildId, { + name: testCase.name, + fileName: relative(this.cwd, testCase.module.moduleId).replaceAll("\\", "/"), + scopes: [testCase.project.name, ...parts.slice(0, -1)].slice(0, 20), + startedAt: iso(), + ...(testCase.location ? { location: `${testCase.location.line}:${testCase.location.column}` } : {}), + }); + const uuid = res?.uuid ?? res?.testRunId; + return uuid ? { buildId, uuid, startedAt: Date.now() } : undefined; + } catch (e) { + warn("startTestRun", e); + return undefined; + } + })(); + this.tests.set(testCase.id, started); + await started; + } + + async onTestCaseResult(testCase) { + if (!this.enabled) return; + const run = await this.tests.get(testCase.id); + if (!run) return; + try { + const result = testCase.result(); + const state = result.state === "pending" ? "skipped" : result.state; + const parts = testCase.fullName.split(" > "); + await this.client.finishTestRun(run.buildId, run.uuid, { + result: state, + finishedAt: iso(), + fileName: relative(this.cwd, testCase.module.moduleId).replaceAll("\\", "/"), + scopes: [testCase.project.name, ...parts.slice(0, -1)].slice(0, 20), + durationInMs: Math.round(testCase.diagnostic()?.duration ?? Date.now() - run.startedAt), + ...(state === "failed" ? { failure: (result.errors ?? []).slice(0, 100).map((x) => ({ error: x.message, backtrace: x.stack })) } : {}), + }); + } catch (e) { + warn("finishTestRun", e); + } + } + + async onTestRunEnd() { + if (!this.enabled) return; + await Promise.all(this.tests.values()); + const buildId = await this.build; + if (!buildId) return; + try { + await this.client.finishBuild(buildId, { finishedAt: iso() }); + } catch (e) { + warn("finishBuild", e); + } + } +} + +// Vitest loads the default export as the reporter class; it needs the credentialed client up front. +export default class TraVitestReporter extends TraReporter { + async onInit(vitest) { + this.vitestVersion = vitest?.version; + const r = await TraReporter.fromEnv(process.env); + this.client = r.client; + this.enabled = r.enabled; + if (!this.enabled) console.log("[tra] BrowserStack credentials not available; live reporting disabled."); + } +} diff --git a/scripts/ci-report/tra-reporter.test.ts b/scripts/ci-report/tra-reporter.test.ts new file mode 100644 index 0000000..f635589 --- /dev/null +++ b/scripts/ci-report/tra-reporter.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from "vitest"; +import { TraReporter } from "./tra-reporter.mjs"; + +const env = { + GITHUB_REPOSITORY: "shirish87/browserstack-client", + GITHUB_RUN_ID: "123", + GITHUB_RUN_NUMBER: "45", + GITHUB_RUN_ATTEMPT: "1", + GITHUB_REF_NAME: "main", + GITHUB_SHA: "abcdef1234567890", + GITHUB_WORKFLOW: "CI", + TRA_LABEL: "linux", +}; + +function fakeClient() { + return { + startBuild: vi.fn(async () => ({ success: true, buildHashedId: "bld1" })), + startTestRun: vi.fn(async () => ({ success: true, uuid: "run1" })), + finishTestRun: vi.fn(async () => ({ success: true })), + finishBuild: vi.fn(async () => ({ success: true })), + }; +} + +const tc = (over: Record = {}) => ({ + id: "t1", + name: "does a thing", + fullName: "suite > does a thing", + module: { moduleId: "/repo/packages/core/src/a.test.ts" }, + project: { name: "core" }, + location: { line: 3, column: 1 }, + result: () => ({ state: "passed", errors: [] }), + diagnostic: () => ({ duration: 12.4 }), + ...over, +}); + +describe("TraReporter", () => { + it("starts the build when the run starts, with CI metadata", async () => { + const client = fakeClient(); + const r = new TraReporter({ client, env, cwd: "/repo" }); + await r.onTestRunStart([]); + await r.onTestRunEnd([], []); + expect(client.startBuild).toHaveBeenCalledWith( + expect.objectContaining({ + projectName: "shirish87/browserstack-client", + name: "CI #45 (main) [linux]", + buildRunIdentifier: "123-1-linux", + framework: expect.objectContaining({ name: "vitest" }), + ciInfo: expect.objectContaining({ buildUrl: "https://github.com/shirish87/browserstack-client/actions/runs/123" }), + versionControl: expect.objectContaining({ sha: "abcdef1234567890", branch: "main" }), + }) + ); + }); + + it("captures PR context: head commit and branch, PR number/title, actor, event and run/job URLs", async () => { + const client = fakeClient(); + const event = { + pull_request: { + number: 34, + title: "ci: report to TRA", + html_url: "https://github.com/shirish87/browserstack-client/pull/34", + head: { ref: "feature/x", sha: "headsha1234567" }, + }, + }; + const prEnv = { ...env, GITHUB_EVENT_NAME: "pull_request", GITHUB_HEAD_REF: "feature/x", GITHUB_ACTOR: "shirish87", GITHUB_JOB: "test-sdk", GITHUB_REF_NAME: "34/merge", GITHUB_SERVER_URL: "https://github.com", RUNNER_OS: "Linux" }; + const r = new TraReporter({ client, env: prEnv, cwd: "/repo", event }); + await r.onTestRunStart(); + expect(client.startBuild).toHaveBeenCalledWith( + expect.objectContaining({ + name: "CI #45 (feature/x) [linux]", + tags: expect.arrayContaining(["ci", "linux", "feature/x", "pull_request"]), + versionControl: expect.objectContaining({ sha: "headsha1234567", branch: "feature/x", commitMessage: "ci: report to TRA" }), + ciInfo: expect.objectContaining({ + name: "GitHub Actions", + buildUrl: "https://github.com/shirish87/browserstack-client/actions/runs/123", + url: "https://github.com/shirish87/browserstack-client/pull/34", + buildNumber: "45", + jobName: "test-sdk", + }), + }) + ); + }); + + it("captures push context: head commit message and author from the event", async () => { + const client = fakeClient(); + const event = { head_commit: { message: "fix: a thing\n\nbody", author: { name: "Ann", email: "ann@x.io" }, committer: { name: "Bob", email: "bob@x.io" } } }; + const r = new TraReporter({ client, env: { ...env, GITHUB_EVENT_NAME: "push" }, cwd: "/repo", event }); + await r.onTestRunStart(); + expect(client.startBuild).toHaveBeenCalledWith( + expect.objectContaining({ + versionControl: expect.objectContaining({ sha: "abcdef1234567890", branch: "main", commitMessage: "fix: a thing", committerName: "Bob", committerEmail: "bob@x.io" }), + }) + ); + }); + + it("reports each test live: start when it is ready, finish when it has a result", async () => { + const client = fakeClient(); + const r = new TraReporter({ client, env, cwd: "/repo" }); + await r.onTestRunStart([]); + await r.onTestCaseReady(tc()); + expect(client.startTestRun).toHaveBeenCalledWith("bld1", expect.objectContaining({ name: "does a thing", scopes: ["core", "suite"], fileName: "packages/core/src/a.test.ts" })); + expect(client.finishTestRun).not.toHaveBeenCalled(); + await r.onTestCaseResult(tc()); + expect(client.finishTestRun).toHaveBeenCalledWith("bld1", "run1", expect.objectContaining({ result: "passed", durationInMs: 12 })); + await r.onTestRunEnd([], []); + expect(client.finishBuild).toHaveBeenCalledWith("bld1", expect.objectContaining({ finishedAt: expect.any(String) })); + }); + + it("maps failures with error and backtrace", async () => { + const client = fakeClient(); + const r = new TraReporter({ client, env, cwd: "/repo" }); + await r.onTestRunStart([]); + await r.onTestCaseReady(tc()); + await r.onTestCaseResult(tc({ result: () => ({ state: "failed", errors: [{ message: "boom", stack: "at x" }] }) })); + expect(client.finishTestRun).toHaveBeenCalledWith("bld1", "run1", expect.objectContaining({ result: "failed", failure: [{ error: "boom", backtrace: "at x" }] })); + }); + + it("never throws and stops calling once the build cannot be started", async () => { + const client = fakeClient(); + client.startBuild.mockRejectedValue(new Error("unreachable")); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const r = new TraReporter({ client, env, cwd: "/repo" }); + await expect(r.onTestRunStart([])).resolves.toBeUndefined(); + await expect(r.onTestCaseReady(tc())).resolves.toBeUndefined(); + await expect(r.onTestCaseResult(tc())).resolves.toBeUndefined(); + await expect(r.onTestRunEnd([], [])).resolves.toBeUndefined(); + expect(client.startTestRun).not.toHaveBeenCalled(); + expect(client.finishBuild).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("swallows per-test API errors", async () => { + const client = fakeClient(); + client.startTestRun.mockRejectedValue(new Error("500")); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const r = new TraReporter({ client, env, cwd: "/repo" }); + await r.onTestRunStart([]); + await r.onTestCaseReady(tc()); + await expect(r.onTestCaseResult(tc())).resolves.toBeUndefined(); + expect(client.finishTestRun).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("is disabled without credentials", async () => { + const r = await TraReporter.fromEnv({ GITHUB_RUN_ID: "1" }); + expect(r.enabled).toBe(false); + }); +}); From f6632b0f6c513cd9c960cb73ee7eee79a0aa5d9b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 20:22:27 +0000 Subject: [PATCH 3/7] feat(test-reporting): send ingestion calls to the collector host; report logs and errors from CI Ingestion endpoints (start/finish build, test and hook runs, build logs) live on collector-observability.browserstack.com, not api-automation. Adds an sdkIngest base URL (ingestBaseUrl option) and has the CI reporter send console output, failure stack traces and unhandled errors as build logs. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WUdwzfDSUhTEqqEWDhi6tB --- packages/core/src/api-client.ts | 9 ++-- packages/core/src/middleware.ts | 4 +- .../src/codegen/typescript/emit-method.ts | 2 +- .../src/codegen/typescript/index.ts | 4 +- .../generated/test-reporting.client.ts | 14 +++---- packages/openapi/specs/test-reporting.yml | 7 ++++ .../src/__tests__/test-reporting.test.ts | 41 +++++++++++++++++++ packages/test-reporting/src/index.ts | 6 ++- scripts/ci-report/tra-reporter.mjs | 32 ++++++++++++++- scripts/ci-report/tra-reporter.test.ts | 25 +++++++++++ 10 files changed, 127 insertions(+), 17 deletions(-) diff --git a/packages/core/src/api-client.ts b/packages/core/src/api-client.ts index 33bd733..d37dbff 100644 --- a/packages/core/src/api-client.ts +++ b/packages/core/src/api-client.ts @@ -50,7 +50,7 @@ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; */ export class APIClient { /** Base URLs used for API requests. */ - protected readonly baseUrls: { sdk: string; sdkCloud: string }; + protected readonly baseUrls: { sdk: string; sdkCloud: string; sdkIngest?: string }; /** HTTP Basic Auth header derived from username and access key. */ protected readonly authHeader?: string; /** User-Agent string sent with every request. */ @@ -70,7 +70,8 @@ export class APIClient { baseUrl: string, cloudBaseUrl: string, pkgName: string, - pkgVersion: string + pkgVersion: string, + ingestBaseUrl?: string ) { const hasMiddleware = !!(options.middleware && options.middleware.length > 0); const usernameOptional = options.usernameOptional === true || hasMiddleware; @@ -93,6 +94,7 @@ export class APIClient { this.baseUrls = { sdk: options.baseUrl ?? baseUrl, sdkCloud: cloudBaseUrl, + sdkIngest: ingestBaseUrl, }; this.authHeader = (username && accessKey) ? buildBasicAuthHeader(username, accessKey) : undefined; this.userAgent = pkginfo.userAgent; @@ -133,7 +135,7 @@ export class APIClient { requestInput?: unknown; responseCodec: string; responseCodecConfig: unknown; - baseUrl?: "sdk" | "sdkCloud"; + baseUrl?: "sdk" | "sdkCloud" | "sdkIngest"; signal?: AbortSignal; [key: string]: unknown; }): Promise { @@ -179,6 +181,7 @@ export class APIClient { try { const base = this.baseUrls[spec.baseUrl ?? "sdk"]; + if (!base) throw new BrowserStackError(`No base URL configured for "${spec.baseUrl}"`); let interpolated = spec.path; for (const [k, v] of Object.entries(spec.params?.path ?? {})) { interpolated = interpolated.replace(`{${k}}`, encodeURIComponent(String(v))); diff --git a/packages/core/src/middleware.ts b/packages/core/src/middleware.ts index fd00a26..6d3c805 100644 --- a/packages/core/src/middleware.ts +++ b/packages/core/src/middleware.ts @@ -2,7 +2,7 @@ import type { HttpMethod } from "./api-client"; export interface MiddlewareContext { operationId: string; - baseUrl?: "sdk" | "sdkCloud"; + baseUrl?: "sdk" | "sdkCloud" | "sdkIngest"; } export interface MiddlewareRequest { @@ -85,7 +85,7 @@ export function composeMiddleware( }; } - const customInit = init as (RequestInit & { operationId?: string; baseUrl?: "sdk" | "sdkCloud" }) | undefined; + const customInit = init as (RequestInit & { operationId?: string; baseUrl?: "sdk" | "sdkCloud" | "sdkIngest" }) | undefined; const { operationId, baseUrl, ...rest } = customInit ?? {}; if (customInit) { diff --git a/packages/openapi-transforms/src/codegen/typescript/emit-method.ts b/packages/openapi-transforms/src/codegen/typescript/emit-method.ts index ec9d72d..1c34396 100644 --- a/packages/openapi-transforms/src/codegen/typescript/emit-method.ts +++ b/packages/openapi-transforms/src/codegen/typescript/emit-method.ts @@ -14,7 +14,7 @@ export interface EmitMethodInput { returnType: string; returnTypeAliases?: string[]; annotations: OperationAnnotations; - baseUrl: "sdk" | "sdkCloud"; + baseUrl: "sdk" | "sdkCloud" | "sdkIngest"; overrides?: OperationOverrides; summary?: string; description?: string; diff --git a/packages/openapi-transforms/src/codegen/typescript/index.ts b/packages/openapi-transforms/src/codegen/typescript/index.ts index 3af085a..42bba7d 100644 --- a/packages/openapi-transforms/src/codegen/typescript/index.ts +++ b/packages/openapi-transforms/src/codegen/typescript/index.ts @@ -14,7 +14,7 @@ export interface GenerateClientOptions { className: string; typesImportPath: string; registry: CodecRegistry; - baseUrl: "sdk" | "sdkCloud"; + baseUrl: "sdk" | "sdkCloud" | "sdkIngest"; fieldOverridesPath?: string; } @@ -101,7 +101,7 @@ export async function generateClientModule(opts: GenerateClientOptions): Promise method: method.toUpperCase() as EmitMethodInput["method"], path, pathParams, queryParams, hasRequestBody: Boolean(op.requestBody), operationsKey: operationId, returnType, returnTypeAliases: derived.aliases, - annotations, baseUrl: (op["x-base-url"] ?? opts.baseUrl) as "sdk" | "sdkCloud", + annotations, baseUrl: (op["x-base-url"] ?? opts.baseUrl) as "sdk" | "sdkCloud" | "sdkIngest", summary: op.summary, description: op.description, }); const errorStatuses = Object.keys(op.responses ?? {}).map(Number) diff --git a/packages/openapi/generated/test-reporting.client.ts b/packages/openapi/generated/test-reporting.client.ts index fed09a0..72fd27a 100644 --- a/packages/openapi/generated/test-reporting.client.ts +++ b/packages/openapi/generated/test-reporting.client.ts @@ -108,7 +108,7 @@ export class GeneratedTestReportingClient extends APIClient { requestCodecConfig: {}, responseCodec: "json", responseCodecConfig: {}, - baseUrl: "sdk" as const, + baseUrl: "sdkIngest" as const, operationId: "startTestReportingBuild", method: "POST" as const, signal: options?.signal, @@ -192,7 +192,7 @@ export class GeneratedTestReportingClient extends APIClient { requestCodecConfig: {}, responseCodec: "json", responseCodecConfig: {}, - baseUrl: "sdk" as const, + baseUrl: "sdkIngest" as const, operationId: "finishTestReportingBuild", method: "PUT" as const, signal: options?.signal, @@ -213,7 +213,7 @@ export class GeneratedTestReportingClient extends APIClient { requestCodecConfig: {}, responseCodec: "json", responseCodecConfig: {}, - baseUrl: "sdk" as const, + baseUrl: "sdkIngest" as const, operationId: "startTestReportingTestRun", method: "POST" as const, signal: options?.signal, @@ -234,7 +234,7 @@ export class GeneratedTestReportingClient extends APIClient { requestCodecConfig: {}, responseCodec: "json", responseCodecConfig: {}, - baseUrl: "sdk" as const, + baseUrl: "sdkIngest" as const, operationId: "finishTestReportingTestRun", method: "PUT" as const, signal: options?.signal, @@ -255,7 +255,7 @@ export class GeneratedTestReportingClient extends APIClient { requestCodecConfig: {}, responseCodec: "json", responseCodecConfig: {}, - baseUrl: "sdk" as const, + baseUrl: "sdkIngest" as const, operationId: "startTestReportingHookRun", method: "POST" as const, signal: options?.signal, @@ -276,7 +276,7 @@ export class GeneratedTestReportingClient extends APIClient { requestCodecConfig: {}, responseCodec: "json", responseCodecConfig: {}, - baseUrl: "sdk" as const, + baseUrl: "sdkIngest" as const, operationId: "finishTestReportingHookRun", method: "PUT" as const, signal: options?.signal, @@ -297,7 +297,7 @@ export class GeneratedTestReportingClient extends APIClient { requestCodecConfig: {}, responseCodec: "json", responseCodecConfig: {}, - baseUrl: "sdk" as const, + baseUrl: "sdkIngest" as const, operationId: "addTestReportingBuildLogs", method: "POST" as const, signal: options?.signal, diff --git a/packages/openapi/specs/test-reporting.yml b/packages/openapi/specs/test-reporting.yml index c395043..aaa0a70 100644 --- a/packages/openapi/specs/test-reporting.yml +++ b/packages/openapi/specs/test-reporting.yml @@ -81,6 +81,7 @@ paths: tags: [TestReporting] summary: Start Build (ingestion) operationId: startTestReportingBuild + x-base-url: sdkIngest x-cli-section: "Builds" requestBody: required: true @@ -198,6 +199,7 @@ paths: tags: [TestReporting] summary: Finish Build (ingestion) operationId: finishTestReportingBuild + x-base-url: sdkIngest x-cli-section: "Builds" parameters: - name: buildHashedId @@ -233,6 +235,7 @@ paths: tags: [TestReporting] summary: Start Test Run (ingestion) operationId: startTestReportingTestRun + x-base-url: sdkIngest x-cli-section: "Test Runs" parameters: - name: buildHashedId @@ -265,6 +268,7 @@ paths: tags: [TestReporting] summary: Finish Test Run (ingestion) operationId: finishTestReportingTestRun + x-base-url: sdkIngest x-cli-section: "Test Runs" parameters: - name: buildHashedId @@ -301,6 +305,7 @@ paths: tags: [TestReporting] summary: Start Hook Run (ingestion) operationId: startTestReportingHookRun + x-base-url: sdkIngest x-cli-section: "Hook Runs" parameters: - name: buildHashedId @@ -333,6 +338,7 @@ paths: tags: [TestReporting] summary: Finish Hook Run (ingestion) operationId: finishTestReportingHookRun + x-base-url: sdkIngest x-cli-section: "Hook Runs" parameters: - name: buildHashedId @@ -369,6 +375,7 @@ paths: tags: [TestReporting] summary: Add Build Logs (ingestion) operationId: addTestReportingBuildLogs + x-base-url: sdkIngest x-cli-section: "Builds" parameters: - name: buildHashedId diff --git a/packages/test-reporting/src/__tests__/test-reporting.test.ts b/packages/test-reporting/src/__tests__/test-reporting.test.ts index b914342..3918085 100644 --- a/packages/test-reporting/src/__tests__/test-reporting.test.ts +++ b/packages/test-reporting/src/__tests__/test-reporting.test.ts @@ -224,6 +224,47 @@ describe("TestReportingClient", () => { }); }); + describe("ingestion base URL", () => { + function capture() { + const urls: string[] = []; + const fetchFn = async (url: string | URL | Request) => { + urls.push(url.toString()); + return new Response(JSON.stringify({ success: true }), { status: 200, headers: { "content-type": "application/json" } }); + }; + return { urls, client: new TestReportingClient({ username: "u", accessKey: "k", fetchFn }) }; + } + const body = { name: "n", projectName: "p", startedAt: "2026-01-01T00:00:00Z", framework: { name: "vitest", version: "4" } }; + + it("sends every ingestion call to collector-observability, not api-automation", async () => { + const { urls, client } = capture(); + await client.startBuild(body); + await client.startTestRun("bld", { name: "t", fileName: "a.test.ts", scopes: ["s"], startedAt: "2026-01-01T00:00:00Z" }); + await client.finishTestRun("bld", "run", { result: "passed", finishedAt: "2026-01-01T00:00:01Z", fileName: "a.test.ts", scopes: ["s"] }); + await client.addBuildLogs("bld", { logs: [{ kind: "TEST_LOG", message: "hi", timestamp: "2026-01-01T00:00:00Z" }] }); + await client.finishBuild("bld", { finishedAt: "2026-01-01T00:00:02Z" }); + expect(urls).toEqual([ + "https://collector-observability.browserstack.com/ext/v1/builds/start", + "https://collector-observability.browserstack.com/ext/v1/builds/bld/tests/start", + "https://collector-observability.browserstack.com/ext/v1/builds/bld/tests/run/finish", + "https://collector-observability.browserstack.com/ext/v1/builds/bld/logs", + "https://collector-observability.browserstack.com/ext/v1/builds/bld/finish", + ]); + }); + + it("keeps read APIs on api-automation", async () => { + const { urls, client } = capture(); + await client.getProjects(); + expect(urls[0]).toMatch(/^https:\/\/api-automation\.browserstack\.com\/ext\/v1\/projects/); + }); + + it("honours ingestBaseUrl", async () => { + const urls: string[] = []; + const fetchFn = async (url: string | URL | Request) => (urls.push(url.toString()), new Response("{}", { status: 200, headers: { "content-type": "application/json" } })); + await new TestReportingClient({ username: "u", accessKey: "k", fetchFn, ingestBaseUrl: "https://example.test/ext/v1" }).startBuild(body); + expect(urls[0]).toBe("https://example.test/ext/v1/builds/start"); + }); + }); + describe("uploadReport", () => { function makeCapturingClient() { let capturedUrl = ""; diff --git a/packages/test-reporting/src/index.ts b/packages/test-reporting/src/index.ts index da16c16..133dae5 100644 --- a/packages/test-reporting/src/index.ts +++ b/packages/test-reporting/src/index.ts @@ -21,6 +21,8 @@ export type * from "./linked-sessions"; export interface TestReportingClientOptions extends BrowserStackOptions { uploadBaseUrl?: string; + /** Base URL of the ingestion (collector) API: start/finish build, test and hook runs, build logs. */ + ingestBaseUrl?: string; /** * Used to resolve a test's session and fetch its logs (see `getTestSession`). Defaults to a client built from * these same options, minus the Test Reporting base URLs. Pass one to customise it (base URL, timeout, ...). @@ -42,7 +44,8 @@ export class TestReportingClient extends GeneratedTestReportingClient { options?.baseUrl ?? "https://api-automation.browserstack.com/ext/v1", options?.uploadBaseUrl ?? "https://upload-automation.browserstack.com", "@dot-slash/browserstack-test-reporting", - __PKG_VERSION__ + __PKG_VERSION__, + options?.ingestBaseUrl ?? "https://collector-observability.browserstack.com/ext/v1" ); const shared: TestReportingClientOptions = { ...options }; this.automateClient = shared.automate; @@ -50,6 +53,7 @@ export class TestReportingClient extends GeneratedTestReportingClient { // Those base URLs are Test Reporting's, so the sibling clients keep their own. delete shared.baseUrl; delete shared.uploadBaseUrl; + delete shared.ingestBaseUrl; delete shared.automate; delete shared.appAutomate; this.siblingOptions = shared; diff --git a/scripts/ci-report/tra-reporter.mjs b/scripts/ci-report/tra-reporter.mjs index 5192109..c0aa6a1 100644 --- a/scripts/ci-report/tra-reporter.mjs +++ b/scripts/ci-report/tra-reporter.mjs @@ -19,6 +19,7 @@ export class TraReporter { this.enabled = Boolean(client); this.build = undefined; // Promise (buildHashedId) this.tests = new Map(); // test id -> Promise<{ buildId, uuid } | undefined> + this.logs = []; // pending build log entries, flushed at the end of the run } static async fromEnv(env = process.env) { @@ -106,6 +107,19 @@ export class TraReporter { await started; } + async onUserConsoleLog(log) { + if (!this.enabled || !log.taskId) return; + const run = await this.tests.get(log.taskId); + if (!run) return; + this.logs.push({ + kind: "TEST_LOG", + testRunUuid: run.uuid, + level: log.type === "stderr" ? "ERROR" : "INFO", + message: String(log.content).replace(/\n$/, "").slice(0, 10000), + timestamp: new Date(log.time ?? Date.now()).toISOString(), + }); + } + async onTestCaseResult(testCase) { if (!this.enabled) return; const run = await this.tests.get(testCase.id); @@ -114,6 +128,11 @@ export class TraReporter { const result = testCase.result(); const state = result.state === "pending" ? "skipped" : result.state; const parts = testCase.fullName.split(" > "); + if (state === "failed") { + for (const x of result.errors ?? []) { + this.logs.push({ kind: "TEST_LOG", testRunUuid: run.uuid, level: "ERROR", message: String(x.stack ?? x.message).slice(0, 10000), timestamp: iso(), failure: true }); + } + } await this.client.finishTestRun(run.buildId, run.uuid, { result: state, finishedAt: iso(), @@ -127,11 +146,22 @@ export class TraReporter { } } - async onTestRunEnd() { + async onTestRunEnd(_modules, errors = []) { if (!this.enabled) return; await Promise.all(this.tests.values()); const buildId = await this.build; if (!buildId) return; + // Unhandled errors (outside any test) belong to the build itself. + for (const x of errors) { + this.logs.push({ kind: "TEST_LOG", level: "ERROR", message: String(x.stack ?? x.message).slice(0, 10000), timestamp: iso(), failure: true }); + } + for (let i = 0; i < this.logs.length; i += 500) { + try { + await this.client.addBuildLogs(buildId, { logs: this.logs.slice(i, i + 500) }); + } catch (e) { + warn("addBuildLogs", e); + } + } try { await this.client.finishBuild(buildId, { finishedAt: iso() }); } catch (e) { diff --git a/scripts/ci-report/tra-reporter.test.ts b/scripts/ci-report/tra-reporter.test.ts index f635589..31e94e5 100644 --- a/scripts/ci-report/tra-reporter.test.ts +++ b/scripts/ci-report/tra-reporter.test.ts @@ -18,6 +18,7 @@ function fakeClient() { startTestRun: vi.fn(async () => ({ success: true, uuid: "run1" })), finishTestRun: vi.fn(async () => ({ success: true })), finishBuild: vi.fn(async () => ({ success: true })), + addBuildLogs: vi.fn(async () => ({ success: true })), }; } @@ -114,6 +115,30 @@ describe("TraReporter", () => { expect(client.finishTestRun).toHaveBeenCalledWith("bld1", "run1", expect.objectContaining({ result: "failed", failure: [{ error: "boom", backtrace: "at x" }] })); }); + it("sends test console output as TEST_LOG entries linked to the test run", async () => { + const client = fakeClient(); + const r = new TraReporter({ client, env, cwd: "/repo" }); + await r.onTestRunStart(); + await r.onTestCaseReady(tc()); + await r.onUserConsoleLog({ content: "hello\n", type: "stderr", taskId: "t1", time: 1759600000000 }); + await r.onTestRunEnd([], []); + expect(client.addBuildLogs).toHaveBeenCalledWith("bld1", { + logs: [expect.objectContaining({ kind: "TEST_LOG", testRunUuid: "run1", level: "ERROR", message: "hello", timestamp: new Date(1759600000000).toISOString() })], + }); + }); + + it("sends failure messages and unhandled errors as ERROR logs with stack traces", async () => { + const client = fakeClient(); + const r = new TraReporter({ client, env, cwd: "/repo" }); + await r.onTestRunStart(); + await r.onTestCaseReady(tc()); + await r.onTestCaseResult(tc({ result: () => ({ state: "failed", errors: [{ message: "boom", stack: "Error: boom\n at x" }] }) })); + await r.onTestRunEnd([], [{ name: "Error", message: "unhandled", stack: "Error: unhandled\n at y" }]); + const logs = client.addBuildLogs.mock.calls.flatMap((c) => (c[1] as { logs: unknown[] }).logs); + expect(logs).toContainEqual(expect.objectContaining({ kind: "TEST_LOG", testRunUuid: "run1", level: "ERROR", message: "Error: boom\n at x", failure: true })); + expect(logs).toContainEqual(expect.objectContaining({ kind: "TEST_LOG", level: "ERROR", message: "Error: unhandled\n at y", failure: true })); + }); + it("never throws and stops calling once the build cannot be started", async () => { const client = fakeClient(); client.startBuild.mockRejectedValue(new Error("unreachable")); From 84ca2fac52b04b070f739ac11735706840cd94bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 20:34:01 +0000 Subject: [PATCH 4/7] feat(test-reporting): live ingestion client; CI reporter streams over it The documented ext/v1 ingestion endpoints reject basic auth on the collector. Use the flow the BrowserStack SDKs use: POST /api/v2/builds (basic auth) returns a build id and JWT, events go to /api/v1/batch with the JWT, then the build is stopped. Reverts the earlier sdkIngest base URL change. Adds unit tests and a live end-to-end test. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WUdwzfDSUhTEqqEWDhi6tB --- packages/core/src/api-client.ts | 9 +- packages/core/src/middleware.ts | 4 +- .../src/codegen/typescript/emit-method.ts | 2 +- .../src/codegen/typescript/index.ts | 4 +- .../generated/test-reporting.client.ts | 14 +- packages/openapi/specs/test-reporting.yml | 7 - .../src/__tests__/ingest.live.test.ts | 49 +++++ .../src/__tests__/ingest.test.ts | 72 +++++++ .../src/__tests__/test-reporting.test.ts | 41 ---- packages/test-reporting/src/index.ts | 8 +- packages/test-reporting/src/ingest.ts | 102 ++++++++++ scripts/ci-report/tra-reporter.mjs | 188 ++++++++++-------- scripts/ci-report/tra-reporter.test.ts | 123 ++++++------ 13 files changed, 408 insertions(+), 215 deletions(-) create mode 100644 packages/test-reporting/src/__tests__/ingest.live.test.ts create mode 100644 packages/test-reporting/src/__tests__/ingest.test.ts create mode 100644 packages/test-reporting/src/ingest.ts diff --git a/packages/core/src/api-client.ts b/packages/core/src/api-client.ts index d37dbff..33bd733 100644 --- a/packages/core/src/api-client.ts +++ b/packages/core/src/api-client.ts @@ -50,7 +50,7 @@ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; */ export class APIClient { /** Base URLs used for API requests. */ - protected readonly baseUrls: { sdk: string; sdkCloud: string; sdkIngest?: string }; + protected readonly baseUrls: { sdk: string; sdkCloud: string }; /** HTTP Basic Auth header derived from username and access key. */ protected readonly authHeader?: string; /** User-Agent string sent with every request. */ @@ -70,8 +70,7 @@ export class APIClient { baseUrl: string, cloudBaseUrl: string, pkgName: string, - pkgVersion: string, - ingestBaseUrl?: string + pkgVersion: string ) { const hasMiddleware = !!(options.middleware && options.middleware.length > 0); const usernameOptional = options.usernameOptional === true || hasMiddleware; @@ -94,7 +93,6 @@ export class APIClient { this.baseUrls = { sdk: options.baseUrl ?? baseUrl, sdkCloud: cloudBaseUrl, - sdkIngest: ingestBaseUrl, }; this.authHeader = (username && accessKey) ? buildBasicAuthHeader(username, accessKey) : undefined; this.userAgent = pkginfo.userAgent; @@ -135,7 +133,7 @@ export class APIClient { requestInput?: unknown; responseCodec: string; responseCodecConfig: unknown; - baseUrl?: "sdk" | "sdkCloud" | "sdkIngest"; + baseUrl?: "sdk" | "sdkCloud"; signal?: AbortSignal; [key: string]: unknown; }): Promise { @@ -181,7 +179,6 @@ export class APIClient { try { const base = this.baseUrls[spec.baseUrl ?? "sdk"]; - if (!base) throw new BrowserStackError(`No base URL configured for "${spec.baseUrl}"`); let interpolated = spec.path; for (const [k, v] of Object.entries(spec.params?.path ?? {})) { interpolated = interpolated.replace(`{${k}}`, encodeURIComponent(String(v))); diff --git a/packages/core/src/middleware.ts b/packages/core/src/middleware.ts index 6d3c805..fd00a26 100644 --- a/packages/core/src/middleware.ts +++ b/packages/core/src/middleware.ts @@ -2,7 +2,7 @@ import type { HttpMethod } from "./api-client"; export interface MiddlewareContext { operationId: string; - baseUrl?: "sdk" | "sdkCloud" | "sdkIngest"; + baseUrl?: "sdk" | "sdkCloud"; } export interface MiddlewareRequest { @@ -85,7 +85,7 @@ export function composeMiddleware( }; } - const customInit = init as (RequestInit & { operationId?: string; baseUrl?: "sdk" | "sdkCloud" | "sdkIngest" }) | undefined; + const customInit = init as (RequestInit & { operationId?: string; baseUrl?: "sdk" | "sdkCloud" }) | undefined; const { operationId, baseUrl, ...rest } = customInit ?? {}; if (customInit) { diff --git a/packages/openapi-transforms/src/codegen/typescript/emit-method.ts b/packages/openapi-transforms/src/codegen/typescript/emit-method.ts index 1c34396..ec9d72d 100644 --- a/packages/openapi-transforms/src/codegen/typescript/emit-method.ts +++ b/packages/openapi-transforms/src/codegen/typescript/emit-method.ts @@ -14,7 +14,7 @@ export interface EmitMethodInput { returnType: string; returnTypeAliases?: string[]; annotations: OperationAnnotations; - baseUrl: "sdk" | "sdkCloud" | "sdkIngest"; + baseUrl: "sdk" | "sdkCloud"; overrides?: OperationOverrides; summary?: string; description?: string; diff --git a/packages/openapi-transforms/src/codegen/typescript/index.ts b/packages/openapi-transforms/src/codegen/typescript/index.ts index 42bba7d..3af085a 100644 --- a/packages/openapi-transforms/src/codegen/typescript/index.ts +++ b/packages/openapi-transforms/src/codegen/typescript/index.ts @@ -14,7 +14,7 @@ export interface GenerateClientOptions { className: string; typesImportPath: string; registry: CodecRegistry; - baseUrl: "sdk" | "sdkCloud" | "sdkIngest"; + baseUrl: "sdk" | "sdkCloud"; fieldOverridesPath?: string; } @@ -101,7 +101,7 @@ export async function generateClientModule(opts: GenerateClientOptions): Promise method: method.toUpperCase() as EmitMethodInput["method"], path, pathParams, queryParams, hasRequestBody: Boolean(op.requestBody), operationsKey: operationId, returnType, returnTypeAliases: derived.aliases, - annotations, baseUrl: (op["x-base-url"] ?? opts.baseUrl) as "sdk" | "sdkCloud" | "sdkIngest", + annotations, baseUrl: (op["x-base-url"] ?? opts.baseUrl) as "sdk" | "sdkCloud", summary: op.summary, description: op.description, }); const errorStatuses = Object.keys(op.responses ?? {}).map(Number) diff --git a/packages/openapi/generated/test-reporting.client.ts b/packages/openapi/generated/test-reporting.client.ts index 72fd27a..fed09a0 100644 --- a/packages/openapi/generated/test-reporting.client.ts +++ b/packages/openapi/generated/test-reporting.client.ts @@ -108,7 +108,7 @@ export class GeneratedTestReportingClient extends APIClient { requestCodecConfig: {}, responseCodec: "json", responseCodecConfig: {}, - baseUrl: "sdkIngest" as const, + baseUrl: "sdk" as const, operationId: "startTestReportingBuild", method: "POST" as const, signal: options?.signal, @@ -192,7 +192,7 @@ export class GeneratedTestReportingClient extends APIClient { requestCodecConfig: {}, responseCodec: "json", responseCodecConfig: {}, - baseUrl: "sdkIngest" as const, + baseUrl: "sdk" as const, operationId: "finishTestReportingBuild", method: "PUT" as const, signal: options?.signal, @@ -213,7 +213,7 @@ export class GeneratedTestReportingClient extends APIClient { requestCodecConfig: {}, responseCodec: "json", responseCodecConfig: {}, - baseUrl: "sdkIngest" as const, + baseUrl: "sdk" as const, operationId: "startTestReportingTestRun", method: "POST" as const, signal: options?.signal, @@ -234,7 +234,7 @@ export class GeneratedTestReportingClient extends APIClient { requestCodecConfig: {}, responseCodec: "json", responseCodecConfig: {}, - baseUrl: "sdkIngest" as const, + baseUrl: "sdk" as const, operationId: "finishTestReportingTestRun", method: "PUT" as const, signal: options?.signal, @@ -255,7 +255,7 @@ export class GeneratedTestReportingClient extends APIClient { requestCodecConfig: {}, responseCodec: "json", responseCodecConfig: {}, - baseUrl: "sdkIngest" as const, + baseUrl: "sdk" as const, operationId: "startTestReportingHookRun", method: "POST" as const, signal: options?.signal, @@ -276,7 +276,7 @@ export class GeneratedTestReportingClient extends APIClient { requestCodecConfig: {}, responseCodec: "json", responseCodecConfig: {}, - baseUrl: "sdkIngest" as const, + baseUrl: "sdk" as const, operationId: "finishTestReportingHookRun", method: "PUT" as const, signal: options?.signal, @@ -297,7 +297,7 @@ export class GeneratedTestReportingClient extends APIClient { requestCodecConfig: {}, responseCodec: "json", responseCodecConfig: {}, - baseUrl: "sdkIngest" as const, + baseUrl: "sdk" as const, operationId: "addTestReportingBuildLogs", method: "POST" as const, signal: options?.signal, diff --git a/packages/openapi/specs/test-reporting.yml b/packages/openapi/specs/test-reporting.yml index aaa0a70..c395043 100644 --- a/packages/openapi/specs/test-reporting.yml +++ b/packages/openapi/specs/test-reporting.yml @@ -81,7 +81,6 @@ paths: tags: [TestReporting] summary: Start Build (ingestion) operationId: startTestReportingBuild - x-base-url: sdkIngest x-cli-section: "Builds" requestBody: required: true @@ -199,7 +198,6 @@ paths: tags: [TestReporting] summary: Finish Build (ingestion) operationId: finishTestReportingBuild - x-base-url: sdkIngest x-cli-section: "Builds" parameters: - name: buildHashedId @@ -235,7 +233,6 @@ paths: tags: [TestReporting] summary: Start Test Run (ingestion) operationId: startTestReportingTestRun - x-base-url: sdkIngest x-cli-section: "Test Runs" parameters: - name: buildHashedId @@ -268,7 +265,6 @@ paths: tags: [TestReporting] summary: Finish Test Run (ingestion) operationId: finishTestReportingTestRun - x-base-url: sdkIngest x-cli-section: "Test Runs" parameters: - name: buildHashedId @@ -305,7 +301,6 @@ paths: tags: [TestReporting] summary: Start Hook Run (ingestion) operationId: startTestReportingHookRun - x-base-url: sdkIngest x-cli-section: "Hook Runs" parameters: - name: buildHashedId @@ -338,7 +333,6 @@ paths: tags: [TestReporting] summary: Finish Hook Run (ingestion) operationId: finishTestReportingHookRun - x-base-url: sdkIngest x-cli-section: "Hook Runs" parameters: - name: buildHashedId @@ -375,7 +369,6 @@ paths: tags: [TestReporting] summary: Add Build Logs (ingestion) operationId: addTestReportingBuildLogs - x-base-url: sdkIngest x-cli-section: "Builds" parameters: - name: buildHashedId diff --git a/packages/test-reporting/src/__tests__/ingest.live.test.ts b/packages/test-reporting/src/__tests__/ingest.live.test.ts new file mode 100644 index 0000000..5878136 --- /dev/null +++ b/packages/test-reporting/src/__tests__/ingest.live.test.ts @@ -0,0 +1,49 @@ +import { randomUUID } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { TestReportingClient } from "../index.ts"; +import { TestReportingIngestClient } from "../ingest.ts"; + +const username = process.env.BROWSERSTACK_USERNAME; +const accessKey = process.env.BROWSERSTACK_ACCESS_KEY ?? process.env.BROWSERSTACK_KEY; + +// End to end against the real Test Reporting & Analytics ingestion API: report a run, then read it back. +describe.skipIf(!username || !accessKey)("TestReportingIngestClient (live)", () => { + it("reports a build with a passed and a failed test and reads them back", async () => { + const ingest = new TestReportingIngestClient({ username, accessKey }); + const now = () => new Date().toISOString(); + const { buildHashedId } = await ingest.startBuild({ + projectName: "sdk-integration-tests", + name: `ingest-live ${now()}`, + startedAt: now(), + framework: { name: "vitest", version: "4" }, + tags: ["sdk-test"], + }); + expect(buildHashedId).toMatch(/^[a-z0-9]{40}$/); + + const run = (name: string) => ({ uuid: randomUUID(), type: "test", name, scope: `live > ${name}`, scopes: ["live"], file_name: "ingest.live.test.ts", framework: "vitest" }); + const ok = run("passes"); + const bad = run("fails"); + await ingest.sendEvents([ + { event_type: "TestRunStarted", test_run: { ...ok, started_at: now(), result: "pending" } }, + { event_type: "TestRunStarted", test_run: { ...bad, started_at: now(), result: "pending" } }, + ]); + await ingest.sendEvents([ + { event_type: "LogCreated", logs: [{ kind: "TEST_LOG", test_run_uuid: bad.uuid, level: "ERROR", message: "boom", timestamp: now(), http_response: {} }] }, + { event_type: "TestRunFinished", test_run: { ...ok, finished_at: now(), result: "passed", duration_in_ms: 5 } }, + { + event_type: "TestRunFinished", + test_run: { ...bad, finished_at: now(), result: "failed", duration_in_ms: 5, failure: [{ backtrace: ["boom", "at x"] }], failure_reason: "boom", failure_type: "AssertionError" }, + }, + ]); + await ingest.stopBuild(now()); + + const reader = new TestReportingClient({ username, accessKey }); + let summary: { passed?: number; failed?: number } | undefined; + for (let i = 0; i < 20; i++) { + summary = (await reader.getTestRuns(buildHashedId)).testSummary; + if ((summary?.passed ?? 0) + (summary?.failed ?? 0) >= 2) break; + await new Promise((r) => setTimeout(r, 3000)); + } + expect(summary).toMatchObject({ passed: 1, failed: 1 }); + }, 90_000); +}); diff --git a/packages/test-reporting/src/__tests__/ingest.test.ts b/packages/test-reporting/src/__tests__/ingest.test.ts new file mode 100644 index 0000000..25e8d2a --- /dev/null +++ b/packages/test-reporting/src/__tests__/ingest.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { TestReportingIngestClient } from "../ingest.ts"; + +function setup(responses: Array<{ status?: number; body?: unknown }>) { + const calls: Array<{ url: string; method: string; headers: Record; body: unknown }> = []; + const queue = [...responses]; + const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ + url: url.toString(), + method: init?.method ?? "GET", + headers: Object.fromEntries(new Headers(init?.headers).entries()), + body: init?.body ? JSON.parse(init.body as string) : undefined, + }); + const r = queue.shift() ?? { body: {} }; + return new Response(JSON.stringify(r.body ?? {}), { status: r.status ?? 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + return { calls, client: new TestReportingIngestClient({ username: "u", accessKey: "k", fetchFn }) }; +} + +const build = { projectName: "proj", name: "build", startedAt: "2026-01-01T00:00:00Z", framework: { name: "vitest", version: "4" } }; + +describe("TestReportingIngestClient", () => { + it("starts a build on the collector with basic auth and keeps the returned id and JWT", async () => { + const { client, calls } = setup([{ body: { build_hashed_id: "bld", jwt: "tok" } }]); + const res = await client.startBuild({ ...build, tags: ["ci"], ciInfo: { name: "GitHub Actions", buildUrl: "https://ci/1" }, versionControl: { sha: "abc", commitMessage: "m" } }); + expect(res).toEqual({ buildHashedId: "bld", jwt: "tok" }); + expect(calls[0].url).toBe("https://collector-observability.browserstack.com/api/v2/builds"); + expect(calls[0].method).toBe("POST"); + expect(calls[0].headers.authorization).toBe(`Basic ${btoa("u:k")}`); + expect(calls[0].body).toMatchObject({ + format: "json", + project_name: "proj", + name: "build", + started_at: "2026-01-01T00:00:00Z", + tags: ["ci"], + ci_info: { name: "GitHub Actions", build_url: "https://ci/1" }, + version_control: { sha: "abc", commit_message: "m" }, + product_map: { observability: true }, + framework_details: { frameworkName: "vitest", frameworkVersion: "4", testFramework: { name: "vitest", version: "4" } }, + }); + }); + + it("fails when the build start returns no JWT", async () => { + const { client } = setup([{ body: { build_hashed_id: "bld" } }]); + await expect(client.startBuild(build)).rejects.toThrow(/jwt/i); + }); + + it("sends events in one batch with the bearer JWT", async () => { + const { client, calls } = setup([{ body: { build_hashed_id: "bld", jwt: "tok" } }, { body: { success: true } }]); + await client.startBuild(build); + await client.sendEvents([{ event_type: "TestRunStarted", test_run: { uuid: "1" } }]); + expect(calls[1].url).toBe("https://collector-observability.browserstack.com/api/v1/batch"); + expect(calls[1].headers.authorization).toBe("Bearer tok"); + expect(calls[1].body).toEqual([{ event_type: "TestRunStarted", test_run: { uuid: "1" } }]); + }); + + it("stops the build", async () => { + const { client, calls } = setup([{ body: { build_hashed_id: "bld", jwt: "tok" } }, { body: {} }]); + await client.startBuild(build); + await client.stopBuild("2026-01-01T00:01:00Z"); + expect(calls[1].url).toBe("https://collector-observability.browserstack.com/api/v1/builds/bld/stop"); + expect(calls[1].method).toBe("PUT"); + expect(calls[1].body).toEqual({ stop_time: "2026-01-01T00:01:00Z" }); + }); + + it("throws before a build is started and on HTTP errors", async () => { + const a = setup([]); + await expect(a.client.sendEvents([])).rejects.toThrow(/startBuild/); + const b = setup([{ status: 403, body: { message: "no" } }]); + await expect(b.client.startBuild(build)).rejects.toThrow(/403/); + }); +}); diff --git a/packages/test-reporting/src/__tests__/test-reporting.test.ts b/packages/test-reporting/src/__tests__/test-reporting.test.ts index 3918085..b914342 100644 --- a/packages/test-reporting/src/__tests__/test-reporting.test.ts +++ b/packages/test-reporting/src/__tests__/test-reporting.test.ts @@ -224,47 +224,6 @@ describe("TestReportingClient", () => { }); }); - describe("ingestion base URL", () => { - function capture() { - const urls: string[] = []; - const fetchFn = async (url: string | URL | Request) => { - urls.push(url.toString()); - return new Response(JSON.stringify({ success: true }), { status: 200, headers: { "content-type": "application/json" } }); - }; - return { urls, client: new TestReportingClient({ username: "u", accessKey: "k", fetchFn }) }; - } - const body = { name: "n", projectName: "p", startedAt: "2026-01-01T00:00:00Z", framework: { name: "vitest", version: "4" } }; - - it("sends every ingestion call to collector-observability, not api-automation", async () => { - const { urls, client } = capture(); - await client.startBuild(body); - await client.startTestRun("bld", { name: "t", fileName: "a.test.ts", scopes: ["s"], startedAt: "2026-01-01T00:00:00Z" }); - await client.finishTestRun("bld", "run", { result: "passed", finishedAt: "2026-01-01T00:00:01Z", fileName: "a.test.ts", scopes: ["s"] }); - await client.addBuildLogs("bld", { logs: [{ kind: "TEST_LOG", message: "hi", timestamp: "2026-01-01T00:00:00Z" }] }); - await client.finishBuild("bld", { finishedAt: "2026-01-01T00:00:02Z" }); - expect(urls).toEqual([ - "https://collector-observability.browserstack.com/ext/v1/builds/start", - "https://collector-observability.browserstack.com/ext/v1/builds/bld/tests/start", - "https://collector-observability.browserstack.com/ext/v1/builds/bld/tests/run/finish", - "https://collector-observability.browserstack.com/ext/v1/builds/bld/logs", - "https://collector-observability.browserstack.com/ext/v1/builds/bld/finish", - ]); - }); - - it("keeps read APIs on api-automation", async () => { - const { urls, client } = capture(); - await client.getProjects(); - expect(urls[0]).toMatch(/^https:\/\/api-automation\.browserstack\.com\/ext\/v1\/projects/); - }); - - it("honours ingestBaseUrl", async () => { - const urls: string[] = []; - const fetchFn = async (url: string | URL | Request) => (urls.push(url.toString()), new Response("{}", { status: 200, headers: { "content-type": "application/json" } })); - await new TestReportingClient({ username: "u", accessKey: "k", fetchFn, ingestBaseUrl: "https://example.test/ext/v1" }).startBuild(body); - expect(urls[0]).toBe("https://example.test/ext/v1/builds/start"); - }); - }); - describe("uploadReport", () => { function makeCapturingClient() { let capturedUrl = ""; diff --git a/packages/test-reporting/src/index.ts b/packages/test-reporting/src/index.ts index 133dae5..fbdc342 100644 --- a/packages/test-reporting/src/index.ts +++ b/packages/test-reporting/src/index.ts @@ -18,11 +18,11 @@ import { } from "./linked-sessions"; export type * from "./linked-sessions"; +export { TestReportingIngestClient } from "./ingest"; +export type { IngestBuildRequest, IngestEvent, TestReportingIngestOptions } from "./ingest"; export interface TestReportingClientOptions extends BrowserStackOptions { uploadBaseUrl?: string; - /** Base URL of the ingestion (collector) API: start/finish build, test and hook runs, build logs. */ - ingestBaseUrl?: string; /** * Used to resolve a test's session and fetch its logs (see `getTestSession`). Defaults to a client built from * these same options, minus the Test Reporting base URLs. Pass one to customise it (base URL, timeout, ...). @@ -44,8 +44,7 @@ export class TestReportingClient extends GeneratedTestReportingClient { options?.baseUrl ?? "https://api-automation.browserstack.com/ext/v1", options?.uploadBaseUrl ?? "https://upload-automation.browserstack.com", "@dot-slash/browserstack-test-reporting", - __PKG_VERSION__, - options?.ingestBaseUrl ?? "https://collector-observability.browserstack.com/ext/v1" + __PKG_VERSION__ ); const shared: TestReportingClientOptions = { ...options }; this.automateClient = shared.automate; @@ -53,7 +52,6 @@ export class TestReportingClient extends GeneratedTestReportingClient { // Those base URLs are Test Reporting's, so the sibling clients keep their own. delete shared.baseUrl; delete shared.uploadBaseUrl; - delete shared.ingestBaseUrl; delete shared.automate; delete shared.appAutomate; this.siblingOptions = shared; diff --git a/packages/test-reporting/src/ingest.ts b/packages/test-reporting/src/ingest.ts new file mode 100644 index 0000000..6db523a --- /dev/null +++ b/packages/test-reporting/src/ingest.ts @@ -0,0 +1,102 @@ +import { BrowserStackError, type BrowserStackOptions } from "@dot-slash/browserstack-core"; + +export interface IngestBuildRequest { + projectName: string; + name: string; + startedAt: string; + framework: { name: string; version: string }; + tags?: string[]; + buildRunIdentifier?: string; + hostInfo?: { hostname?: string; platform?: string; type?: string; version?: string; arch?: string }; + ciInfo?: { name?: string; buildUrl?: string; url?: string; buildNumber?: string; jobName?: string }; + versionControl?: { name?: string; sha?: string; branch?: string; tag?: string; commitMessage?: string; committerName?: string; committerEmail?: string }; +} + +/** An event for `sendEvents`: TestRunStarted/TestRunFinished (`test_run`), HookRun* (`hook_run`) or LogCreated (`logs`). */ +export interface IngestEvent { + event_type: "TestRunStarted" | "TestRunFinished" | "HookRunStarted" | "HookRunFinished" | "LogCreated" | (string & {}); + [key: string]: unknown; +} + +export interface TestReportingIngestOptions extends Pick { + /** Defaults to the Test Reporting & Analytics collector. */ + baseUrl?: string; +} + +/** + * Live ingestion into Test Reporting & Analytics, the way the BrowserStack SDKs (e.g. WebdriverIO) report results: start a + * build with basic auth (which returns a build id and a JWT), stream test, hook and log events with the JWT, then stop the build. + */ +export class TestReportingIngestClient { + private readonly baseUrl: string; + private readonly basicAuth: string; + private readonly fetchFn: typeof fetch; + private build: { buildHashedId: string; jwt: string } | undefined; + + constructor(options: TestReportingIngestOptions) { + const username = options.username ?? process.env.BROWSERSTACK_USERNAME; + const accessKey = options.accessKey ?? process.env.BROWSERSTACK_ACCESS_KEY ?? process.env.BROWSERSTACK_KEY; + if (!username || !accessKey) throw new BrowserStackError("Missing username or accessKey"); + this.basicAuth = `Basic ${Buffer.from(`${username}:${accessKey}`).toString("base64")}`; + this.baseUrl = (options.baseUrl ?? "https://collector-observability.browserstack.com").replace(/\/$/, ""); + this.fetchFn = options.fetchFn ?? fetch; + } + + async startBuild(req: IngestBuildRequest): Promise<{ buildHashedId: string; jwt: string }> { + const res = await this.request<{ build_hashed_id?: string; jwt?: string }>("POST", "/api/v2/builds", this.basicAuth, { + format: "json", + project_name: req.projectName, + name: req.name, + started_at: req.startedAt, + tags: req.tags, + build_run_identifier: req.buildRunIdentifier, + host_info: req.hostInfo, + ci_info: req.ciInfo && { name: req.ciInfo.name, build_url: req.ciInfo.buildUrl, url: req.ciInfo.url, build_number: req.ciInfo.buildNumber, job_name: req.ciInfo.jobName }, + version_control: req.versionControl && { + name: req.versionControl.name, + sha: req.versionControl.sha, + branch: req.versionControl.branch, + tag: req.versionControl.tag, + commit_message: req.versionControl.commitMessage, + committer_name: req.versionControl.committerName, + committer_email: req.versionControl.committerEmail, + }, + framework_details: { + frameworkName: req.framework.name, + frameworkVersion: req.framework.version, + sdkVersion: req.framework.version, + language: "ECMAScript", + testFramework: { name: req.framework.name, version: req.framework.version }, + }, + product_map: { observability: true }, + config: {}, + }); + if (!res.build_hashed_id || !res.jwt) throw new BrowserStackError("Build start returned no build id or jwt (is Test Reporting enabled for this account?)"); + this.build = { buildHashedId: res.build_hashed_id, jwt: res.jwt }; + return this.build; + } + + async sendEvents(events: IngestEvent[]): Promise { + await this.request("POST", "/api/v1/batch", this.bearer("sendEvents"), events); + } + + async stopBuild(stopTime: string): Promise { + await this.request("PUT", `/api/v1/builds/${this.build?.buildHashedId}/stop`, this.bearer("stopBuild"), { stop_time: stopTime }); + } + + private bearer(method: string): string { + if (!this.build) throw new BrowserStackError(`${method}() called before startBuild()`); + return `Bearer ${this.build.jwt}`; + } + + private async request(method: string, path: string, authorization: string, body: unknown): Promise { + const res = await this.fetchFn(`${this.baseUrl}${path}`, { + method, + headers: { "content-type": "application/json", authorization }, + body: JSON.stringify(body), + }); + const text = await res.text(); + if (!res.ok) throw new BrowserStackError(`${method} ${path} failed: HTTP ${res.status}${text ? ` ${text.slice(0, 300)}` : ""}`); + return (text ? JSON.parse(text) : {}) as T; + } +} diff --git a/scripts/ci-report/tra-reporter.mjs b/scripts/ci-report/tra-reporter.mjs index c0aa6a1..f013161 100644 --- a/scripts/ci-report/tra-reporter.mjs +++ b/scripts/ci-report/tra-reporter.mjs @@ -4,6 +4,7 @@ // Best-effort: every API error is logged and swallowed, so an unreachable BrowserStack never fails CI. import { relative } from "node:path"; import os from "node:os"; +import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; const warn = (what, e) => console.warn(`[tra] ${what} failed (ignored): ${e instanceof Error ? e.message : e}`); @@ -17,17 +18,18 @@ export class TraReporter { // The GitHub Actions event payload (push / pull_request) has the commit and PR details env vars lack. this.event = event ?? (env.GITHUB_EVENT_PATH ? JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf-8")) : {}); this.enabled = Boolean(client); - this.build = undefined; // Promise (buildHashedId) - this.tests = new Map(); // test id -> Promise<{ buildId, uuid } | undefined> - this.logs = []; // pending build log entries, flushed at the end of the run + this.started = false; + this.chain = Promise.resolve(); + this.uuids = new Map(); // vitest test id -> run uuid + this.startedAt = new Map(); } static async fromEnv(env = process.env) { const username = env.BROWSERSTACK_USERNAME; const accessKey = env.BROWSERSTACK_ACCESS_KEY || env.BROWSERSTACK_KEY; if (!username || !accessKey) return new TraReporter({ env }); - const { TestReportingClient } = await import("../../packages/test-reporting/dist/index.js"); - return new TraReporter({ client: new TestReportingClient({ username, accessKey }), env }); + const { TestReportingIngestClient } = await import("../../packages/test-reporting/dist/index.js"); + return new TraReporter({ client: new TestReportingIngestClient({ username, accessKey }), env }); } buildRequest() { @@ -73,99 +75,129 @@ export class TraReporter { return req; } + // Events are sent one at a time, in order, and never throw. + enqueue(what, fn) { + this.chain = this.chain.then(async () => { + if (!this.started) return; + try { + await fn(); + } catch (e) { + warn(what, e); + } + }); + return this.chain; + } + async onTestRunStart() { if (!this.enabled) return; - this.build = this.client - .startBuild(this.buildRequest()) - .then((res) => res?.buildHashedId ?? res?.build_hashed_id) - .catch((e) => (warn("startBuild", e), undefined)); - await this.build; + try { + await this.client.startBuild(this.buildRequest()); + this.started = true; + } catch (e) { + warn("startBuild", e); + } } - async onTestCaseReady(testCase) { - if (!this.enabled) return; - const started = (async () => { - const buildId = await this.build; - if (!buildId) return undefined; - try { - const parts = testCase.fullName.split(" > "); - const res = await this.client.startTestRun(buildId, { - name: testCase.name, - fileName: relative(this.cwd, testCase.module.moduleId).replaceAll("\\", "/"), - scopes: [testCase.project.name, ...parts.slice(0, -1)].slice(0, 20), - startedAt: iso(), - ...(testCase.location ? { location: `${testCase.location.line}:${testCase.location.column}` } : {}), - }); - const uuid = res?.uuid ?? res?.testRunId; - return uuid ? { buildId, uuid, startedAt: Date.now() } : undefined; - } catch (e) { - warn("startTestRun", e); - return undefined; - } - })(); - this.tests.set(testCase.id, started); - await started; + runData(testCase, extra) { + const parts = testCase.fullName.split(" > "); + const file = relative(this.cwd, testCase.module.moduleId).replaceAll("\\", "/"); + return { + uuid: this.uuids.get(testCase.id), + type: "test", + name: testCase.name, + scope: testCase.fullName, + scopes: [testCase.project.name, ...parts.slice(0, -1)].slice(0, 20), + identifier: `${file} > ${testCase.fullName}`, + file_name: file, + location: file, + framework: "vitest", + ...extra, + }; } - async onUserConsoleLog(log) { - if (!this.enabled || !log.taskId) return; - const run = await this.tests.get(log.taskId); - if (!run) return; - this.logs.push({ - kind: "TEST_LOG", - testRunUuid: run.uuid, - level: log.type === "stderr" ? "ERROR" : "INFO", - message: String(log.content).replace(/\n$/, "").slice(0, 10000), - timestamp: new Date(log.time ?? Date.now()).toISOString(), - }); + onTestCaseReady(testCase) { + if (!this.enabled) return; + this.uuids.set(testCase.id, randomUUID()); + const started = iso(); + this.startedAt.set(testCase.id, Date.now()); + return this.enqueue("TestRunStarted", () => + this.client.sendEvents([{ event_type: "TestRunStarted", test_run: this.runData(testCase, { started_at: started, result: "pending" }) }]) + ); } - async onTestCaseResult(testCase) { + onUserConsoleLog(log) { if (!this.enabled) return; - const run = await this.tests.get(testCase.id); - if (!run) return; - try { - const result = testCase.result(); - const state = result.state === "pending" ? "skipped" : result.state; - const parts = testCase.fullName.split(" > "); + const uuid = this.uuids.get(log.taskId); + if (!uuid) return; + return this.enqueue("LogCreated", () => + this.client.sendEvents([ + { + event_type: "LogCreated", + logs: [ + { + kind: "TEST_LOG", + test_run_uuid: uuid, + level: log.type === "stderr" ? "ERROR" : "INFO", + message: String(log.content).replace(/\n$/, "").slice(0, 10000), + timestamp: new Date(log.time ?? Date.now()).toISOString(), + http_response: {}, + }, + ], + }, + ]) + ); + } + + onTestCaseResult(testCase) { + if (!this.enabled || !this.uuids.has(testCase.id)) return; + const result = testCase.result(); + const state = result.state === "pending" ? "skipped" : result.state; + const errors = (result.errors ?? []).slice(0, 100); + const extra = { + finished_at: iso(), + result: state, + duration_in_ms: Math.round(testCase.diagnostic()?.duration ?? Date.now() - this.startedAt.get(testCase.id)), + }; + if (state === "failed" && errors.length) { + extra.failure = errors.map((x) => ({ backtrace: [x.message, x.stack ?? ""] })); + extra.failure_reason = errors[0].message; + extra.failure_type = /AssertionError/.test(errors[0].name ?? errors[0].message ?? "") ? "AssertionError" : "UnhandledError"; + } + return this.enqueue("TestRunFinished", async () => { + const uuid = this.uuids.get(testCase.id); + const events = []; if (state === "failed") { - for (const x of result.errors ?? []) { - this.logs.push({ kind: "TEST_LOG", testRunUuid: run.uuid, level: "ERROR", message: String(x.stack ?? x.message).slice(0, 10000), timestamp: iso(), failure: true }); + for (const x of errors) { + events.push({ + event_type: "LogCreated", + logs: [{ kind: "TEST_LOG", test_run_uuid: uuid, level: "ERROR", message: String(x.stack ?? x.message).slice(0, 10000), timestamp: iso(), http_response: {}, failure: true }], + }); } } - await this.client.finishTestRun(run.buildId, run.uuid, { - result: state, - finishedAt: iso(), - fileName: relative(this.cwd, testCase.module.moduleId).replaceAll("\\", "/"), - scopes: [testCase.project.name, ...parts.slice(0, -1)].slice(0, 20), - durationInMs: Math.round(testCase.diagnostic()?.duration ?? Date.now() - run.startedAt), - ...(state === "failed" ? { failure: (result.errors ?? []).slice(0, 100).map((x) => ({ error: x.message, backtrace: x.stack })) } : {}), - }); - } catch (e) { - warn("finishTestRun", e); - } + events.push({ event_type: "TestRunFinished", test_run: this.runData(testCase, extra) }); + await this.client.sendEvents(events); + }); } async onTestRunEnd(_modules, errors = []) { if (!this.enabled) return; - await Promise.all(this.tests.values()); - const buildId = await this.build; - if (!buildId) return; - // Unhandled errors (outside any test) belong to the build itself. + // Unhandled errors happen outside any test: report each as a failed run so they show up in the build. for (const x of errors) { - this.logs.push({ kind: "TEST_LOG", level: "ERROR", message: String(x.stack ?? x.message).slice(0, 10000), timestamp: iso(), failure: true }); - } - for (let i = 0; i < this.logs.length; i += 500) { - try { - await this.client.addBuildLogs(buildId, { logs: this.logs.slice(i, i + 500) }); - } catch (e) { - warn("addBuildLogs", e); - } + const uuid = randomUUID(); + const now = iso(); + const run = { + uuid, type: "test", name: `Unhandled ${x.name ?? "error"}`, scope: "unhandled errors", scopes: ["unhandled errors"], + identifier: `unhandled > ${x.message}`, file_name: "unhandled", framework: "vitest", started_at: now, finished_at: now, + result: "failed", duration_in_ms: 0, failure: [{ backtrace: [x.message, x.stack ?? ""] }], failure_reason: x.message, failure_type: "UnhandledError", + }; + this.enqueue("unhandled error", () => this.client.sendEvents([{ event_type: "TestRunStarted", test_run: { ...run, finished_at: undefined, result: "pending" } }, { event_type: "TestRunFinished", test_run: run }])); } + await this.chain; + if (!this.started) return; try { - await this.client.finishBuild(buildId, { finishedAt: iso() }); + await this.client.stopBuild(iso()); } catch (e) { - warn("finishBuild", e); + warn("stopBuild", e); } } } diff --git a/scripts/ci-report/tra-reporter.test.ts b/scripts/ci-report/tra-reporter.test.ts index 31e94e5..cec4a86 100644 --- a/scripts/ci-report/tra-reporter.test.ts +++ b/scripts/ci-report/tra-reporter.test.ts @@ -14,11 +14,9 @@ const env = { function fakeClient() { return { - startBuild: vi.fn(async () => ({ success: true, buildHashedId: "bld1" })), - startTestRun: vi.fn(async () => ({ success: true, uuid: "run1" })), - finishTestRun: vi.fn(async () => ({ success: true })), - finishBuild: vi.fn(async () => ({ success: true })), - addBuildLogs: vi.fn(async () => ({ success: true })), + startBuild: vi.fn(async () => ({ buildHashedId: "bld1", jwt: "tok" })), + sendEvents: vi.fn(async () => undefined), + stopBuild: vi.fn(async () => undefined), }; } @@ -28,17 +26,19 @@ const tc = (over: Record = {}) => ({ fullName: "suite > does a thing", module: { moduleId: "/repo/packages/core/src/a.test.ts" }, project: { name: "core" }, - location: { line: 3, column: 1 }, result: () => ({ state: "passed", errors: [] }), diagnostic: () => ({ duration: 12.4 }), ...over, }); +const make = (client = fakeClient(), extra: Record = {}) => new TraReporter({ client, env, cwd: "/repo", event: {}, ...extra }); +const events = (client: ReturnType) => client.sendEvents.mock.calls.flatMap((c) => c[0] as Array>); + describe("TraReporter", () => { it("starts the build when the run starts, with CI metadata", async () => { const client = fakeClient(); - const r = new TraReporter({ client, env, cwd: "/repo" }); - await r.onTestRunStart([]); + const r = make(client); + await r.onTestRunStart(); await r.onTestRunEnd([], []); expect(client.startBuild).toHaveBeenCalledWith( expect.objectContaining({ @@ -50,19 +50,15 @@ describe("TraReporter", () => { versionControl: expect.objectContaining({ sha: "abcdef1234567890", branch: "main" }), }) ); + expect(client.stopBuild).toHaveBeenCalledWith(expect.any(String)); }); it("captures PR context: head commit and branch, PR number/title, actor, event and run/job URLs", async () => { const client = fakeClient(); const event = { - pull_request: { - number: 34, - title: "ci: report to TRA", - html_url: "https://github.com/shirish87/browserstack-client/pull/34", - head: { ref: "feature/x", sha: "headsha1234567" }, - }, + pull_request: { number: 34, title: "ci: report to TRA", html_url: "https://github.com/shirish87/browserstack-client/pull/34", head: { ref: "feature/x", sha: "headsha1234567" } }, }; - const prEnv = { ...env, GITHUB_EVENT_NAME: "pull_request", GITHUB_HEAD_REF: "feature/x", GITHUB_ACTOR: "shirish87", GITHUB_JOB: "test-sdk", GITHUB_REF_NAME: "34/merge", GITHUB_SERVER_URL: "https://github.com", RUNNER_OS: "Linux" }; + const prEnv = { ...env, GITHUB_EVENT_NAME: "pull_request", GITHUB_HEAD_REF: "feature/x", GITHUB_ACTOR: "shirish87", GITHUB_JOB: "test-sdk", GITHUB_REF_NAME: "34/merge", RUNNER_OS: "Linux" }; const r = new TraReporter({ client, env: prEnv, cwd: "/repo", event }); await r.onTestRunStart(); expect(client.startBuild).toHaveBeenCalledWith( @@ -70,98 +66,93 @@ describe("TraReporter", () => { name: "CI #45 (feature/x) [linux]", tags: expect.arrayContaining(["ci", "linux", "feature/x", "pull_request"]), versionControl: expect.objectContaining({ sha: "headsha1234567", branch: "feature/x", commitMessage: "ci: report to TRA" }), - ciInfo: expect.objectContaining({ - name: "GitHub Actions", - buildUrl: "https://github.com/shirish87/browserstack-client/actions/runs/123", - url: "https://github.com/shirish87/browserstack-client/pull/34", - buildNumber: "45", - jobName: "test-sdk", - }), + ciInfo: expect.objectContaining({ name: "GitHub Actions", url: "https://github.com/shirish87/browserstack-client/pull/34", buildNumber: "45", jobName: "test-sdk" }), }) ); }); it("captures push context: head commit message and author from the event", async () => { const client = fakeClient(); - const event = { head_commit: { message: "fix: a thing\n\nbody", author: { name: "Ann", email: "ann@x.io" }, committer: { name: "Bob", email: "bob@x.io" } } }; + const event = { head_commit: { message: "fix: a thing\n\nbody", committer: { name: "Bob", email: "bob@x.io" } } }; const r = new TraReporter({ client, env: { ...env, GITHUB_EVENT_NAME: "push" }, cwd: "/repo", event }); await r.onTestRunStart(); expect(client.startBuild).toHaveBeenCalledWith( - expect.objectContaining({ - versionControl: expect.objectContaining({ sha: "abcdef1234567890", branch: "main", commitMessage: "fix: a thing", committerName: "Bob", committerEmail: "bob@x.io" }), - }) + expect.objectContaining({ versionControl: expect.objectContaining({ commitMessage: "fix: a thing", committerName: "Bob", committerEmail: "bob@x.io" }) }) ); }); - it("reports each test live: start when it is ready, finish when it has a result", async () => { + it("reports each test live: TestRunStarted when ready, TestRunFinished when it has a result", async () => { const client = fakeClient(); - const r = new TraReporter({ client, env, cwd: "/repo" }); - await r.onTestRunStart([]); + const r = make(client); + await r.onTestRunStart(); await r.onTestCaseReady(tc()); - expect(client.startTestRun).toHaveBeenCalledWith("bld1", expect.objectContaining({ name: "does a thing", scopes: ["core", "suite"], fileName: "packages/core/src/a.test.ts" })); - expect(client.finishTestRun).not.toHaveBeenCalled(); + const [started] = events(client); + expect(started).toMatchObject({ + event_type: "TestRunStarted", + test_run: { type: "test", name: "does a thing", scopes: ["core", "suite"], file_name: "packages/core/src/a.test.ts", framework: "vitest", result: "pending", uuid: expect.any(String) }, + }); await r.onTestCaseResult(tc()); - expect(client.finishTestRun).toHaveBeenCalledWith("bld1", "run1", expect.objectContaining({ result: "passed", durationInMs: 12 })); - await r.onTestRunEnd([], []); - expect(client.finishBuild).toHaveBeenCalledWith("bld1", expect.objectContaining({ finishedAt: expect.any(String) })); + const finished = events(client).find((e) => e.event_type === "TestRunFinished")!; + expect(finished.test_run).toMatchObject({ uuid: started.test_run.uuid, result: "passed", duration_in_ms: 12 }); }); - it("maps failures with error and backtrace", async () => { + it("reports failures with message, stack trace and an ERROR log", async () => { const client = fakeClient(); - const r = new TraReporter({ client, env, cwd: "/repo" }); - await r.onTestRunStart([]); + const r = make(client); + await r.onTestRunStart(); await r.onTestCaseReady(tc()); - await r.onTestCaseResult(tc({ result: () => ({ state: "failed", errors: [{ message: "boom", stack: "at x" }] }) })); - expect(client.finishTestRun).toHaveBeenCalledWith("bld1", "run1", expect.objectContaining({ result: "failed", failure: [{ error: "boom", backtrace: "at x" }] })); + await r.onTestCaseResult(tc({ result: () => ({ state: "failed", errors: [{ name: "AssertionError", message: "boom", stack: "Error: boom\n at x" }] }) })); + const ev = events(client); + const finished = ev.find((e) => e.event_type === "TestRunFinished")!; + expect(finished.test_run).toMatchObject({ result: "failed", failure: [{ backtrace: ["boom", "Error: boom\n at x"] }], failure_reason: "boom", failure_type: "AssertionError" }); + const log = ev.find((e) => e.event_type === "LogCreated")!; + expect(log.logs[0]).toMatchObject({ kind: "TEST_LOG", level: "ERROR", message: "Error: boom\n at x", test_run_uuid: finished.test_run.uuid, failure: true }); }); - it("sends test console output as TEST_LOG entries linked to the test run", async () => { + it("sends test console output as TEST_LOG entries linked to the run", async () => { const client = fakeClient(); - const r = new TraReporter({ client, env, cwd: "/repo" }); + const r = make(client); await r.onTestRunStart(); await r.onTestCaseReady(tc()); await r.onUserConsoleLog({ content: "hello\n", type: "stderr", taskId: "t1", time: 1759600000000 }); - await r.onTestRunEnd([], []); - expect(client.addBuildLogs).toHaveBeenCalledWith("bld1", { - logs: [expect.objectContaining({ kind: "TEST_LOG", testRunUuid: "run1", level: "ERROR", message: "hello", timestamp: new Date(1759600000000).toISOString() })], - }); + const log = events(client).find((e) => e.event_type === "LogCreated")!; + expect(log.logs[0]).toMatchObject({ kind: "TEST_LOG", level: "ERROR", message: "hello", timestamp: new Date(1759600000000).toISOString() }); }); - it("sends failure messages and unhandled errors as ERROR logs with stack traces", async () => { + it("reports unhandled errors as a failed run", async () => { const client = fakeClient(); - const r = new TraReporter({ client, env, cwd: "/repo" }); + const r = make(client); await r.onTestRunStart(); - await r.onTestCaseReady(tc()); - await r.onTestCaseResult(tc({ result: () => ({ state: "failed", errors: [{ message: "boom", stack: "Error: boom\n at x" }] }) })); await r.onTestRunEnd([], [{ name: "Error", message: "unhandled", stack: "Error: unhandled\n at y" }]); - const logs = client.addBuildLogs.mock.calls.flatMap((c) => (c[1] as { logs: unknown[] }).logs); - expect(logs).toContainEqual(expect.objectContaining({ kind: "TEST_LOG", testRunUuid: "run1", level: "ERROR", message: "Error: boom\n at x", failure: true })); - expect(logs).toContainEqual(expect.objectContaining({ kind: "TEST_LOG", level: "ERROR", message: "Error: unhandled\n at y", failure: true })); + const finished = events(client).find((e) => e.event_type === "TestRunFinished")!; + expect(finished.test_run).toMatchObject({ result: "failed", failure: [{ backtrace: ["unhandled", "Error: unhandled\n at y"] }] }); }); - it("never throws and stops calling once the build cannot be started", async () => { + it("never throws and sends nothing when the build cannot be started", async () => { const client = fakeClient(); client.startBuild.mockRejectedValue(new Error("unreachable")); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const r = new TraReporter({ client, env, cwd: "/repo" }); - await expect(r.onTestRunStart([])).resolves.toBeUndefined(); - await expect(r.onTestCaseReady(tc())).resolves.toBeUndefined(); - await expect(r.onTestCaseResult(tc())).resolves.toBeUndefined(); - await expect(r.onTestRunEnd([], [])).resolves.toBeUndefined(); - expect(client.startTestRun).not.toHaveBeenCalled(); - expect(client.finishBuild).not.toHaveBeenCalled(); + const r = make(client); + await r.onTestRunStart(); + await r.onTestCaseReady(tc()); + await r.onTestCaseResult(tc()); + await r.onTestRunEnd([], []); + expect(client.sendEvents).not.toHaveBeenCalled(); + expect(client.stopBuild).not.toHaveBeenCalled(); warn.mockRestore(); }); - it("swallows per-test API errors", async () => { + it("swallows per-event API errors and keeps going", async () => { const client = fakeClient(); - client.startTestRun.mockRejectedValue(new Error("500")); + client.sendEvents.mockRejectedValueOnce(new Error("500")); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const r = new TraReporter({ client, env, cwd: "/repo" }); - await r.onTestRunStart([]); + const r = make(client); + await r.onTestRunStart(); await r.onTestCaseReady(tc()); - await expect(r.onTestCaseResult(tc())).resolves.toBeUndefined(); - expect(client.finishTestRun).not.toHaveBeenCalled(); + await r.onTestCaseResult(tc()); + await r.onTestRunEnd([], []); + expect(client.sendEvents).toHaveBeenCalledTimes(2); + expect(client.stopBuild).toHaveBeenCalled(); warn.mockRestore(); }); From 73b74766c996629ebf765440af997f1013d8eda4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 20:39:59 +0000 Subject: [PATCH 5/7] fix(test-reporting): ingest client must not use Node globals The package builds declarations without Node types; use core's auth helpers (btoa-based) instead of process.env and Buffer. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WUdwzfDSUhTEqqEWDhi6tB --- packages/test-reporting/src/ingest.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/test-reporting/src/ingest.ts b/packages/test-reporting/src/ingest.ts index 6db523a..f60093f 100644 --- a/packages/test-reporting/src/ingest.ts +++ b/packages/test-reporting/src/ingest.ts @@ -1,4 +1,4 @@ -import { BrowserStackError, type BrowserStackOptions } from "@dot-slash/browserstack-core"; +import { BrowserStackError, buildBasicAuthHeader, resolveAccessKey, resolveUsername, type BrowserStackOptions } from "@dot-slash/browserstack-core"; export interface IngestBuildRequest { projectName: string; @@ -34,10 +34,10 @@ export class TestReportingIngestClient { private build: { buildHashedId: string; jwt: string } | undefined; constructor(options: TestReportingIngestOptions) { - const username = options.username ?? process.env.BROWSERSTACK_USERNAME; - const accessKey = options.accessKey ?? process.env.BROWSERSTACK_ACCESS_KEY ?? process.env.BROWSERSTACK_KEY; + const username = resolveUsername(options.username); + const accessKey = resolveAccessKey(options.accessKey); if (!username || !accessKey) throw new BrowserStackError("Missing username or accessKey"); - this.basicAuth = `Basic ${Buffer.from(`${username}:${accessKey}`).toString("base64")}`; + this.basicAuth = buildBasicAuthHeader(username, accessKey); this.baseUrl = (options.baseUrl ?? "https://collector-observability.browserstack.com").replace(/\/$/, ""); this.fetchFn = options.fetchFn ?? fetch; } From 740ee501237863deced831cb1f1a73caf7eddc51 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 20:46:45 +0000 Subject: [PATCH 6/7] feat(test-reporting): make the ingestion methods work live; add session link and screenshots TestReportingClient's startBuild/startTestRun/finishTestRun/hook/addBuildLogs/finishBuild now use the collector protocol the BrowserStack SDKs use (build start returns a JWT, events go to /api/v1/batch, build stop), keeping their signatures. Adds linkTestRunSession (CBTSessionCreated) and addTestScreenshots, as WebdriverIO sends them. Drops the separate ingest client. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WUdwzfDSUhTEqqEWDhi6tB --- .../src/__tests__/ingest.live.test.ts | 49 ----- .../src/__tests__/ingest.test.ts | 72 ------- .../src/__tests__/ingestion.live.test.ts | 48 +++++ .../src/__tests__/ingestion.test.ts | 132 ++++++++++++ .../src/__tests__/test-reporting.test.ts | 59 ------ packages/test-reporting/src/index.ts | 154 +++++++++++++- packages/test-reporting/src/ingest.ts | 102 ---------- packages/test-reporting/src/ingestion.ts | 143 +++++++++++++ scripts/ci-report/tra-reporter.mjs | 188 ++++++++---------- scripts/ci-report/tra-reporter.test.ts | 123 ++++++------ 10 files changed, 618 insertions(+), 452 deletions(-) delete mode 100644 packages/test-reporting/src/__tests__/ingest.live.test.ts delete mode 100644 packages/test-reporting/src/__tests__/ingest.test.ts create mode 100644 packages/test-reporting/src/__tests__/ingestion.live.test.ts create mode 100644 packages/test-reporting/src/__tests__/ingestion.test.ts delete mode 100644 packages/test-reporting/src/ingest.ts create mode 100644 packages/test-reporting/src/ingestion.ts diff --git a/packages/test-reporting/src/__tests__/ingest.live.test.ts b/packages/test-reporting/src/__tests__/ingest.live.test.ts deleted file mode 100644 index 5878136..0000000 --- a/packages/test-reporting/src/__tests__/ingest.live.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { describe, expect, it } from "vitest"; -import { TestReportingClient } from "../index.ts"; -import { TestReportingIngestClient } from "../ingest.ts"; - -const username = process.env.BROWSERSTACK_USERNAME; -const accessKey = process.env.BROWSERSTACK_ACCESS_KEY ?? process.env.BROWSERSTACK_KEY; - -// End to end against the real Test Reporting & Analytics ingestion API: report a run, then read it back. -describe.skipIf(!username || !accessKey)("TestReportingIngestClient (live)", () => { - it("reports a build with a passed and a failed test and reads them back", async () => { - const ingest = new TestReportingIngestClient({ username, accessKey }); - const now = () => new Date().toISOString(); - const { buildHashedId } = await ingest.startBuild({ - projectName: "sdk-integration-tests", - name: `ingest-live ${now()}`, - startedAt: now(), - framework: { name: "vitest", version: "4" }, - tags: ["sdk-test"], - }); - expect(buildHashedId).toMatch(/^[a-z0-9]{40}$/); - - const run = (name: string) => ({ uuid: randomUUID(), type: "test", name, scope: `live > ${name}`, scopes: ["live"], file_name: "ingest.live.test.ts", framework: "vitest" }); - const ok = run("passes"); - const bad = run("fails"); - await ingest.sendEvents([ - { event_type: "TestRunStarted", test_run: { ...ok, started_at: now(), result: "pending" } }, - { event_type: "TestRunStarted", test_run: { ...bad, started_at: now(), result: "pending" } }, - ]); - await ingest.sendEvents([ - { event_type: "LogCreated", logs: [{ kind: "TEST_LOG", test_run_uuid: bad.uuid, level: "ERROR", message: "boom", timestamp: now(), http_response: {} }] }, - { event_type: "TestRunFinished", test_run: { ...ok, finished_at: now(), result: "passed", duration_in_ms: 5 } }, - { - event_type: "TestRunFinished", - test_run: { ...bad, finished_at: now(), result: "failed", duration_in_ms: 5, failure: [{ backtrace: ["boom", "at x"] }], failure_reason: "boom", failure_type: "AssertionError" }, - }, - ]); - await ingest.stopBuild(now()); - - const reader = new TestReportingClient({ username, accessKey }); - let summary: { passed?: number; failed?: number } | undefined; - for (let i = 0; i < 20; i++) { - summary = (await reader.getTestRuns(buildHashedId)).testSummary; - if ((summary?.passed ?? 0) + (summary?.failed ?? 0) >= 2) break; - await new Promise((r) => setTimeout(r, 3000)); - } - expect(summary).toMatchObject({ passed: 1, failed: 1 }); - }, 90_000); -}); diff --git a/packages/test-reporting/src/__tests__/ingest.test.ts b/packages/test-reporting/src/__tests__/ingest.test.ts deleted file mode 100644 index 25e8d2a..0000000 --- a/packages/test-reporting/src/__tests__/ingest.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { TestReportingIngestClient } from "../ingest.ts"; - -function setup(responses: Array<{ status?: number; body?: unknown }>) { - const calls: Array<{ url: string; method: string; headers: Record; body: unknown }> = []; - const queue = [...responses]; - const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => { - calls.push({ - url: url.toString(), - method: init?.method ?? "GET", - headers: Object.fromEntries(new Headers(init?.headers).entries()), - body: init?.body ? JSON.parse(init.body as string) : undefined, - }); - const r = queue.shift() ?? { body: {} }; - return new Response(JSON.stringify(r.body ?? {}), { status: r.status ?? 200, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - return { calls, client: new TestReportingIngestClient({ username: "u", accessKey: "k", fetchFn }) }; -} - -const build = { projectName: "proj", name: "build", startedAt: "2026-01-01T00:00:00Z", framework: { name: "vitest", version: "4" } }; - -describe("TestReportingIngestClient", () => { - it("starts a build on the collector with basic auth and keeps the returned id and JWT", async () => { - const { client, calls } = setup([{ body: { build_hashed_id: "bld", jwt: "tok" } }]); - const res = await client.startBuild({ ...build, tags: ["ci"], ciInfo: { name: "GitHub Actions", buildUrl: "https://ci/1" }, versionControl: { sha: "abc", commitMessage: "m" } }); - expect(res).toEqual({ buildHashedId: "bld", jwt: "tok" }); - expect(calls[0].url).toBe("https://collector-observability.browserstack.com/api/v2/builds"); - expect(calls[0].method).toBe("POST"); - expect(calls[0].headers.authorization).toBe(`Basic ${btoa("u:k")}`); - expect(calls[0].body).toMatchObject({ - format: "json", - project_name: "proj", - name: "build", - started_at: "2026-01-01T00:00:00Z", - tags: ["ci"], - ci_info: { name: "GitHub Actions", build_url: "https://ci/1" }, - version_control: { sha: "abc", commit_message: "m" }, - product_map: { observability: true }, - framework_details: { frameworkName: "vitest", frameworkVersion: "4", testFramework: { name: "vitest", version: "4" } }, - }); - }); - - it("fails when the build start returns no JWT", async () => { - const { client } = setup([{ body: { build_hashed_id: "bld" } }]); - await expect(client.startBuild(build)).rejects.toThrow(/jwt/i); - }); - - it("sends events in one batch with the bearer JWT", async () => { - const { client, calls } = setup([{ body: { build_hashed_id: "bld", jwt: "tok" } }, { body: { success: true } }]); - await client.startBuild(build); - await client.sendEvents([{ event_type: "TestRunStarted", test_run: { uuid: "1" } }]); - expect(calls[1].url).toBe("https://collector-observability.browserstack.com/api/v1/batch"); - expect(calls[1].headers.authorization).toBe("Bearer tok"); - expect(calls[1].body).toEqual([{ event_type: "TestRunStarted", test_run: { uuid: "1" } }]); - }); - - it("stops the build", async () => { - const { client, calls } = setup([{ body: { build_hashed_id: "bld", jwt: "tok" } }, { body: {} }]); - await client.startBuild(build); - await client.stopBuild("2026-01-01T00:01:00Z"); - expect(calls[1].url).toBe("https://collector-observability.browserstack.com/api/v1/builds/bld/stop"); - expect(calls[1].method).toBe("PUT"); - expect(calls[1].body).toEqual({ stop_time: "2026-01-01T00:01:00Z" }); - }); - - it("throws before a build is started and on HTTP errors", async () => { - const a = setup([]); - await expect(a.client.sendEvents([])).rejects.toThrow(/startBuild/); - const b = setup([{ status: 403, body: { message: "no" } }]); - await expect(b.client.startBuild(build)).rejects.toThrow(/403/); - }); -}); diff --git a/packages/test-reporting/src/__tests__/ingestion.live.test.ts b/packages/test-reporting/src/__tests__/ingestion.live.test.ts new file mode 100644 index 0000000..f2786ca --- /dev/null +++ b/packages/test-reporting/src/__tests__/ingestion.live.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { TestReportingClient } from "../index.ts"; + +const username = process.env.BROWSERSTACK_USERNAME; +const accessKey = process.env.BROWSERSTACK_ACCESS_KEY ?? process.env.BROWSERSTACK_KEY; + +// End to end against the real Test Reporting & Analytics ingestion API: report a run, then read it back. +describe.skipIf(!username || !accessKey)("TestReportingClient live ingestion", () => { + it("reports a build with a passed and a failed test and reads them back", async () => { + const client = new TestReportingClient({ username, accessKey }); + const now = () => new Date().toISOString(); + const { buildHashedId } = await client.startBuild({ + projectName: "sdk-integration-tests", + name: `ingestion-live ${now()}`, + startedAt: now(), + framework: { name: "vitest", version: "4" }, + tags: ["sdk-test"], + }); + expect(buildHashedId).toMatch(/^[a-z0-9]{40}$/); + + const ok = await client.startTestRun(buildHashedId!, { name: "passes", fileName: "ingestion.live.test.ts", scopes: ["live"], startedAt: now() }); + const bad = await client.startTestRun(buildHashedId!, { name: "fails", fileName: "ingestion.live.test.ts", scopes: ["live"], startedAt: now() }); + await client.addBuildLogs(buildHashedId!, { logs: [{ kind: "TEST_LOG", testRunUuid: bad.uuid, level: "ERROR", message: "boom", timestamp: now() }] }); + await client.finishTestRun(buildHashedId!, ok.uuid!, { result: "passed", finishedAt: now(), fileName: "ingestion.live.test.ts", scopes: ["live"], durationInMs: 5 }); + await client.finishTestRun(buildHashedId!, bad.uuid!, { + result: "failed", + finishedAt: now(), + fileName: "ingestion.live.test.ts", + scopes: ["live"], + durationInMs: 5, + failure: [{ error: "AssertionError: boom", backtrace: "at x" }], + }); + await client.finishBuild(buildHashedId!, { finishedAt: now() }); + + const reader = client; + let summary: { passed?: number; failed?: number } | undefined; + for (let i = 0; i < 20; i++) { + try { + summary = (await reader.getTestRuns(buildHashedId!)).testSummary; // the build can take a moment to become readable + } catch { + summary = undefined; + } + if ((summary?.passed ?? 0) + (summary?.failed ?? 0) >= 2) break; + await new Promise((r) => setTimeout(r, 3000)); + } + expect(summary).toMatchObject({ passed: 1, failed: 1 }); + }, 90_000); +}); diff --git a/packages/test-reporting/src/__tests__/ingestion.test.ts b/packages/test-reporting/src/__tests__/ingestion.test.ts new file mode 100644 index 0000000..c8d1a41 --- /dev/null +++ b/packages/test-reporting/src/__tests__/ingestion.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { BrowserStackError } from "@dot-slash/browserstack-core"; +import { HttpError } from "@dot-slash/browserstack-openapi-transforms"; +import { TestReportingClient } from "../index.ts"; + +const COLLECTOR = "https://collector-observability.browserstack.com"; + +function setup(startBody: unknown = { build_hashed_id: "bld", jwt: "tok" }) { + const calls: Array<{ url: string; method: string; headers: Record; body: any }> = []; + const queue: Array<{ status?: number; body: unknown }> = [{ body: startBody }]; + const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: url.toString(), method: init?.method ?? "GET", headers: Object.fromEntries(new Headers(init?.headers).entries()), body: init?.body ? JSON.parse(init.body as string) : undefined }); + const r = queue.shift() ?? { body: { success: true } }; + return new Response(JSON.stringify(r.body), { status: r.status ?? 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const client = new TestReportingClient({ username: "u", accessKey: "k", fetchFn }); + return { client, calls, queue }; +} + +const build = { name: "build", projectName: "proj", startedAt: "2026-01-01T00:00:00Z", framework: { name: "vitest", version: "4" } }; +const started = async (s = setup()) => ({ ...s, id: (await s.client.startBuild(build)).buildHashedId as string }); + +describe("live ingestion (TestReportingClient)", () => { + it("startBuild posts to the collector with basic auth and returns the build id", async () => { + const { client, calls } = setup(); + const res = await client.startBuild({ ...build, tags: ["ci"], ciInfo: { name: "GitHub Actions", buildUrl: "https://ci/1" }, versionControl: { sha: "abc", commitMessage: "m" } } as never); + expect(res).toEqual({ success: true, buildHashedId: "bld" }); + expect(calls[0].url).toBe(`${COLLECTOR}/api/v2/builds`); + expect(calls[0].headers.authorization).toBe(`Basic ${btoa("u:k")}`); + expect(calls[0].body).toMatchObject({ + format: "json", + project_name: "proj", + name: "build", + started_at: "2026-01-01T00:00:00Z", + tags: ["ci"], + ci_info: { name: "GitHub Actions", build_url: "https://ci/1" }, + version_control: { sha: "abc", commit_message: "m" }, + product_map: { observability: true }, + framework_details: { frameworkName: "vitest", frameworkVersion: "4", testFramework: { name: "vitest", version: "4" } }, + }); + }); + + it("startBuild fails without a JWT", async () => { + await expect(setup({ build_hashed_id: "bld" }).client.startBuild(build)).rejects.toThrow(/JWT/); + }); + + it("honours ingestBaseUrl", async () => { + const urls: string[] = []; + const fetchFn = (async (url: string | URL | Request) => (urls.push(url.toString()), new Response(JSON.stringify({ build_hashed_id: "b", jwt: "t" }), { status: 200 }))) as typeof fetch; + await new TestReportingClient({ username: "u", accessKey: "k", fetchFn, ingestBaseUrl: "https://example.test/" }).startBuild(build); + expect(urls[0]).toBe("https://example.test/api/v2/builds"); + }); + + it("startTestRun sends TestRunStarted with the build JWT and returns the run uuid", async () => { + const { client, calls, id } = await started(); + const res = await client.startTestRun(id, { name: "t", fileName: "a.test.ts", scopes: ["unit", "suite"], startedAt: "2026-01-01T00:00:01Z" }); + expect(res.uuid).toMatch(/^[0-9a-f-]{36}$/); + expect(calls[1].url).toBe(`${COLLECTOR}/api/v1/batch`); + expect(calls[1].headers.authorization).toBe("Bearer tok"); + expect(calls[1].body).toEqual([ + { event_type: "TestRunStarted", test_run: expect.objectContaining({ uuid: res.uuid, type: "test", name: "t", file_name: "a.test.ts", scopes: ["unit", "suite"], scope: "unit > suite", framework: "vitest", result: "pending", started_at: "2026-01-01T00:00:01Z" }) }, + ]); + }); + + it("finishTestRun sends TestRunFinished merged with the start data, including failure details", async () => { + const { client, calls, id } = await started(); + const { uuid } = await client.startTestRun(id, { name: "t", fileName: "a.test.ts", scopes: ["unit"], startedAt: "2026-01-01T00:00:01Z" }); + await client.finishTestRun(id, uuid as string, { + result: "failed", + finishedAt: "2026-01-01T00:00:02Z", + fileName: "a.test.ts", + scopes: ["unit"], + durationInMs: 1000, + failure: [{ error: "AssertionError: boom", backtrace: "at x" }], + }); + expect(calls[2].body[0]).toMatchObject({ + event_type: "TestRunFinished", + test_run: { uuid, name: "t", started_at: "2026-01-01T00:00:01Z", finished_at: "2026-01-01T00:00:02Z", result: "failed", duration_in_ms: 1000, failure: [{ backtrace: ["AssertionError: boom", "at x"] }], failure_reason: "AssertionError: boom", failure_type: "AssertionError" }, + }); + }); + + it("hook runs use HookRunStarted/HookRunFinished", async () => { + const { client, calls, id } = await started(); + const { uuid } = await client.startHookRun(id, { hookType: "BEFORE_ALL", name: "setup", fileName: "a.test.ts", scopes: ["unit"], startedAt: "2026-01-01T00:00:01Z" }); + await client.finishHookRun(id, uuid as string, { hookType: "BEFORE_ALL", result: "passed", finishedAt: "2026-01-01T00:00:02Z", fileName: "a.test.ts", scopes: ["unit"] }); + expect(calls[1].body[0]).toMatchObject({ event_type: "HookRunStarted", hook_run: { uuid, type: "hook", hook_type: "BEFORE_ALL" } }); + expect(calls[2].body[0]).toMatchObject({ event_type: "HookRunFinished", hook_run: { uuid, result: "passed" } }); + }); + + it("addBuildLogs sends a LogCreated event with snake_case fields", async () => { + const { client, calls, id } = await started(); + await client.addBuildLogs(id, { logs: [{ kind: "TEST_LOG", testRunUuid: "u1", level: "ERROR", message: "boom", timestamp: "2026-01-01T00:00:01Z" }] }); + expect(calls[1].body).toEqual([ + { event_type: "LogCreated", logs: [expect.objectContaining({ kind: "TEST_LOG", test_run_uuid: "u1", level: "ERROR", message: "boom", timestamp: "2026-01-01T00:00:01Z", http_response: {} })] }, + ]); + }); + + it("finishBuild stops the build and forgets it", async () => { + const { client, calls, id } = await started(); + await client.finishBuild(id, { finishedAt: "2026-01-01T00:01:00Z" }); + expect(calls[1].url).toBe(`${COLLECTOR}/api/v1/builds/bld/stop`); + expect(calls[1].method).toBe("PUT"); + expect(calls[1].body).toEqual({ stop_time: "2026-01-01T00:01:00Z" }); + await expect(client.addBuildLogs(id, { logs: [] })).rejects.toThrow(BrowserStackError); + }); + + it("linkTestRunSession sends CBTSessionCreated with the session details", async () => { + const { client, calls, id } = await started(); + await client.linkTestRunSession(id, "run1", { sessionId: "s1", browser: "chrome", browserVersion: "130", platform: "linux" }); + expect(calls[1].body).toEqual([ + { event_type: "CBTSessionCreated", test_run: { uuid: "run1", integrations: { browserstack: expect.objectContaining({ session_id: "s1", browser: "chrome", browser_version: "130", platform: "linux" }) } } }, + ]); + }); + + it("addTestScreenshots sends TEST_SCREENSHOT logs to the screenshots endpoint", async () => { + const { client, calls, id } = await started(); + await client.addTestScreenshots(id, [{ testRunUuid: "run1", base64: "aGk=" }]); + expect(calls[1].url).toBe(`${COLLECTOR}/api/v1/screenshots`); + expect(calls[1].body[0]).toMatchObject({ event_type: "LogCreated", logs: [expect.objectContaining({ kind: "TEST_SCREENSHOT", test_run_uuid: "run1", message: "aGk=" })] }); + }); + + it("rejects runs and logs for a build this client did not start", async () => { + const { client } = setup(); + await expect(client.startTestRun("nope", { name: "t", fileName: "f", scopes: ["s"], startedAt: "2026-01-01T00:00:00Z" })).rejects.toThrow(/startBuild/); + }); + + it("surfaces HTTP errors with status and body", async () => { + const { client, queue } = setup(); + queue[0] = { status: 403, body: { message: "no" } }; + await expect(client.startBuild(build)).rejects.toBeInstanceOf(HttpError); + }); +}); diff --git a/packages/test-reporting/src/__tests__/test-reporting.test.ts b/packages/test-reporting/src/__tests__/test-reporting.test.ts index b914342..1b93c1a 100644 --- a/packages/test-reporting/src/__tests__/test-reporting.test.ts +++ b/packages/test-reporting/src/__tests__/test-reporting.test.ts @@ -31,11 +31,8 @@ const BUILD_DETAIL_WIRE = { project_id: 25033, }; -const START_BUILD_WIRE = { success: true, build_hashed_id: "bld_newbuild123" }; -const FINISH_BUILD_WIRE = { success: true, message: "Build finished successfully." }; -const START_TEST_RUN_WIRE = { success: true, test_run_id: "tr-001" }; describe("TestReportingClient", () => { describe("Credentials", () => { @@ -168,62 +165,6 @@ describe("TestReportingClient", () => { }); }); - describe("startBuild", () => { - it("returns success true and buildHashedId", async () => { - const client = makeClient(START_BUILD_WIRE); - const data = await client.startBuild({ - name: "my-build", - projectName: "sdk-integration-tests", - startedAt: "2026-04-27T10:00:00.000Z", - framework: { name: "vitest", version: "1.0.0" }, - }); - expect(data).toBeDefined(); - expect((data as { success?: boolean }).success).toBe(true); - expect((data as { buildHashedId?: string }).buildHashedId).toBe("bld_newbuild123"); - }); - - it("throws HttpError on 422", async () => { - const client = makeClient(makeErrorResponse(422, "Unprocessable Entity")); - await expect( - client.startBuild({ name: "", projectName: "", startedAt: "", framework: { name: "vitest", version: "1.0.0" } }) - ).rejects.toThrow(HttpError); - }); - }); - - describe("finishBuild", () => { - it("returns success true and message", async () => { - const client = makeClient(FINISH_BUILD_WIRE); - const data = await client.finishBuild("bld_newbuild123", { - finishedAt: "2026-04-27T10:30:00.000Z", - }); - expect(data).toBeDefined(); - expect((data as { success?: boolean }).success).toBe(true); - expect((data as { message?: string }).message).toBe("Build finished successfully."); - }); - - it("throws HttpError on 401", async () => { - const client = makeClient(makeErrorResponse(401, "Unauthorized")); - await expect( - client.finishBuild("bld_newbuild123", { finishedAt: "2026-04-27T10:30:00.000Z" }) - ).rejects.toThrow(HttpError); - }); - }); - - describe("startTestRun", () => { - it("returns success true and test_run_id", async () => { - const client = makeClient(START_TEST_RUN_WIRE); - const data = await client.startTestRun("bld_newbuild123", { - name: "sdk test run", - startedAt: "2026-04-27T10:00:00.000Z", - fileName: "sdk.test.ts", - scopes: ["integration"], - }); - expect(data).toBeDefined(); - expect((data as { success?: boolean }).success).toBe(true); - expect((data as { testRunId?: string }).testRunId).toBe("tr-001"); - }); - }); - describe("uploadReport", () => { function makeCapturingClient() { let capturedUrl = ""; diff --git a/packages/test-reporting/src/index.ts b/packages/test-reporting/src/index.ts index fbdc342..7dfdf49 100644 --- a/packages/test-reporting/src/index.ts +++ b/packages/test-reporting/src/index.ts @@ -1,7 +1,18 @@ -import { BrowserStackOptions } from "@dot-slash/browserstack-core"; +import { BrowserStackError, BrowserStackOptions } from "@dot-slash/browserstack-core"; import { AppAutomateClient } from "@dot-slash/browserstack-app-automate"; import { AutomateClient } from "@dot-slash/browserstack-automate"; +import { HttpError, NetworkError } from "@dot-slash/browserstack-openapi-transforms"; import { GeneratedTestReportingClient } from "@dot-slash/browserstack-openapi/test-reporting/client"; +import { + buildStartBody, + failureFields, + logFields, + runFields, + sessionIntegrations, + type LogEntry, + type RunSession, + type RunStart, +} from "./ingestion"; import { defaultKinds, fetchAppAutomateLogs, @@ -18,11 +29,12 @@ import { } from "./linked-sessions"; export type * from "./linked-sessions"; -export { TestReportingIngestClient } from "./ingest"; -export type { IngestBuildRequest, IngestEvent, TestReportingIngestOptions } from "./ingest"; +export type { LogEntry, RunSession } from "./ingestion"; export interface TestReportingClientOptions extends BrowserStackOptions { uploadBaseUrl?: string; + /** Base URL of the live ingestion API (build start/stop, test and hook events, logs). */ + ingestBaseUrl?: string; /** * Used to resolve a test's session and fetch its logs (see `getTestSession`). Defaults to a client built from * these same options, minus the Test Reporting base URLs. Pass one to customise it (base URL, timeout, ...). @@ -36,6 +48,9 @@ export class TestReportingClient extends GeneratedTestReportingClient { private readonly siblingOptions: BrowserStackOptions; private automateClient: AutomateClient | undefined; private appAutomateClient: AppAutomateClient | undefined; + private readonly ingestBaseUrl: string; + /** Per started build: its ingestion JWT, framework, and the runs that have started but not finished. */ + private readonly builds = new Map }>(); private readonly sessionCache = new Map>(); constructor(options?: TestReportingClientOptions) { @@ -46,12 +61,14 @@ export class TestReportingClient extends GeneratedTestReportingClient { "@dot-slash/browserstack-test-reporting", __PKG_VERSION__ ); + this.ingestBaseUrl = (options?.ingestBaseUrl ?? "https://collector-observability.browserstack.com").replace(/\/$/, ""); const shared: TestReportingClientOptions = { ...options }; this.automateClient = shared.automate; this.appAutomateClient = shared.appAutomate; // Those base URLs are Test Reporting's, so the sibling clients keep their own. delete shared.baseUrl; delete shared.uploadBaseUrl; + delete shared.ingestBaseUrl; delete shared.automate; delete shared.appAutomate; this.siblingOptions = shared; @@ -128,6 +145,137 @@ export class TestReportingClient extends GeneratedTestReportingClient { : fetchAppAutomateLogs(this.appAutomate, linked.session, sessionId, wanted); } + // ── Live ingestion ──────────────────────────────────────────────────────────────────────────────────────────── + // These override the generated ingestion methods (same signatures) so they work against the live service, which + // is the protocol BrowserStack's SDKs use: start a build with basic auth (returns a build id and a JWT), stream + // events with that JWT, stop the build. A client must start a build before reporting runs or logs into it. + + private async ingest(operationId: string, method: "POST" | "PUT", path: string, authorization: string, body: unknown): Promise { + const url = `${this.ingestBaseUrl}${path}`; + const ctx = { operationId, method, url }; + let res: Response; + try { + res = await this.fetchFn(url, { + method, + headers: { "content-type": "application/json", authorization, "user-agent": this.userAgent }, + body: JSON.stringify(body), + }); + } catch (cause) { + throw new NetworkError((cause as Error).message || "network error", ctx, cause as Error); + } + const text = await res.text(); + if (!res.ok) { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + parsed = undefined; + } + const message = (parsed as { message?: string } | undefined)?.message ?? `HTTP ${res.status} ${res.statusText}`; + throw new HttpError(message, ctx, { + status: res.status, + statusText: res.statusText, + headers: res.headers, + body: { text, parsed, truncated: false } as never, + retryable: res.status >= 500 || res.status === 429, + }); + } + return (text ? JSON.parse(text) : {}) as T; + } + + private buildState(buildHashedId: string) { + const b = this.builds.get(buildHashedId); + if (!b) throw new BrowserStackError(`Build ${buildHashedId} was not started by this client; call startBuild() first`); + return b; + } + + private async sendEvents(buildHashedId: string, events: unknown[], path = "/api/v1/batch"): Promise { + await this.ingest("sendTestReportingEvents", "POST", path, `Bearer ${this.buildState(buildHashedId).jwt}`, events); + } + + override async startBuild(body: Parameters[0]): ReturnType { + const res = await this.ingest<{ build_hashed_id?: string; jwt?: string }>("startTestReportingBuild", "POST", "/api/v2/builds", this.authHeader ?? "", buildStartBody(body as Parameters[0])); + if (!res.build_hashed_id || !res.jwt) throw new BrowserStackError("Build start returned no build id or JWT (is Test Reporting enabled for this account?)"); + this.builds.set(res.build_hashed_id, { jwt: res.jwt, framework: (body as { framework?: { name: string } }).framework?.name, runs: new Map() }); + return { success: true, buildHashedId: res.build_hashed_id } as Awaited>; + } + + override async startTestRun(buildHashedId: string, body: Parameters[1]): ReturnType { + return this.startRun("test", buildHashedId, body) as ReturnType; + } + + override async finishTestRun(buildHashedId: string, testRunUuid: string, body: Parameters[2]): ReturnType { + return this.finishRun("test", buildHashedId, testRunUuid, body) as ReturnType; + } + + override async startHookRun(buildHashedId: string, body: Parameters[1]): ReturnType { + return this.startRun("hook", buildHashedId, body) as ReturnType; + } + + override async finishHookRun(buildHashedId: string, hookRunUuid: string, body: Parameters[2]): ReturnType { + return this.finishRun("hook", buildHashedId, hookRunUuid, body) as ReturnType; + } + + private async startRun(type: "test" | "hook", buildHashedId: string, body: Record) { + const build = this.buildState(buildHashedId); + const run: RunStart = { + type, + uuid: crypto.randomUUID(), + name: body.name, + fileName: body.fileName, + scopes: body.scopes, + startedAt: body.startedAt, + tags: body.tags, + location: body.location, + hookType: body.hookType, + framework: build.framework, + }; + const data = runFields(run, { result: body.result ?? "pending", environment: body.environment, custom_metadata: body.customMetadata, test_run_id: body.testRunId }); + await this.sendEvents(buildHashedId, [{ event_type: type === "test" ? "TestRunStarted" : "HookRunStarted", [type === "test" ? "test_run" : "hook_run"]: data }]); + build.runs.set(run.uuid, run); + return { success: true, uuid: run.uuid }; + } + + private async finishRun(type: "test" | "hook", buildHashedId: string, uuid: string, body: Record) { + const build = this.buildState(buildHashedId); + const run = build.runs.get(uuid); + if (!run) throw new BrowserStackError(`${type} run ${uuid} was not started by this client`); + const data = runFields( + { ...run, fileName: body.fileName ?? run.fileName, scopes: body.scopes ?? run.scopes }, + { finished_at: body.finishedAt, result: body.result, duration_in_ms: body.durationInMs, environment: body.environment, custom_metadata: body.customMetadata, ...failureFields(body.failure) } + ); + await this.sendEvents(buildHashedId, [{ event_type: type === "test" ? "TestRunFinished" : "HookRunFinished", [type === "test" ? "test_run" : "hook_run"]: data }]); + build.runs.delete(uuid); + return { success: true, message: `${type === "test" ? "Test" : "Hook"} run updated successfully.` }; + } + + override async addBuildLogs(buildHashedId: string, body: Parameters[1]): ReturnType { + const logs = (body.logs as unknown as LogEntry[]).map(logFields); + await this.sendEvents(buildHashedId, [{ event_type: "LogCreated", logs }]); + return { success: true, message: "Logs ingested successfully." } as Awaited>; + } + + override async finishBuild(buildHashedId: string, body: Parameters[1]): ReturnType { + const build = this.buildState(buildHashedId); + await this.ingest("finishTestReportingBuild", "PUT", `/api/v1/builds/${encodeURIComponent(buildHashedId)}/stop`, `Bearer ${build.jwt}`, { stop_time: body.finishedAt }); + this.builds.delete(buildHashedId); + return { success: true, message: "Build finished successfully." } as Awaited>; + } + + /** + * Links a test run to the BrowserStack session it ran in, so TRA can show its session, logs and video (what + * WebdriverIO sends as a CBTSessionCreated event). Takes the uuid `startTestRun` returned. + */ + async linkTestRunSession(buildHashedId: string, testRunUuid: string, session: RunSession): Promise { + await this.sendEvents(buildHashedId, [{ event_type: "CBTSessionCreated", test_run: { uuid: testRunUuid, integrations: sessionIntegrations(session) } }]); + } + + /** Attaches screenshots to test runs: `kind` `TEST_SCREENSHOT` logs whose `message` is the base64 image. */ + async addTestScreenshots(buildHashedId: string, screenshots: Array<{ testRunUuid: string; base64: string; timestamp?: string }>): Promise { + const logs = screenshots.map((s) => logFields({ kind: "TEST_SCREENSHOT", testRunUuid: s.testRunUuid, message: s.base64, timestamp: s.timestamp ?? new Date().toISOString() })); + await this.sendEvents(buildHashedId, [{ event_type: "LogCreated", logs }], "/api/v1/screenshots"); + } + private async lookupSession(sessionId: string, hint?: SessionHint): Promise { const web = async (): Promise => { try { diff --git a/packages/test-reporting/src/ingest.ts b/packages/test-reporting/src/ingest.ts deleted file mode 100644 index f60093f..0000000 --- a/packages/test-reporting/src/ingest.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { BrowserStackError, buildBasicAuthHeader, resolveAccessKey, resolveUsername, type BrowserStackOptions } from "@dot-slash/browserstack-core"; - -export interface IngestBuildRequest { - projectName: string; - name: string; - startedAt: string; - framework: { name: string; version: string }; - tags?: string[]; - buildRunIdentifier?: string; - hostInfo?: { hostname?: string; platform?: string; type?: string; version?: string; arch?: string }; - ciInfo?: { name?: string; buildUrl?: string; url?: string; buildNumber?: string; jobName?: string }; - versionControl?: { name?: string; sha?: string; branch?: string; tag?: string; commitMessage?: string; committerName?: string; committerEmail?: string }; -} - -/** An event for `sendEvents`: TestRunStarted/TestRunFinished (`test_run`), HookRun* (`hook_run`) or LogCreated (`logs`). */ -export interface IngestEvent { - event_type: "TestRunStarted" | "TestRunFinished" | "HookRunStarted" | "HookRunFinished" | "LogCreated" | (string & {}); - [key: string]: unknown; -} - -export interface TestReportingIngestOptions extends Pick { - /** Defaults to the Test Reporting & Analytics collector. */ - baseUrl?: string; -} - -/** - * Live ingestion into Test Reporting & Analytics, the way the BrowserStack SDKs (e.g. WebdriverIO) report results: start a - * build with basic auth (which returns a build id and a JWT), stream test, hook and log events with the JWT, then stop the build. - */ -export class TestReportingIngestClient { - private readonly baseUrl: string; - private readonly basicAuth: string; - private readonly fetchFn: typeof fetch; - private build: { buildHashedId: string; jwt: string } | undefined; - - constructor(options: TestReportingIngestOptions) { - const username = resolveUsername(options.username); - const accessKey = resolveAccessKey(options.accessKey); - if (!username || !accessKey) throw new BrowserStackError("Missing username or accessKey"); - this.basicAuth = buildBasicAuthHeader(username, accessKey); - this.baseUrl = (options.baseUrl ?? "https://collector-observability.browserstack.com").replace(/\/$/, ""); - this.fetchFn = options.fetchFn ?? fetch; - } - - async startBuild(req: IngestBuildRequest): Promise<{ buildHashedId: string; jwt: string }> { - const res = await this.request<{ build_hashed_id?: string; jwt?: string }>("POST", "/api/v2/builds", this.basicAuth, { - format: "json", - project_name: req.projectName, - name: req.name, - started_at: req.startedAt, - tags: req.tags, - build_run_identifier: req.buildRunIdentifier, - host_info: req.hostInfo, - ci_info: req.ciInfo && { name: req.ciInfo.name, build_url: req.ciInfo.buildUrl, url: req.ciInfo.url, build_number: req.ciInfo.buildNumber, job_name: req.ciInfo.jobName }, - version_control: req.versionControl && { - name: req.versionControl.name, - sha: req.versionControl.sha, - branch: req.versionControl.branch, - tag: req.versionControl.tag, - commit_message: req.versionControl.commitMessage, - committer_name: req.versionControl.committerName, - committer_email: req.versionControl.committerEmail, - }, - framework_details: { - frameworkName: req.framework.name, - frameworkVersion: req.framework.version, - sdkVersion: req.framework.version, - language: "ECMAScript", - testFramework: { name: req.framework.name, version: req.framework.version }, - }, - product_map: { observability: true }, - config: {}, - }); - if (!res.build_hashed_id || !res.jwt) throw new BrowserStackError("Build start returned no build id or jwt (is Test Reporting enabled for this account?)"); - this.build = { buildHashedId: res.build_hashed_id, jwt: res.jwt }; - return this.build; - } - - async sendEvents(events: IngestEvent[]): Promise { - await this.request("POST", "/api/v1/batch", this.bearer("sendEvents"), events); - } - - async stopBuild(stopTime: string): Promise { - await this.request("PUT", `/api/v1/builds/${this.build?.buildHashedId}/stop`, this.bearer("stopBuild"), { stop_time: stopTime }); - } - - private bearer(method: string): string { - if (!this.build) throw new BrowserStackError(`${method}() called before startBuild()`); - return `Bearer ${this.build.jwt}`; - } - - private async request(method: string, path: string, authorization: string, body: unknown): Promise { - const res = await this.fetchFn(`${this.baseUrl}${path}`, { - method, - headers: { "content-type": "application/json", authorization }, - body: JSON.stringify(body), - }); - const text = await res.text(); - if (!res.ok) throw new BrowserStackError(`${method} ${path} failed: HTTP ${res.status}${text ? ` ${text.slice(0, 300)}` : ""}`); - return (text ? JSON.parse(text) : {}) as T; - } -} diff --git a/packages/test-reporting/src/ingestion.ts b/packages/test-reporting/src/ingestion.ts new file mode 100644 index 0000000..e99952e --- /dev/null +++ b/packages/test-reporting/src/ingestion.ts @@ -0,0 +1,143 @@ +// Live ingestion into Test Reporting & Analytics. This is the protocol the BrowserStack SDKs (e.g. WebdriverIO) use: +// a build is started with basic auth (which returns a build id and a JWT), test/hook/log events are streamed to the +// batch endpoint with that JWT, and the build is stopped at the end. The mapping from the camelCase request bodies of +// `TestReportingClient` to those wire events lives here. + +const snake = (k: string) => k.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); +const snakeKeys = (o: Record | undefined) => o && Object.fromEntries(Object.entries(o).map(([k, v]) => [snake(k), v])); + +export interface FrameworkInfo { + name: string; + version: string; +} + +export function buildStartBody(body: { + name: string; + projectName: string; + startedAt: string; + framework?: FrameworkInfo; + tags?: string[]; + buildRunIdentifier?: string; + hostInfo?: Record; + ciInfo?: Record; + versionControl?: Record; +}) { + const f = body.framework; + return { + format: "json", + project_name: body.projectName, + name: body.name, + started_at: body.startedAt, + tags: body.tags, + build_run_identifier: body.buildRunIdentifier, + host_info: snakeKeys(body.hostInfo), + ci_info: snakeKeys(body.ciInfo), + version_control: snakeKeys(body.versionControl), + framework_details: f && { + frameworkName: f.name, + frameworkVersion: f.version, + sdkVersion: f.version, + language: "ECMAScript", + testFramework: { name: f.name, version: f.version }, + }, + product_map: { observability: true }, + config: {}, + }; +} + +export interface RunStart { + type: "test" | "hook"; + uuid: string; + name: string; + fileName: string; + scopes: string[]; + startedAt: string; + tags?: string[]; + location?: string; + hookType?: string; + framework?: string; +} + +/** Run fields that stay the same between a run's start and finish event. */ +export function runFields(r: RunStart, extra: Record = {}) { + return { + uuid: r.uuid, + type: r.type, + name: r.name, + scope: r.scopes.join(" > "), + scopes: r.scopes, + identifier: `${r.fileName} > ${r.scopes.join(" > ")} > ${r.name}`, + file_name: r.fileName, + location: r.location ?? r.fileName, + tags: r.tags, + framework: r.framework, + hook_type: r.hookType, + started_at: r.startedAt, + ...extra, + }; +} + +export function failureFields(failure: Array<{ error?: string; backtrace?: string }> | undefined) { + if (!failure?.length) return {}; + const first = failure[0].error ?? ""; + return { + failure: failure.map((x) => ({ backtrace: [x.error ?? "", x.backtrace ?? ""] })), + failure_reason: first, + failure_type: /AssertionError/.test(first) ? "AssertionError" : "UnhandledError", + }; +} + +export interface LogEntry { + kind: string; + testRunUuid?: string; + hookRunUuid?: string; + timestamp?: string; + level?: string; + message?: string; + duration?: number; + failure?: boolean; + fileName?: string; + fileSize?: number; + attachmentType?: string; + httpResponse?: Record; +} + +export const logFields = (l: LogEntry) => ({ + kind: l.kind, + test_run_uuid: l.testRunUuid, + hook_run_uuid: l.hookRunUuid, + timestamp: l.timestamp, + level: l.level, + message: l.message, + duration: l.duration, + failure: l.failure, + file_name: l.fileName, + file_size: l.fileSize, + attachment_type: l.attachmentType, + http_response: l.httpResponse ?? {}, +}); + +/** Session/cloud details of a test run, as BrowserStack's CBTSessionCreated event carries them. */ +export interface RunSession { + /** Cloud provider key, e.g. `browserstack`. */ + provider?: string; + sessionId: string; + browser?: string; + browserVersion?: string; + platform?: string; + platformVersion?: string; + device?: string; + capabilities?: Record; +} + +export const sessionIntegrations = (s: RunSession) => ({ + [s.provider ?? "browserstack"]: { + capabilities: s.capabilities, + session_id: s.sessionId, + browser: s.browser, + browser_version: s.browserVersion, + platform: s.platform, + platform_version: s.platformVersion, + device: s.device, + }, +}); diff --git a/scripts/ci-report/tra-reporter.mjs b/scripts/ci-report/tra-reporter.mjs index f013161..c0aa6a1 100644 --- a/scripts/ci-report/tra-reporter.mjs +++ b/scripts/ci-report/tra-reporter.mjs @@ -4,7 +4,6 @@ // Best-effort: every API error is logged and swallowed, so an unreachable BrowserStack never fails CI. import { relative } from "node:path"; import os from "node:os"; -import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; const warn = (what, e) => console.warn(`[tra] ${what} failed (ignored): ${e instanceof Error ? e.message : e}`); @@ -18,18 +17,17 @@ export class TraReporter { // The GitHub Actions event payload (push / pull_request) has the commit and PR details env vars lack. this.event = event ?? (env.GITHUB_EVENT_PATH ? JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf-8")) : {}); this.enabled = Boolean(client); - this.started = false; - this.chain = Promise.resolve(); - this.uuids = new Map(); // vitest test id -> run uuid - this.startedAt = new Map(); + this.build = undefined; // Promise (buildHashedId) + this.tests = new Map(); // test id -> Promise<{ buildId, uuid } | undefined> + this.logs = []; // pending build log entries, flushed at the end of the run } static async fromEnv(env = process.env) { const username = env.BROWSERSTACK_USERNAME; const accessKey = env.BROWSERSTACK_ACCESS_KEY || env.BROWSERSTACK_KEY; if (!username || !accessKey) return new TraReporter({ env }); - const { TestReportingIngestClient } = await import("../../packages/test-reporting/dist/index.js"); - return new TraReporter({ client: new TestReportingIngestClient({ username, accessKey }), env }); + const { TestReportingClient } = await import("../../packages/test-reporting/dist/index.js"); + return new TraReporter({ client: new TestReportingClient({ username, accessKey }), env }); } buildRequest() { @@ -75,129 +73,99 @@ export class TraReporter { return req; } - // Events are sent one at a time, in order, and never throw. - enqueue(what, fn) { - this.chain = this.chain.then(async () => { - if (!this.started) return; - try { - await fn(); - } catch (e) { - warn(what, e); - } - }); - return this.chain; - } - async onTestRunStart() { if (!this.enabled) return; - try { - await this.client.startBuild(this.buildRequest()); - this.started = true; - } catch (e) { - warn("startBuild", e); - } - } - - runData(testCase, extra) { - const parts = testCase.fullName.split(" > "); - const file = relative(this.cwd, testCase.module.moduleId).replaceAll("\\", "/"); - return { - uuid: this.uuids.get(testCase.id), - type: "test", - name: testCase.name, - scope: testCase.fullName, - scopes: [testCase.project.name, ...parts.slice(0, -1)].slice(0, 20), - identifier: `${file} > ${testCase.fullName}`, - file_name: file, - location: file, - framework: "vitest", - ...extra, - }; + this.build = this.client + .startBuild(this.buildRequest()) + .then((res) => res?.buildHashedId ?? res?.build_hashed_id) + .catch((e) => (warn("startBuild", e), undefined)); + await this.build; } - onTestCaseReady(testCase) { + async onTestCaseReady(testCase) { if (!this.enabled) return; - this.uuids.set(testCase.id, randomUUID()); - const started = iso(); - this.startedAt.set(testCase.id, Date.now()); - return this.enqueue("TestRunStarted", () => - this.client.sendEvents([{ event_type: "TestRunStarted", test_run: this.runData(testCase, { started_at: started, result: "pending" }) }]) - ); + const started = (async () => { + const buildId = await this.build; + if (!buildId) return undefined; + try { + const parts = testCase.fullName.split(" > "); + const res = await this.client.startTestRun(buildId, { + name: testCase.name, + fileName: relative(this.cwd, testCase.module.moduleId).replaceAll("\\", "/"), + scopes: [testCase.project.name, ...parts.slice(0, -1)].slice(0, 20), + startedAt: iso(), + ...(testCase.location ? { location: `${testCase.location.line}:${testCase.location.column}` } : {}), + }); + const uuid = res?.uuid ?? res?.testRunId; + return uuid ? { buildId, uuid, startedAt: Date.now() } : undefined; + } catch (e) { + warn("startTestRun", e); + return undefined; + } + })(); + this.tests.set(testCase.id, started); + await started; } - onUserConsoleLog(log) { - if (!this.enabled) return; - const uuid = this.uuids.get(log.taskId); - if (!uuid) return; - return this.enqueue("LogCreated", () => - this.client.sendEvents([ - { - event_type: "LogCreated", - logs: [ - { - kind: "TEST_LOG", - test_run_uuid: uuid, - level: log.type === "stderr" ? "ERROR" : "INFO", - message: String(log.content).replace(/\n$/, "").slice(0, 10000), - timestamp: new Date(log.time ?? Date.now()).toISOString(), - http_response: {}, - }, - ], - }, - ]) - ); + async onUserConsoleLog(log) { + if (!this.enabled || !log.taskId) return; + const run = await this.tests.get(log.taskId); + if (!run) return; + this.logs.push({ + kind: "TEST_LOG", + testRunUuid: run.uuid, + level: log.type === "stderr" ? "ERROR" : "INFO", + message: String(log.content).replace(/\n$/, "").slice(0, 10000), + timestamp: new Date(log.time ?? Date.now()).toISOString(), + }); } - onTestCaseResult(testCase) { - if (!this.enabled || !this.uuids.has(testCase.id)) return; - const result = testCase.result(); - const state = result.state === "pending" ? "skipped" : result.state; - const errors = (result.errors ?? []).slice(0, 100); - const extra = { - finished_at: iso(), - result: state, - duration_in_ms: Math.round(testCase.diagnostic()?.duration ?? Date.now() - this.startedAt.get(testCase.id)), - }; - if (state === "failed" && errors.length) { - extra.failure = errors.map((x) => ({ backtrace: [x.message, x.stack ?? ""] })); - extra.failure_reason = errors[0].message; - extra.failure_type = /AssertionError/.test(errors[0].name ?? errors[0].message ?? "") ? "AssertionError" : "UnhandledError"; - } - return this.enqueue("TestRunFinished", async () => { - const uuid = this.uuids.get(testCase.id); - const events = []; + async onTestCaseResult(testCase) { + if (!this.enabled) return; + const run = await this.tests.get(testCase.id); + if (!run) return; + try { + const result = testCase.result(); + const state = result.state === "pending" ? "skipped" : result.state; + const parts = testCase.fullName.split(" > "); if (state === "failed") { - for (const x of errors) { - events.push({ - event_type: "LogCreated", - logs: [{ kind: "TEST_LOG", test_run_uuid: uuid, level: "ERROR", message: String(x.stack ?? x.message).slice(0, 10000), timestamp: iso(), http_response: {}, failure: true }], - }); + for (const x of result.errors ?? []) { + this.logs.push({ kind: "TEST_LOG", testRunUuid: run.uuid, level: "ERROR", message: String(x.stack ?? x.message).slice(0, 10000), timestamp: iso(), failure: true }); } } - events.push({ event_type: "TestRunFinished", test_run: this.runData(testCase, extra) }); - await this.client.sendEvents(events); - }); + await this.client.finishTestRun(run.buildId, run.uuid, { + result: state, + finishedAt: iso(), + fileName: relative(this.cwd, testCase.module.moduleId).replaceAll("\\", "/"), + scopes: [testCase.project.name, ...parts.slice(0, -1)].slice(0, 20), + durationInMs: Math.round(testCase.diagnostic()?.duration ?? Date.now() - run.startedAt), + ...(state === "failed" ? { failure: (result.errors ?? []).slice(0, 100).map((x) => ({ error: x.message, backtrace: x.stack })) } : {}), + }); + } catch (e) { + warn("finishTestRun", e); + } } async onTestRunEnd(_modules, errors = []) { if (!this.enabled) return; - // Unhandled errors happen outside any test: report each as a failed run so they show up in the build. + await Promise.all(this.tests.values()); + const buildId = await this.build; + if (!buildId) return; + // Unhandled errors (outside any test) belong to the build itself. for (const x of errors) { - const uuid = randomUUID(); - const now = iso(); - const run = { - uuid, type: "test", name: `Unhandled ${x.name ?? "error"}`, scope: "unhandled errors", scopes: ["unhandled errors"], - identifier: `unhandled > ${x.message}`, file_name: "unhandled", framework: "vitest", started_at: now, finished_at: now, - result: "failed", duration_in_ms: 0, failure: [{ backtrace: [x.message, x.stack ?? ""] }], failure_reason: x.message, failure_type: "UnhandledError", - }; - this.enqueue("unhandled error", () => this.client.sendEvents([{ event_type: "TestRunStarted", test_run: { ...run, finished_at: undefined, result: "pending" } }, { event_type: "TestRunFinished", test_run: run }])); + this.logs.push({ kind: "TEST_LOG", level: "ERROR", message: String(x.stack ?? x.message).slice(0, 10000), timestamp: iso(), failure: true }); + } + for (let i = 0; i < this.logs.length; i += 500) { + try { + await this.client.addBuildLogs(buildId, { logs: this.logs.slice(i, i + 500) }); + } catch (e) { + warn("addBuildLogs", e); + } } - await this.chain; - if (!this.started) return; try { - await this.client.stopBuild(iso()); + await this.client.finishBuild(buildId, { finishedAt: iso() }); } catch (e) { - warn("stopBuild", e); + warn("finishBuild", e); } } } diff --git a/scripts/ci-report/tra-reporter.test.ts b/scripts/ci-report/tra-reporter.test.ts index cec4a86..31e94e5 100644 --- a/scripts/ci-report/tra-reporter.test.ts +++ b/scripts/ci-report/tra-reporter.test.ts @@ -14,9 +14,11 @@ const env = { function fakeClient() { return { - startBuild: vi.fn(async () => ({ buildHashedId: "bld1", jwt: "tok" })), - sendEvents: vi.fn(async () => undefined), - stopBuild: vi.fn(async () => undefined), + startBuild: vi.fn(async () => ({ success: true, buildHashedId: "bld1" })), + startTestRun: vi.fn(async () => ({ success: true, uuid: "run1" })), + finishTestRun: vi.fn(async () => ({ success: true })), + finishBuild: vi.fn(async () => ({ success: true })), + addBuildLogs: vi.fn(async () => ({ success: true })), }; } @@ -26,19 +28,17 @@ const tc = (over: Record = {}) => ({ fullName: "suite > does a thing", module: { moduleId: "/repo/packages/core/src/a.test.ts" }, project: { name: "core" }, + location: { line: 3, column: 1 }, result: () => ({ state: "passed", errors: [] }), diagnostic: () => ({ duration: 12.4 }), ...over, }); -const make = (client = fakeClient(), extra: Record = {}) => new TraReporter({ client, env, cwd: "/repo", event: {}, ...extra }); -const events = (client: ReturnType) => client.sendEvents.mock.calls.flatMap((c) => c[0] as Array>); - describe("TraReporter", () => { it("starts the build when the run starts, with CI metadata", async () => { const client = fakeClient(); - const r = make(client); - await r.onTestRunStart(); + const r = new TraReporter({ client, env, cwd: "/repo" }); + await r.onTestRunStart([]); await r.onTestRunEnd([], []); expect(client.startBuild).toHaveBeenCalledWith( expect.objectContaining({ @@ -50,15 +50,19 @@ describe("TraReporter", () => { versionControl: expect.objectContaining({ sha: "abcdef1234567890", branch: "main" }), }) ); - expect(client.stopBuild).toHaveBeenCalledWith(expect.any(String)); }); it("captures PR context: head commit and branch, PR number/title, actor, event and run/job URLs", async () => { const client = fakeClient(); const event = { - pull_request: { number: 34, title: "ci: report to TRA", html_url: "https://github.com/shirish87/browserstack-client/pull/34", head: { ref: "feature/x", sha: "headsha1234567" } }, + pull_request: { + number: 34, + title: "ci: report to TRA", + html_url: "https://github.com/shirish87/browserstack-client/pull/34", + head: { ref: "feature/x", sha: "headsha1234567" }, + }, }; - const prEnv = { ...env, GITHUB_EVENT_NAME: "pull_request", GITHUB_HEAD_REF: "feature/x", GITHUB_ACTOR: "shirish87", GITHUB_JOB: "test-sdk", GITHUB_REF_NAME: "34/merge", RUNNER_OS: "Linux" }; + const prEnv = { ...env, GITHUB_EVENT_NAME: "pull_request", GITHUB_HEAD_REF: "feature/x", GITHUB_ACTOR: "shirish87", GITHUB_JOB: "test-sdk", GITHUB_REF_NAME: "34/merge", GITHUB_SERVER_URL: "https://github.com", RUNNER_OS: "Linux" }; const r = new TraReporter({ client, env: prEnv, cwd: "/repo", event }); await r.onTestRunStart(); expect(client.startBuild).toHaveBeenCalledWith( @@ -66,93 +70,98 @@ describe("TraReporter", () => { name: "CI #45 (feature/x) [linux]", tags: expect.arrayContaining(["ci", "linux", "feature/x", "pull_request"]), versionControl: expect.objectContaining({ sha: "headsha1234567", branch: "feature/x", commitMessage: "ci: report to TRA" }), - ciInfo: expect.objectContaining({ name: "GitHub Actions", url: "https://github.com/shirish87/browserstack-client/pull/34", buildNumber: "45", jobName: "test-sdk" }), + ciInfo: expect.objectContaining({ + name: "GitHub Actions", + buildUrl: "https://github.com/shirish87/browserstack-client/actions/runs/123", + url: "https://github.com/shirish87/browserstack-client/pull/34", + buildNumber: "45", + jobName: "test-sdk", + }), }) ); }); it("captures push context: head commit message and author from the event", async () => { const client = fakeClient(); - const event = { head_commit: { message: "fix: a thing\n\nbody", committer: { name: "Bob", email: "bob@x.io" } } }; + const event = { head_commit: { message: "fix: a thing\n\nbody", author: { name: "Ann", email: "ann@x.io" }, committer: { name: "Bob", email: "bob@x.io" } } }; const r = new TraReporter({ client, env: { ...env, GITHUB_EVENT_NAME: "push" }, cwd: "/repo", event }); await r.onTestRunStart(); expect(client.startBuild).toHaveBeenCalledWith( - expect.objectContaining({ versionControl: expect.objectContaining({ commitMessage: "fix: a thing", committerName: "Bob", committerEmail: "bob@x.io" }) }) + expect.objectContaining({ + versionControl: expect.objectContaining({ sha: "abcdef1234567890", branch: "main", commitMessage: "fix: a thing", committerName: "Bob", committerEmail: "bob@x.io" }), + }) ); }); - it("reports each test live: TestRunStarted when ready, TestRunFinished when it has a result", async () => { + it("reports each test live: start when it is ready, finish when it has a result", async () => { const client = fakeClient(); - const r = make(client); - await r.onTestRunStart(); + const r = new TraReporter({ client, env, cwd: "/repo" }); + await r.onTestRunStart([]); await r.onTestCaseReady(tc()); - const [started] = events(client); - expect(started).toMatchObject({ - event_type: "TestRunStarted", - test_run: { type: "test", name: "does a thing", scopes: ["core", "suite"], file_name: "packages/core/src/a.test.ts", framework: "vitest", result: "pending", uuid: expect.any(String) }, - }); + expect(client.startTestRun).toHaveBeenCalledWith("bld1", expect.objectContaining({ name: "does a thing", scopes: ["core", "suite"], fileName: "packages/core/src/a.test.ts" })); + expect(client.finishTestRun).not.toHaveBeenCalled(); await r.onTestCaseResult(tc()); - const finished = events(client).find((e) => e.event_type === "TestRunFinished")!; - expect(finished.test_run).toMatchObject({ uuid: started.test_run.uuid, result: "passed", duration_in_ms: 12 }); + expect(client.finishTestRun).toHaveBeenCalledWith("bld1", "run1", expect.objectContaining({ result: "passed", durationInMs: 12 })); + await r.onTestRunEnd([], []); + expect(client.finishBuild).toHaveBeenCalledWith("bld1", expect.objectContaining({ finishedAt: expect.any(String) })); }); - it("reports failures with message, stack trace and an ERROR log", async () => { + it("maps failures with error and backtrace", async () => { const client = fakeClient(); - const r = make(client); - await r.onTestRunStart(); + const r = new TraReporter({ client, env, cwd: "/repo" }); + await r.onTestRunStart([]); await r.onTestCaseReady(tc()); - await r.onTestCaseResult(tc({ result: () => ({ state: "failed", errors: [{ name: "AssertionError", message: "boom", stack: "Error: boom\n at x" }] }) })); - const ev = events(client); - const finished = ev.find((e) => e.event_type === "TestRunFinished")!; - expect(finished.test_run).toMatchObject({ result: "failed", failure: [{ backtrace: ["boom", "Error: boom\n at x"] }], failure_reason: "boom", failure_type: "AssertionError" }); - const log = ev.find((e) => e.event_type === "LogCreated")!; - expect(log.logs[0]).toMatchObject({ kind: "TEST_LOG", level: "ERROR", message: "Error: boom\n at x", test_run_uuid: finished.test_run.uuid, failure: true }); + await r.onTestCaseResult(tc({ result: () => ({ state: "failed", errors: [{ message: "boom", stack: "at x" }] }) })); + expect(client.finishTestRun).toHaveBeenCalledWith("bld1", "run1", expect.objectContaining({ result: "failed", failure: [{ error: "boom", backtrace: "at x" }] })); }); - it("sends test console output as TEST_LOG entries linked to the run", async () => { + it("sends test console output as TEST_LOG entries linked to the test run", async () => { const client = fakeClient(); - const r = make(client); + const r = new TraReporter({ client, env, cwd: "/repo" }); await r.onTestRunStart(); await r.onTestCaseReady(tc()); await r.onUserConsoleLog({ content: "hello\n", type: "stderr", taskId: "t1", time: 1759600000000 }); - const log = events(client).find((e) => e.event_type === "LogCreated")!; - expect(log.logs[0]).toMatchObject({ kind: "TEST_LOG", level: "ERROR", message: "hello", timestamp: new Date(1759600000000).toISOString() }); + await r.onTestRunEnd([], []); + expect(client.addBuildLogs).toHaveBeenCalledWith("bld1", { + logs: [expect.objectContaining({ kind: "TEST_LOG", testRunUuid: "run1", level: "ERROR", message: "hello", timestamp: new Date(1759600000000).toISOString() })], + }); }); - it("reports unhandled errors as a failed run", async () => { + it("sends failure messages and unhandled errors as ERROR logs with stack traces", async () => { const client = fakeClient(); - const r = make(client); + const r = new TraReporter({ client, env, cwd: "/repo" }); await r.onTestRunStart(); + await r.onTestCaseReady(tc()); + await r.onTestCaseResult(tc({ result: () => ({ state: "failed", errors: [{ message: "boom", stack: "Error: boom\n at x" }] }) })); await r.onTestRunEnd([], [{ name: "Error", message: "unhandled", stack: "Error: unhandled\n at y" }]); - const finished = events(client).find((e) => e.event_type === "TestRunFinished")!; - expect(finished.test_run).toMatchObject({ result: "failed", failure: [{ backtrace: ["unhandled", "Error: unhandled\n at y"] }] }); + const logs = client.addBuildLogs.mock.calls.flatMap((c) => (c[1] as { logs: unknown[] }).logs); + expect(logs).toContainEqual(expect.objectContaining({ kind: "TEST_LOG", testRunUuid: "run1", level: "ERROR", message: "Error: boom\n at x", failure: true })); + expect(logs).toContainEqual(expect.objectContaining({ kind: "TEST_LOG", level: "ERROR", message: "Error: unhandled\n at y", failure: true })); }); - it("never throws and sends nothing when the build cannot be started", async () => { + it("never throws and stops calling once the build cannot be started", async () => { const client = fakeClient(); client.startBuild.mockRejectedValue(new Error("unreachable")); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const r = make(client); - await r.onTestRunStart(); - await r.onTestCaseReady(tc()); - await r.onTestCaseResult(tc()); - await r.onTestRunEnd([], []); - expect(client.sendEvents).not.toHaveBeenCalled(); - expect(client.stopBuild).not.toHaveBeenCalled(); + const r = new TraReporter({ client, env, cwd: "/repo" }); + await expect(r.onTestRunStart([])).resolves.toBeUndefined(); + await expect(r.onTestCaseReady(tc())).resolves.toBeUndefined(); + await expect(r.onTestCaseResult(tc())).resolves.toBeUndefined(); + await expect(r.onTestRunEnd([], [])).resolves.toBeUndefined(); + expect(client.startTestRun).not.toHaveBeenCalled(); + expect(client.finishBuild).not.toHaveBeenCalled(); warn.mockRestore(); }); - it("swallows per-event API errors and keeps going", async () => { + it("swallows per-test API errors", async () => { const client = fakeClient(); - client.sendEvents.mockRejectedValueOnce(new Error("500")); + client.startTestRun.mockRejectedValue(new Error("500")); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const r = make(client); - await r.onTestRunStart(); + const r = new TraReporter({ client, env, cwd: "/repo" }); + await r.onTestRunStart([]); await r.onTestCaseReady(tc()); - await r.onTestCaseResult(tc()); - await r.onTestRunEnd([], []); - expect(client.sendEvents).toHaveBeenCalledTimes(2); - expect(client.stopBuild).toHaveBeenCalled(); + await expect(r.onTestCaseResult(tc())).resolves.toBeUndefined(); + expect(client.finishTestRun).not.toHaveBeenCalled(); warn.mockRestore(); }); From eea40b996b1256b51949b6c24c5a040c6fb18a6e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 20:47:50 +0000 Subject: [PATCH 7/7] chore: bump version to 7.2.0 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WUdwzfDSUhTEqqEWDhi6tB --- package.json | 2 +- packages/accessibility/package.json | 2 +- packages/app-automate/package.json | 2 +- packages/automate/package.json | 2 +- packages/cli/typescript/package.json | 2 +- packages/core/package.json | 2 +- packages/local-testing-binary/package.json | 2 +- packages/local-testing/package.json | 2 +- packages/openapi-transforms/package.json | 2 +- packages/openapi/package.json | 2 +- packages/screenshots/package.json | 2 +- packages/sdk/package.json | 2 +- packages/test-management/package.json | 2 +- packages/test-reporting/package.json | 2 +- packages/website-scanner/package.json | 2 +- 15 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 3c1176d..d3ec585 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "browserstack-client", "private": true, "type": "module", - "version": "7.1.1", + "version": "7.2.0", "description": "BrowserStack client SDK monorepo", "packageManager": "pnpm@10.33.2", "engines": { diff --git a/packages/accessibility/package.json b/packages/accessibility/package.json index fab83d6..0f5b94a 100644 --- a/packages/accessibility/package.json +++ b/packages/accessibility/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "7.1.1", + "version": "7.2.0", "type": "module", "description": "BrowserStack Accessibility API client", "engines": { diff --git a/packages/app-automate/package.json b/packages/app-automate/package.json index 0ae9402..51b09ee 100644 --- a/packages/app-automate/package.json +++ b/packages/app-automate/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "7.1.1", + "version": "7.2.0", "type": "module", "description": "BrowserStack App Automate API client for mobile app automation testing", "engines": { diff --git a/packages/automate/package.json b/packages/automate/package.json index 83ee795..7d688e8 100644 --- a/packages/automate/package.json +++ b/packages/automate/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "7.1.1", + "version": "7.2.0", "type": "module", "description": "BrowserStack Automate API client for browser automation testing", "engines": { diff --git a/packages/cli/typescript/package.json b/packages/cli/typescript/package.json index 84fd8e8..31beb63 100644 --- a/packages/cli/typescript/package.json +++ b/packages/cli/typescript/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "7.1.1", + "version": "7.2.0", "type": "module", "description": "Command-line interface for BrowserStack Local Testing and App Automate", "engines": { diff --git a/packages/core/package.json b/packages/core/package.json index a1253b8..80b39d2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "7.1.1", + "version": "7.2.0", "type": "module", "description": "Shared runtime utilities: APIClient base, auth, error handling, HTTP adapters", "engines": { diff --git a/packages/local-testing-binary/package.json b/packages/local-testing-binary/package.json index c438cf2..76df17c 100644 --- a/packages/local-testing-binary/package.json +++ b/packages/local-testing-binary/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "7.1.1", + "version": "7.2.0", "type": "module", "description": "BrowserStack Local Testing binary management and spawning for Node.js", "engines": { diff --git a/packages/local-testing/package.json b/packages/local-testing/package.json index c495a0c..7599967 100644 --- a/packages/local-testing/package.json +++ b/packages/local-testing/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "7.1.1", + "version": "7.2.0", "type": "module", "description": "BrowserStack Local Testing API client for local development", "engines": { diff --git a/packages/openapi-transforms/package.json b/packages/openapi-transforms/package.json index ef8ba1a..49917e9 100644 --- a/packages/openapi-transforms/package.json +++ b/packages/openapi-transforms/package.json @@ -4,7 +4,7 @@ "publishConfig": { "access": "public" }, - "version": "7.1.1", + "version": "7.2.0", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/openapi/package.json b/packages/openapi/package.json index c918989..fa445ab 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -1,7 +1,7 @@ { "name": "@dot-slash/browserstack-openapi", "private": true, - "version": "7.1.1", + "version": "7.2.0", "type": "module", "description": "OpenAPI specs and generated TypeScript types for BrowserStack APIs", "files": [ diff --git a/packages/screenshots/package.json b/packages/screenshots/package.json index b8c3c79..d9beb0a 100644 --- a/packages/screenshots/package.json +++ b/packages/screenshots/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "7.1.1", + "version": "7.2.0", "type": "module", "description": "BrowserStack Screenshots API client for cross-browser screenshot automation", "engines": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index d393403..745a6b7 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "7.1.1", + "version": "7.2.0", "type": "module", "description": "BrowserStack SDK — all API clients in one package", "engines": { diff --git a/packages/test-management/package.json b/packages/test-management/package.json index 86be602..0b247e7 100644 --- a/packages/test-management/package.json +++ b/packages/test-management/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "7.1.1", + "version": "7.2.0", "type": "module", "description": "BrowserStack Test Management API client", "engines": { diff --git a/packages/test-reporting/package.json b/packages/test-reporting/package.json index e543b77..80ed1c5 100644 --- a/packages/test-reporting/package.json +++ b/packages/test-reporting/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "7.1.1", + "version": "7.2.0", "type": "module", "description": "BrowserStack Test Reporting & Analytics API client", "engines": { diff --git a/packages/website-scanner/package.json b/packages/website-scanner/package.json index 46eadf4..e319294 100644 --- a/packages/website-scanner/package.json +++ b/packages/website-scanner/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "7.1.1", + "version": "7.2.0", "type": "module", "description": "BrowserStack Website Scanner API client", "engines": {