@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.
- package/README.md +38 -0
- package/dist/fixtures/createTestPulseTest.d.ts +14 -0
- package/dist/fixtures/createTestPulseTest.js +35 -0
- package/dist/fixtures/options.d.ts +2 -0
- package/dist/fixtures/options.js +35 -0
- package/dist/fixtures/runtime.d.ts +3 -0
- package/dist/fixtures/runtime.js +6 -0
- package/dist/fixtures/types.d.ts +10 -0
- package/dist/fixtures/types.js +1 -0
- package/dist/fixtures.d.ts +2 -0
- package/dist/fixtures.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/live/CaptureScheduler.d.ts +22 -0
- package/dist/live/CaptureScheduler.js +87 -0
- package/dist/live/EncoderProcess.d.ts +5 -0
- package/dist/live/EncoderProcess.js +61 -0
- package/dist/live/LiveCaptureService.d.ts +2 -0
- package/dist/live/LiveCaptureService.js +103 -0
- package/dist/live/LiveSessionResolver.d.ts +2 -0
- package/dist/live/LiveSessionResolver.js +66 -0
- package/dist/live/LiveSessionState.d.ts +6 -0
- package/dist/live/LiveSessionState.js +20 -0
- package/dist/live/Mp4FragmentParser.d.ts +4 -0
- package/dist/live/Mp4FragmentParser.js +32 -0
- package/dist/live/SegmentQueue.d.ts +9 -0
- package/dist/live/SegmentQueue.js +32 -0
- package/dist/live/StreamUploader.d.ts +21 -0
- package/dist/live/StreamUploader.js +78 -0
- package/dist/live/telemetry.d.ts +3 -0
- package/dist/live/telemetry.js +15 -0
- package/dist/live/types.d.ts +41 -0
- package/dist/live/types.js +1 -0
- package/dist/reporter/ArtifactUploader.d.ts +13 -0
- package/dist/reporter/ArtifactUploader.js +48 -0
- package/dist/reporter/EventQueue.d.ts +7 -0
- package/dist/reporter/EventQueue.js +15 -0
- package/dist/reporter/GitRuntimeMetadataResolver.d.ts +11 -0
- package/dist/reporter/GitRuntimeMetadataResolver.js +93 -0
- package/dist/reporter/PlaywrightReporter.d.ts +21 -0
- package/dist/reporter/PlaywrightReporter.js +126 -0
- package/dist/reporter/RunClient.d.ts +19 -0
- package/dist/reporter/RunClient.js +42 -0
- package/dist/reporter/types.d.ts +26 -0
- package/dist/reporter/types.js +1 -0
- package/dist/shared/endpoint.d.ts +1 -0
- package/dist/shared/endpoint.js +25 -0
- package/dist/shared/http.d.ts +2 -0
- package/dist/shared/http.js +17 -0
- 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,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,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
|
+
}
|