@testpulse.run/reporter 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +38 -0
  2. package/dist/fixtures/createTestPulseTest.d.ts +14 -0
  3. package/dist/fixtures/createTestPulseTest.js +35 -0
  4. package/dist/fixtures/options.d.ts +2 -0
  5. package/dist/fixtures/options.js +35 -0
  6. package/dist/fixtures/runtime.d.ts +3 -0
  7. package/dist/fixtures/runtime.js +6 -0
  8. package/dist/fixtures/types.d.ts +10 -0
  9. package/dist/fixtures/types.js +1 -0
  10. package/dist/fixtures.d.ts +2 -0
  11. package/dist/fixtures.js +1 -0
  12. package/dist/index.d.ts +2 -0
  13. package/dist/index.js +1 -0
  14. package/dist/live/CaptureScheduler.d.ts +22 -0
  15. package/dist/live/CaptureScheduler.js +87 -0
  16. package/dist/live/EncoderProcess.d.ts +5 -0
  17. package/dist/live/EncoderProcess.js +61 -0
  18. package/dist/live/LiveCaptureService.d.ts +2 -0
  19. package/dist/live/LiveCaptureService.js +103 -0
  20. package/dist/live/LiveSessionResolver.d.ts +2 -0
  21. package/dist/live/LiveSessionResolver.js +66 -0
  22. package/dist/live/LiveSessionState.d.ts +6 -0
  23. package/dist/live/LiveSessionState.js +20 -0
  24. package/dist/live/Mp4FragmentParser.d.ts +4 -0
  25. package/dist/live/Mp4FragmentParser.js +32 -0
  26. package/dist/live/SegmentQueue.d.ts +9 -0
  27. package/dist/live/SegmentQueue.js +32 -0
  28. package/dist/live/StreamUploader.d.ts +21 -0
  29. package/dist/live/StreamUploader.js +78 -0
  30. package/dist/live/telemetry.d.ts +3 -0
  31. package/dist/live/telemetry.js +15 -0
  32. package/dist/live/types.d.ts +41 -0
  33. package/dist/live/types.js +1 -0
  34. package/dist/reporter/ArtifactUploader.d.ts +13 -0
  35. package/dist/reporter/ArtifactUploader.js +48 -0
  36. package/dist/reporter/EventQueue.d.ts +7 -0
  37. package/dist/reporter/EventQueue.js +15 -0
  38. package/dist/reporter/GitRuntimeMetadataResolver.d.ts +11 -0
  39. package/dist/reporter/GitRuntimeMetadataResolver.js +93 -0
  40. package/dist/reporter/PlaywrightReporter.d.ts +21 -0
  41. package/dist/reporter/PlaywrightReporter.js +126 -0
  42. package/dist/reporter/RunClient.d.ts +19 -0
  43. package/dist/reporter/RunClient.js +42 -0
  44. package/dist/reporter/types.d.ts +26 -0
  45. package/dist/reporter/types.js +1 -0
  46. package/dist/shared/endpoint.d.ts +1 -0
  47. package/dist/shared/endpoint.js +25 -0
  48. package/dist/shared/http.d.ts +2 -0
  49. package/dist/shared/http.js +17 -0
  50. package/package.json +65 -0
@@ -0,0 +1,21 @@
1
+ import type { LiveCaptureState, SegmentKind, StreamDescriptor } from "./types.js";
2
+ export declare class StreamUploader {
3
+ private readonly session;
4
+ private readonly streamId;
5
+ private readonly descriptor;
6
+ private readonly captureStartedAt;
7
+ private firstInitUploaded;
8
+ private firstMediaUploaded;
9
+ constructor(session: LiveCaptureState, streamId: string, descriptor: StreamDescriptor, captureStartedAt: number);
10
+ start(): Promise<void>;
11
+ uploadPreview(frame: Buffer): Promise<void>;
12
+ uploadSegment(segment: {
13
+ sequence: number;
14
+ gopId: number;
15
+ segmentKind: SegmentKind;
16
+ payload: Buffer;
17
+ publishedAtUtc: string;
18
+ }): Promise<void>;
19
+ stop(): Promise<void>;
20
+ private buildHeaders;
21
+ }
@@ -0,0 +1,78 @@
1
+ import { postJson, postJsonOrThrow } from "../shared/http.js";
2
+ import { logTiming } from "./telemetry.js";
3
+ export class StreamUploader {
4
+ session;
5
+ streamId;
6
+ descriptor;
7
+ captureStartedAt;
8
+ firstInitUploaded = false;
9
+ firstMediaUploaded = false;
10
+ constructor(session, streamId, descriptor, captureStartedAt) {
11
+ this.session = session;
12
+ this.streamId = streamId;
13
+ this.descriptor = descriptor;
14
+ this.captureStartedAt = captureStartedAt;
15
+ }
16
+ async start() {
17
+ await postJsonOrThrow(`${this.session.endpoint}/ingest/v1/projects/${encodeURIComponent(this.session.projectId)}/runs/${this.session.runId}/stream/start`, {
18
+ token: this.session.streamPublisherToken,
19
+ streamId: this.streamId,
20
+ profile: this.descriptor.profile,
21
+ mimeCodec: this.descriptor.mimeCodec,
22
+ width: this.descriptor.width,
23
+ height: this.descriptor.height,
24
+ framesPerSecond: this.descriptor.framesPerSecond,
25
+ targetBitrateKbps: this.descriptor.targetBitrateKbps,
26
+ segmentDurationMs: this.descriptor.segmentDurationMs,
27
+ gopDurationMs: this.descriptor.gopDurationMs
28
+ }, "TestPulse live request failed with", this.buildHeaders());
29
+ }
30
+ async uploadPreview(frame) {
31
+ await postJsonOrThrow(`${this.session.endpoint}/ingest/v1/projects/${encodeURIComponent(this.session.projectId)}/runs/${this.session.runId}/stream/preview`, {
32
+ token: this.session.streamPublisherToken,
33
+ streamId: this.streamId,
34
+ capturedAtUtc: new Date().toISOString(),
35
+ contentType: "image/jpeg",
36
+ payloadBase64: frame.toString("base64"),
37
+ width: this.descriptor.width,
38
+ height: this.descriptor.height
39
+ }, "TestPulse live request failed with", this.buildHeaders());
40
+ }
41
+ async uploadSegment(segment) {
42
+ await postJsonOrThrow(`${this.session.endpoint}/ingest/v1/projects/${encodeURIComponent(this.session.projectId)}/runs/${this.session.runId}/stream/segments`, {
43
+ token: this.session.streamPublisherToken,
44
+ streamId: this.streamId,
45
+ sequence: segment.sequence,
46
+ segmentKind: segment.segmentKind,
47
+ gopId: segment.gopId,
48
+ ptsStart: segment.sequence * this.descriptor.segmentDurationMs,
49
+ ptsEnd: (segment.sequence + 1) * this.descriptor.segmentDurationMs,
50
+ durationMs: this.descriptor.segmentDurationMs,
51
+ publishedAtUtc: segment.publishedAtUtc,
52
+ discontinuity: false,
53
+ streamResetRequired: false,
54
+ mimeCodec: this.descriptor.mimeCodec,
55
+ payloadBase64: segment.payload.toString("base64")
56
+ }, "TestPulse live request failed with", this.buildHeaders());
57
+ if (segment.segmentKind === "init" && !this.firstInitUploaded) {
58
+ this.firstInitUploaded = true;
59
+ logTiming("firstInitSegmentUploaded", this.captureStartedAt, { sequence: segment.sequence });
60
+ }
61
+ if (segment.segmentKind === "media" && !this.firstMediaUploaded) {
62
+ this.firstMediaUploaded = true;
63
+ logTiming("firstMediaSegmentUploaded", this.captureStartedAt, { sequence: segment.sequence });
64
+ }
65
+ }
66
+ async stop() {
67
+ await postJson(`${this.session.endpoint}/ingest/v1/projects/${encodeURIComponent(this.session.projectId)}/runs/${this.session.runId}/stream/stop`, {
68
+ token: this.session.streamPublisherToken,
69
+ streamId: this.streamId,
70
+ reason: "capture-stopped"
71
+ }, this.buildHeaders());
72
+ }
73
+ buildHeaders() {
74
+ return {
75
+ "X-Project-Api-Key": this.session.apiKey
76
+ };
77
+ }
78
+ }
@@ -0,0 +1,3 @@
1
+ import type { TimingKey } from "./types.js";
2
+ export declare function logTiming(key: TimingKey, captureStartedAt?: number, extra?: Record<string, number | string>): void;
3
+ export declare function createTimingLogger(captureStartedAt: number): (key: TimingKey, extra?: Record<string, number | string>) => void;
@@ -0,0 +1,15 @@
1
+ export function logTiming(key, captureStartedAt = Date.now(), extra) {
2
+ const elapsedMs = Math.max(0, Date.now() - captureStartedAt);
3
+ const suffix = extra ? ` ${JSON.stringify(extra)}` : "";
4
+ console.info(`[TestPulse] ${key} ${elapsedMs}ms${suffix}`);
5
+ }
6
+ export function createTimingLogger(captureStartedAt) {
7
+ const loggedTimings = new Set();
8
+ return (key, extra) => {
9
+ if (loggedTimings.has(key)) {
10
+ return;
11
+ }
12
+ loggedTimings.add(key);
13
+ logTiming(key, captureStartedAt, extra);
14
+ };
15
+ }
@@ -0,0 +1,41 @@
1
+ import type { Page } from "@playwright/test";
2
+ export type LiveCaptureState = {
3
+ endpoint: string;
4
+ projectId: string;
5
+ apiKey: string;
6
+ runId: string;
7
+ streamPublisherToken: string;
8
+ createdAt: string;
9
+ intervalMs?: number;
10
+ profile?: StreamProfile;
11
+ };
12
+ export type StreamProfile = "focus" | "grid";
13
+ export type StreamDescriptor = {
14
+ streamId: string;
15
+ profile: StreamProfile;
16
+ mimeCodec: string;
17
+ width: number;
18
+ height: number;
19
+ framesPerSecond: number;
20
+ targetBitrateKbps: number;
21
+ segmentDurationMs: number;
22
+ gopDurationMs: number;
23
+ };
24
+ export type SegmentKind = "init" | "media";
25
+ export type TimingKey = "captureStart" | "firstScreenshotTaken" | "previewPosted" | "firstFrameWrittenToEncoder" | "firstInitSegmentUploaded" | "firstMediaSegmentUploaded";
26
+ export interface LiveCaptureOptions {
27
+ endpoint?: string;
28
+ projectId?: string;
29
+ apiKey?: string;
30
+ runId?: string;
31
+ streamPublisherToken?: string;
32
+ sessionKey?: string;
33
+ stateDirectory?: string;
34
+ intervalMs?: number;
35
+ profile?: StreamProfile;
36
+ }
37
+ export interface LiveCaptureHandle {
38
+ stop(): Promise<void>;
39
+ }
40
+ export interface LivePage extends Pick<Page, "screenshot" | "viewportSize"> {
41
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,13 @@
1
+ import type { TestCase, TestResult } from "@playwright/test/reporter";
2
+ import type { EventQueue } from "./EventQueue.js";
3
+ import type { RunClient } from "./RunClient.js";
4
+ export declare class ArtifactUploader {
5
+ private readonly eventQueue;
6
+ private readonly ensureRunStarted;
7
+ private readonly runClient;
8
+ private readonly uploadedArtifacts;
9
+ constructor(eventQueue: EventQueue, ensureRunStarted: () => Promise<string>, runClient: RunClient);
10
+ enqueueArtifacts(test: TestCase, result: TestResult, eventSequence: number): void;
11
+ }
12
+ export declare function getArtifactType(attachment: TestResult["attachments"][number]): "image" | "video" | null;
13
+ export declare function getContentType(artifactType: "image" | "video"): string;
@@ -0,0 +1,48 @@
1
+ export class ArtifactUploader {
2
+ eventQueue;
3
+ ensureRunStarted;
4
+ runClient;
5
+ uploadedArtifacts = new Set();
6
+ constructor(eventQueue, ensureRunStarted, runClient) {
7
+ this.eventQueue = eventQueue;
8
+ this.ensureRunStarted = ensureRunStarted;
9
+ this.runClient = runClient;
10
+ }
11
+ enqueueArtifacts(test, result, eventSequence) {
12
+ for (const attachment of result.attachments ?? []) {
13
+ if (!attachment.path) {
14
+ continue;
15
+ }
16
+ const artifactType = getArtifactType(attachment);
17
+ if (!artifactType || this.uploadedArtifacts.has(attachment.path)) {
18
+ continue;
19
+ }
20
+ this.uploadedArtifacts.add(attachment.path);
21
+ this.eventQueue.enqueue(async () => {
22
+ const runId = await this.ensureRunStarted();
23
+ await this.runClient.uploadArtifact(runId, {
24
+ artifactType,
25
+ filePath: attachment.path,
26
+ contentType: attachment.contentType ?? getContentType(artifactType),
27
+ testId: test.id,
28
+ eventSequence: artifactType === "image" ? eventSequence : undefined
29
+ });
30
+ });
31
+ }
32
+ }
33
+ }
34
+ export function getArtifactType(attachment) {
35
+ const contentType = attachment.contentType?.toLowerCase() ?? "";
36
+ const name = attachment.name?.toLowerCase() ?? "";
37
+ const path = attachment.path?.toLowerCase() ?? "";
38
+ if (contentType.startsWith("image/") || name.includes("screenshot") || /\.(png|jpe?g|webp)$/i.test(path)) {
39
+ return "image";
40
+ }
41
+ if (contentType.startsWith("video/") || name.includes("video") || /\.(webm|mp4)$/i.test(path)) {
42
+ return "video";
43
+ }
44
+ return null;
45
+ }
46
+ export function getContentType(artifactType) {
47
+ return artifactType === "image" ? "image/png" : "video/webm";
48
+ }
@@ -0,0 +1,7 @@
1
+ export declare class EventQueue {
2
+ private sequence;
3
+ private chain;
4
+ nextSequence(task: (sequence: number) => Promise<void>): number;
5
+ enqueue(task: () => Promise<void>): void;
6
+ flush(): Promise<void>;
7
+ }
@@ -0,0 +1,15 @@
1
+ export class EventQueue {
2
+ sequence = 0;
3
+ chain = Promise.resolve();
4
+ nextSequence(task) {
5
+ const sequence = ++this.sequence;
6
+ this.chain = this.chain.then(() => task(sequence));
7
+ return sequence;
8
+ }
9
+ enqueue(task) {
10
+ this.chain = this.chain.then(task);
11
+ }
12
+ async flush() {
13
+ await this.chain;
14
+ }
15
+ }
@@ -0,0 +1,11 @@
1
+ type CommandExecutor = (command: string, args: string[], cwd: string) => Promise<string>;
2
+ export interface GitRuntimeMetadataOptions {
3
+ branch?: string;
4
+ commitSha?: string;
5
+ }
6
+ export interface GitRuntimeMetadata {
7
+ branch?: string;
8
+ commitSha?: string;
9
+ }
10
+ export declare function resolveGitRuntimeMetadata(options: GitRuntimeMetadataOptions, cwd?: string, environment?: NodeJS.ProcessEnv, executor?: CommandExecutor): Promise<GitRuntimeMetadata>;
11
+ export {};
@@ -0,0 +1,93 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ const branchEnvironmentVariables = [
5
+ "TESTPULSE_BRANCH",
6
+ "GITHUB_HEAD_REF",
7
+ "GITHUB_REF_NAME",
8
+ "CI_COMMIT_REF_NAME",
9
+ "BUILD_SOURCEBRANCHNAME",
10
+ "BRANCH_NAME"
11
+ ];
12
+ const commitEnvironmentVariables = [
13
+ "TESTPULSE_COMMIT_SHA",
14
+ "GITHUB_SHA",
15
+ "CI_COMMIT_SHA",
16
+ "BUILD_SOURCEVERSION",
17
+ "COMMIT_SHA"
18
+ ];
19
+ export async function resolveGitRuntimeMetadata(options, cwd = process.cwd(), environment = process.env, executor = executeGitCommand) {
20
+ const branch = options.branch?.trim() || getFirstEnvironmentValue(branchEnvironmentVariables, environment);
21
+ const configuredCommitSha = options.commitSha?.trim();
22
+ if (branch && configuredCommitSha) {
23
+ return {
24
+ branch,
25
+ commitSha: configuredCommitSha
26
+ };
27
+ }
28
+ const gitBranch = branch || await tryGetGitBranch(cwd, executor);
29
+ const commitSha = configuredCommitSha || await tryGetGitCommit(cwd, environment, executor);
30
+ if (!commitSha) {
31
+ return {
32
+ branch: gitBranch || undefined,
33
+ commitSha: undefined
34
+ };
35
+ }
36
+ const hasPendingChanges = await tryHasPendingChanges(cwd, executor);
37
+ const displayCommitSha = shortenCommitSha(commitSha);
38
+ return {
39
+ branch: gitBranch || undefined,
40
+ commitSha: hasPendingChanges ? `${displayCommitSha} + pending changes` : displayCommitSha
41
+ };
42
+ }
43
+ async function tryGetGitBranch(cwd, executor) {
44
+ try {
45
+ const branch = await executor("git", ["rev-parse", "--abbrev-ref", "HEAD"], cwd);
46
+ if (!branch || branch === "HEAD") {
47
+ return undefined;
48
+ }
49
+ return branch;
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ }
55
+ async function tryGetGitCommit(cwd, environment, executor) {
56
+ const environmentCommit = getFirstEnvironmentValue(commitEnvironmentVariables, environment);
57
+ if (environmentCommit) {
58
+ return environmentCommit;
59
+ }
60
+ try {
61
+ return await executor("git", ["rev-parse", "HEAD"], cwd);
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ }
67
+ async function tryHasPendingChanges(cwd, executor) {
68
+ try {
69
+ const status = await executor("git", ["status", "--porcelain"], cwd);
70
+ return status.length > 0;
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ function getFirstEnvironmentValue(keys, environment) {
77
+ for (const key of keys) {
78
+ const value = environment[key]?.trim();
79
+ if (value) {
80
+ return value;
81
+ }
82
+ }
83
+ return undefined;
84
+ }
85
+ async function executeGitCommand(command, args, cwd) {
86
+ const result = await execFileAsync(command, args, { cwd });
87
+ return result.stdout.trim();
88
+ }
89
+ function shortenCommitSha(commitSha) {
90
+ return /^[0-9a-f]{8,}$/i.test(commitSha)
91
+ ? commitSha.slice(0, 7)
92
+ : commitSha;
93
+ }
@@ -0,0 +1,21 @@
1
+ import type { FullConfig, FullResult, Reporter, Suite, TestCase, TestResult } from "@playwright/test/reporter";
2
+ import type { TestPulseReporterOptions } from "./types.js";
3
+ export default class TestPulseReporter implements Reporter {
4
+ private readonly options;
5
+ private runId;
6
+ private metadataPromise;
7
+ private readonly eventQueue;
8
+ private readonly runClient;
9
+ private readonly artifactUploader;
10
+ private readonly resolvedOptions;
11
+ constructor(options: TestPulseReporterOptions);
12
+ onBegin(_config: FullConfig, _suite: Suite): Promise<void>;
13
+ onTestBegin(test: TestCase): void;
14
+ onTestEnd(test: TestCase, result: TestResult): void;
15
+ onStdOut(chunk: string | Buffer, test?: TestCase): void;
16
+ onStdErr(chunk: string | Buffer, test?: TestCase): void;
17
+ onEnd(result: FullResult): Promise<void>;
18
+ printsToStdio(): boolean;
19
+ private enqueueEvent;
20
+ private ensureRunStarted;
21
+ }
@@ -0,0 +1,126 @@
1
+ import { deleteLiveCaptureState, writeLiveCaptureState } from "../live/LiveSessionState.js";
2
+ import { resolveTestPulseEndpoint } from "../shared/endpoint.js";
3
+ import { ArtifactUploader } from "./ArtifactUploader.js";
4
+ import { EventQueue } from "./EventQueue.js";
5
+ import { resolveGitRuntimeMetadata } from "./GitRuntimeMetadataResolver.js";
6
+ import { RunClient } from "./RunClient.js";
7
+ export default class TestPulseReporter {
8
+ options;
9
+ runId = null;
10
+ metadataPromise = null;
11
+ eventQueue = new EventQueue();
12
+ runClient;
13
+ artifactUploader;
14
+ resolvedOptions;
15
+ constructor(options) {
16
+ this.options = options;
17
+ this.resolvedOptions = {
18
+ ...options,
19
+ endpoint: resolveTestPulseEndpoint(options.endpoint)
20
+ };
21
+ this.runClient = new RunClient(this.resolvedOptions);
22
+ this.artifactUploader = new ArtifactUploader(this.eventQueue, () => this.ensureRunStarted(), this.runClient);
23
+ }
24
+ async onBegin(_config, _suite) {
25
+ await this.ensureRunStarted();
26
+ }
27
+ onTestBegin(test) {
28
+ this.enqueueEvent("test.started", {
29
+ testId: test.id,
30
+ testName: test.title,
31
+ location: test.location?.file
32
+ });
33
+ }
34
+ onTestEnd(test, result) {
35
+ const eventType = result.status === "passed"
36
+ ? "test.passed"
37
+ : result.status === "skipped"
38
+ ? "test.skipped"
39
+ : "test.failed";
40
+ const eventSequence = this.enqueueEvent(eventType, {
41
+ testId: test.id,
42
+ testName: test.title,
43
+ durationMs: result.duration,
44
+ retry: result.retry,
45
+ message: result.error?.message ?? null
46
+ });
47
+ if (result.retry > 0) {
48
+ this.enqueueEvent("test.retried", {
49
+ testId: test.id,
50
+ testName: test.title,
51
+ retry: result.retry
52
+ });
53
+ }
54
+ this.artifactUploader.enqueueArtifacts(test, result, eventSequence);
55
+ }
56
+ onStdOut(chunk, test) {
57
+ this.enqueueEvent("log", {
58
+ level: "info",
59
+ message: chunk.toString(),
60
+ testId: test?.id ?? null
61
+ });
62
+ }
63
+ onStdErr(chunk, test) {
64
+ this.enqueueEvent("log", {
65
+ level: "error",
66
+ message: chunk.toString(),
67
+ testId: test?.id ?? null
68
+ });
69
+ }
70
+ async onEnd(result) {
71
+ const runId = await this.ensureRunStarted();
72
+ await this.eventQueue.flush();
73
+ try {
74
+ await this.runClient.completeRun(runId, result.status === "failed" ? "Failed" : "Completed");
75
+ }
76
+ finally {
77
+ if (this.options.liveStreamingEnabled) {
78
+ await deleteLiveCaptureState(this.options.liveStreamSessionKey, this.options.liveCaptureStateDirectory);
79
+ }
80
+ }
81
+ }
82
+ printsToStdio() {
83
+ return false;
84
+ }
85
+ enqueueEvent(eventType, payload) {
86
+ return this.eventQueue.nextSequence(async (sequence) => {
87
+ const runId = await this.ensureRunStarted();
88
+ const envelope = {
89
+ sequence,
90
+ timestamp: new Date().toISOString(),
91
+ eventType,
92
+ payload
93
+ };
94
+ await this.runClient.sendEvents(runId, [envelope]);
95
+ });
96
+ }
97
+ async ensureRunStarted() {
98
+ if (this.runId) {
99
+ return this.runId;
100
+ }
101
+ if (!this.metadataPromise) {
102
+ this.metadataPromise = resolveGitRuntimeMetadata({
103
+ branch: this.options.branch,
104
+ commitSha: this.options.commitSha
105
+ });
106
+ }
107
+ const metadata = await this.metadataPromise;
108
+ const payload = await this.runClient.startRun(metadata);
109
+ this.runId = payload.runId;
110
+ if (this.options.liveStreamingEnabled) {
111
+ if (!payload.streamPublisherToken) {
112
+ throw new Error("TestPulse live streaming token was not returned by the backend.");
113
+ }
114
+ await writeLiveCaptureState({
115
+ endpoint: this.resolvedOptions.endpoint,
116
+ projectId: this.options.projectId,
117
+ apiKey: this.options.apiKey,
118
+ runId: this.runId,
119
+ streamPublisherToken: payload.streamPublisherToken,
120
+ createdAt: new Date().toISOString(),
121
+ intervalMs: this.options.liveCaptureIntervalMs
122
+ }, this.options.liveStreamSessionKey, this.options.liveCaptureStateDirectory);
123
+ }
124
+ return this.runId;
125
+ }
126
+ }
@@ -0,0 +1,19 @@
1
+ import type { ResolvedTestPulseReporterOptions, RunEventEnvelope, StartRunResponse } from "./types.js";
2
+ export declare class RunClient {
3
+ private readonly options;
4
+ constructor(options: ResolvedTestPulseReporterOptions);
5
+ startRun(metadata: {
6
+ branch?: string;
7
+ commitSha?: string;
8
+ }): Promise<StartRunResponse>;
9
+ sendEvents(runId: string, events: RunEventEnvelope[]): Promise<void>;
10
+ completeRun(runId: string, status: "Failed" | "Completed"): Promise<void>;
11
+ uploadArtifact(runId: string, artifact: {
12
+ artifactType: "image" | "video";
13
+ filePath: string;
14
+ contentType: string;
15
+ testId: string;
16
+ eventSequence?: number;
17
+ }): Promise<void>;
18
+ private buildHeaders;
19
+ }
@@ -0,0 +1,42 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { postJson, postJsonOrThrow } from "../shared/http.js";
3
+ export class RunClient {
4
+ options;
5
+ constructor(options) {
6
+ this.options = options;
7
+ }
8
+ async startRun(metadata) {
9
+ const response = await postJsonOrThrow(`${this.options.endpoint}/ingest/v1/projects/${encodeURIComponent(this.options.projectId)}/runs/start`, {
10
+ runName: this.options.runName,
11
+ branch: metadata.branch,
12
+ commitSha: metadata.commitSha
13
+ }, "TestPulse run start failed with", this.buildHeaders());
14
+ return response.json();
15
+ }
16
+ async sendEvents(runId, events) {
17
+ await postJson(`${this.options.endpoint}/ingest/v1/projects/${encodeURIComponent(this.options.projectId)}/runs/${runId}/events`, { events }, this.buildHeaders());
18
+ }
19
+ async completeRun(runId, status) {
20
+ await postJson(`${this.options.endpoint}/ingest/v1/projects/${encodeURIComponent(this.options.projectId)}/runs/${runId}/complete`, { status }, this.buildHeaders());
21
+ }
22
+ async uploadArtifact(runId, artifact) {
23
+ const bytes = await readFile(artifact.filePath);
24
+ const formData = new FormData();
25
+ formData.append("artifactType", artifact.artifactType);
26
+ formData.append("testId", artifact.testId);
27
+ if (artifact.eventSequence !== undefined) {
28
+ formData.append("eventSequence", String(artifact.eventSequence));
29
+ }
30
+ formData.append("file", new Blob([bytes], { type: artifact.contentType }), artifact.filePath.split(/[\\/]/).pop() ?? `${artifact.artifactType}.bin`);
31
+ await fetch(`${this.options.endpoint}/ingest/v1/projects/${encodeURIComponent(this.options.projectId)}/runs/${runId}/artifacts`, {
32
+ method: "POST",
33
+ headers: this.buildHeaders(),
34
+ body: formData
35
+ });
36
+ }
37
+ buildHeaders() {
38
+ return {
39
+ "X-Project-Api-Key": this.options.apiKey
40
+ };
41
+ }
42
+ }
@@ -0,0 +1,26 @@
1
+ export interface TestPulseReporterOptions {
2
+ endpoint?: string;
3
+ projectId: string;
4
+ apiKey: string;
5
+ runName?: string;
6
+ branch?: string;
7
+ commitSha?: string;
8
+ liveStreamingEnabled?: boolean;
9
+ liveCaptureIntervalMs?: number;
10
+ liveStreamSessionKey?: string;
11
+ liveCaptureStateDirectory?: string;
12
+ }
13
+ export type EventPayload = Record<string, unknown>;
14
+ export type RunEventEnvelope = {
15
+ sequence: number;
16
+ timestamp: string;
17
+ eventType: string;
18
+ payload: EventPayload;
19
+ };
20
+ export type StartRunResponse = {
21
+ runId: string;
22
+ streamPublisherToken?: string;
23
+ };
24
+ export interface ResolvedTestPulseReporterOptions extends Omit<TestPulseReporterOptions, "endpoint"> {
25
+ endpoint: string;
26
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export declare function resolveTestPulseEndpoint(endpoint: string | undefined, environment?: NodeJS.ProcessEnv): string;
@@ -0,0 +1,25 @@
1
+ const defaultEndpoints = {
2
+ production: "https://testpulse.mevodo.com",
3
+ staging: "https://testpulse.staging.mevodo.com",
4
+ local: "http://localhost:5123"
5
+ };
6
+ export function resolveTestPulseEndpoint(endpoint, environment = process.env) {
7
+ const explicitEndpoint = trim(endpoint) ?? trim(environment.TESTPULSE_ENDPOINT);
8
+ if (explicitEndpoint) {
9
+ return trimTrailingSlash(explicitEndpoint);
10
+ }
11
+ return defaultEndpoints[parseEnvironment(environment.TESTPULSE_ENV)];
12
+ }
13
+ function parseEnvironment(value) {
14
+ const normalized = value?.trim().toLowerCase();
15
+ return normalized === "local" || normalized === "staging"
16
+ ? normalized
17
+ : "production";
18
+ }
19
+ function trim(value) {
20
+ const trimmed = value?.trim();
21
+ return trimmed ? trimmed : undefined;
22
+ }
23
+ function trimTrailingSlash(value) {
24
+ return value.replace(/\/+$/, "");
25
+ }
@@ -0,0 +1,2 @@
1
+ export declare function postJson(url: string, body: unknown, headers?: HeadersInit): Promise<Response>;
2
+ export declare function postJsonOrThrow(url: string, body: unknown, errorPrefix: string, headers?: HeadersInit): Promise<Response>;
@@ -0,0 +1,17 @@
1
+ export async function postJson(url, body, headers = {}) {
2
+ return fetch(url, {
3
+ method: "POST",
4
+ headers: {
5
+ "content-type": "application/json",
6
+ ...headers
7
+ },
8
+ body: JSON.stringify(body)
9
+ });
10
+ }
11
+ export async function postJsonOrThrow(url, body, errorPrefix, headers = {}) {
12
+ const response = await postJson(url, body, headers);
13
+ if (!response.ok) {
14
+ throw new Error(`${errorPrefix} ${response.status}.`);
15
+ }
16
+ return response;
17
+ }