@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
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # @testpulse.run/reporter
2
+
3
+ Playwright reporter for streaming TestPulse runs and optional live browser capture.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @playwright/test @testpulse.run/reporter
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ import { defineConfig } from "@playwright/test";
15
+
16
+ export default defineConfig({
17
+ reporter: [
18
+ ["list"],
19
+ ["@testpulse.run/reporter", {
20
+ projectId: "demo-project",
21
+ apiKey: "<project-api-key>"
22
+ }]
23
+ ]
24
+ });
25
+ ```
26
+
27
+ ## Auto Live Capture
28
+
29
+ ```ts
30
+ import { expect, test } from "@testpulse.run/reporter/fixtures";
31
+
32
+ test("streams automatically when page is used", async ({ page }) => {
33
+ await page.goto("https://playwright.dev");
34
+ await expect(page).toHaveTitle(/Playwright/i);
35
+ });
36
+ ```
37
+
38
+ When `liveStreamingEnabled` is enabled on the reporter, the fixture automatically starts and stops live capture for tests that use the `page` fixture. The package bundles `ffmpeg` automatically. To override the encoder path, set `TESTPULSE_FFMPEG_PATH`.
@@ -0,0 +1,14 @@
1
+ import type { Page } from "@playwright/test";
2
+ import { startLiveCapture } from "../live/LiveCaptureService.js";
3
+ import type { LiveCaptureHandle, LivePage } from "../live/types.js";
4
+ import type { TestPulseFixturesOptions } from "./types.js";
5
+ type StartLiveCaptureFn = (page: LivePage, options?: Parameters<typeof startLiveCapture>[1]) => Promise<LiveCaptureHandle>;
6
+ export declare function createAutoLiveCapturePageFixture(options?: TestPulseFixturesOptions, startCapture?: StartLiveCaptureFn): ({ page }: {
7
+ page: Page;
8
+ }, use: (page: Page) => Promise<void>, testInfo: {
9
+ title: string;
10
+ }) => Promise<void>;
11
+ export declare function createTestPulseTest(options?: TestPulseFixturesOptions): import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions>;
12
+ declare const expect: import("@playwright/test").Expect<{}>;
13
+ export declare const test: import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions>;
14
+ export { expect };
@@ -0,0 +1,35 @@
1
+ import { startLiveCapture } from "../live/LiveCaptureService.js";
2
+ import { resolveTestPulseFixturesOptions } from "./options.js";
3
+ import { loadConsumerPlaywrightTest } from "./runtime.js";
4
+ export function createAutoLiveCapturePageFixture(options = {}, startCapture = startLiveCapture) {
5
+ return async ({ page }, use, testInfo) => {
6
+ const resolved = resolveTestPulseFixturesOptions(options);
7
+ let capture = null;
8
+ if (resolved.enabled) {
9
+ try {
10
+ capture = await startCapture(page, resolved.captureOptions);
11
+ }
12
+ catch (error) {
13
+ if (!resolved.swallowErrors) {
14
+ throw error;
15
+ }
16
+ console.warn(`[TestPulse] Auto live capture skipped for test "${testInfo.title}": ${error instanceof Error ? error.message : String(error)}`);
17
+ }
18
+ }
19
+ try {
20
+ await use(page);
21
+ }
22
+ finally {
23
+ await capture?.stop();
24
+ }
25
+ };
26
+ }
27
+ export function createTestPulseTest(options = {}) {
28
+ const { test: base } = loadConsumerPlaywrightTest();
29
+ return base.extend({
30
+ page: createAutoLiveCapturePageFixture(options)
31
+ });
32
+ }
33
+ const { expect } = loadConsumerPlaywrightTest();
34
+ export const test = createTestPulseTest();
35
+ export { expect };
@@ -0,0 +1,2 @@
1
+ import type { ResolvedTestPulseFixturesOptions, TestPulseFixturesOptions } from "./types.js";
2
+ export declare function resolveTestPulseFixturesOptions(options?: TestPulseFixturesOptions, environment?: NodeJS.ProcessEnv): ResolvedTestPulseFixturesOptions;
@@ -0,0 +1,35 @@
1
+ import { resolveTestPulseEndpoint } from "../shared/endpoint.js";
2
+ export function resolveTestPulseFixturesOptions(options = {}, environment = process.env) {
3
+ const enabled = options.enabled ?? environment.TESTPULSE_ENABLE_LIVE_STREAMING === "1";
4
+ const swallowErrors = options.swallowErrors ?? true;
5
+ return {
6
+ enabled,
7
+ swallowErrors,
8
+ captureOptions: {
9
+ endpoint: resolveTestPulseEndpoint(options.endpoint, environment),
10
+ runId: options.runId ?? trim(environment.TESTPULSE_RUN_ID),
11
+ streamPublisherToken: options.streamPublisherToken ?? trim(environment.TESTPULSE_STREAM_PUBLISHER_TOKEN),
12
+ sessionKey: options.sessionKey ?? trim(environment.TESTPULSE_LIVE_CAPTURE_SESSION_KEY),
13
+ stateDirectory: options.stateDirectory ?? trim(environment.TESTPULSE_LIVE_CAPTURE_STATE_DIRECTORY),
14
+ intervalMs: options.intervalMs ?? parseOptionalNumber(environment.TESTPULSE_LIVE_CAPTURE_INTERVAL_MS),
15
+ profile: options.profile ?? parseProfile(environment.TESTPULSE_LIVE_CAPTURE_PROFILE)
16
+ }
17
+ };
18
+ }
19
+ function trim(value) {
20
+ const trimmed = value?.trim();
21
+ return trimmed ? trimmed : undefined;
22
+ }
23
+ function parseOptionalNumber(value) {
24
+ const trimmed = value?.trim();
25
+ if (!trimmed) {
26
+ return undefined;
27
+ }
28
+ const parsed = Number(trimmed);
29
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
30
+ }
31
+ function parseProfile(value) {
32
+ return value === "focus" || value === "grid"
33
+ ? value
34
+ : undefined;
35
+ }
@@ -0,0 +1,3 @@
1
+ type PlaywrightTestModule = typeof import("@playwright/test");
2
+ export declare function loadConsumerPlaywrightTest(baseDirectory?: string): PlaywrightTestModule;
3
+ export {};
@@ -0,0 +1,6 @@
1
+ import { createRequire } from "node:module";
2
+ import { cwd } from "node:process";
3
+ export function loadConsumerPlaywrightTest(baseDirectory = cwd()) {
4
+ const require = createRequire(`${baseDirectory}/package.json`);
5
+ return require("@playwright/test");
6
+ }
@@ -0,0 +1,10 @@
1
+ import type { LiveCaptureOptions } from "../live/types.js";
2
+ export interface TestPulseFixturesOptions extends LiveCaptureOptions {
3
+ enabled?: boolean;
4
+ swallowErrors?: boolean;
5
+ }
6
+ export interface ResolvedTestPulseFixturesOptions {
7
+ enabled: boolean;
8
+ swallowErrors: boolean;
9
+ captureOptions: LiveCaptureOptions;
10
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ export { createAutoLiveCapturePageFixture, createTestPulseTest, expect, test } from "./fixtures/createTestPulseTest.js";
2
+ export type { ResolvedTestPulseFixturesOptions, TestPulseFixturesOptions } from "./fixtures/types.js";
@@ -0,0 +1 @@
1
+ export { createAutoLiveCapturePageFixture, createTestPulseTest, expect, test } from "./fixtures/createTestPulseTest.js";
@@ -0,0 +1,2 @@
1
+ export type { TestPulseReporterOptions } from "./reporter/types.js";
2
+ export { default } from "./reporter/PlaywrightReporter.js";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./reporter/PlaywrightReporter.js";
@@ -0,0 +1,22 @@
1
+ import type { ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import type { LivePage, StreamDescriptor, TimingKey } from "./types.js";
3
+ export declare class CaptureScheduler {
4
+ private readonly page;
5
+ private readonly encoder;
6
+ private readonly descriptor;
7
+ private readonly intervalMs;
8
+ private readonly logTimingOnce;
9
+ private stopped;
10
+ private frameWritable;
11
+ private latestFrame;
12
+ private captureInFlight;
13
+ private encoderTimer;
14
+ private captureTimer;
15
+ constructor(page: LivePage, encoder: ChildProcessWithoutNullStreams, descriptor: StreamDescriptor, intervalMs: number | undefined, logTimingOnce: (key: TimingKey, extra?: Record<string, number | string>) => void);
16
+ captureInitialFrame(): Promise<Buffer | null>;
17
+ writeInitialFrame(frame: Buffer | null): void;
18
+ start(): void;
19
+ stop(): void;
20
+ private captureFrame;
21
+ private writeFrameToEncoder;
22
+ }
@@ -0,0 +1,87 @@
1
+ export class CaptureScheduler {
2
+ page;
3
+ encoder;
4
+ descriptor;
5
+ intervalMs;
6
+ logTimingOnce;
7
+ stopped = false;
8
+ frameWritable = true;
9
+ latestFrame = null;
10
+ captureInFlight = null;
11
+ encoderTimer = null;
12
+ captureTimer = null;
13
+ constructor(page, encoder, descriptor, intervalMs, logTimingOnce) {
14
+ this.page = page;
15
+ this.encoder = encoder;
16
+ this.descriptor = descriptor;
17
+ this.intervalMs = intervalMs;
18
+ this.logTimingOnce = logTimingOnce;
19
+ this.encoder.stdin.on("drain", () => {
20
+ this.frameWritable = true;
21
+ });
22
+ }
23
+ async captureInitialFrame() {
24
+ return this.captureFrame();
25
+ }
26
+ writeInitialFrame(frame) {
27
+ this.writeFrameToEncoder(frame);
28
+ }
29
+ start() {
30
+ const encoderIntervalMs = Math.max(20, Math.round(1000 / this.descriptor.framesPerSecond));
31
+ const captureIntervalMs = Math.max(20, this.intervalMs ?? (this.descriptor.profile === "grid" ? 250 : 83));
32
+ this.encoderTimer = setInterval(() => {
33
+ if (this.stopped) {
34
+ return;
35
+ }
36
+ // Keep the encoder fed at a stable cadence so fragmented MP4 emits promptly
37
+ // even when screenshot capture itself is running at a lower interval.
38
+ this.writeFrameToEncoder(this.latestFrame);
39
+ }, encoderIntervalMs);
40
+ this.captureTimer = setInterval(() => {
41
+ if (this.stopped) {
42
+ return;
43
+ }
44
+ void this.captureFrame()
45
+ .then(() => undefined)
46
+ .catch(() => {
47
+ // Best-effort live capture should not fail the run.
48
+ });
49
+ }, captureIntervalMs);
50
+ }
51
+ stop() {
52
+ this.stopped = true;
53
+ if (this.encoderTimer) {
54
+ clearInterval(this.encoderTimer);
55
+ }
56
+ if (this.captureTimer) {
57
+ clearInterval(this.captureTimer);
58
+ }
59
+ }
60
+ async captureFrame() {
61
+ if (this.stopped) {
62
+ return null;
63
+ }
64
+ if (!this.captureInFlight) {
65
+ this.captureInFlight = this.page.screenshot({ type: "jpeg", quality: 60, scale: "css" })
66
+ .then((screenshot) => {
67
+ const frame = Buffer.from(screenshot);
68
+ this.latestFrame = frame;
69
+ this.logTimingOnce("firstScreenshotTaken", { bytes: frame.length });
70
+ return frame;
71
+ })
72
+ .catch(() => null)
73
+ .finally(() => {
74
+ this.captureInFlight = null;
75
+ });
76
+ }
77
+ return this.captureInFlight;
78
+ }
79
+ writeFrameToEncoder(frame) {
80
+ if (this.stopped || !frame || !this.frameWritable) {
81
+ return false;
82
+ }
83
+ this.frameWritable = this.encoder.stdin.write(frame);
84
+ this.logTimingOnce("firstFrameWrittenToEncoder", { bytes: frame.length });
85
+ return true;
86
+ }
87
+ }
@@ -0,0 +1,5 @@
1
+ import type { ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import type { StreamDescriptor } from "./types.js";
3
+ export declare function startEncoderProcess(descriptor: StreamDescriptor): ChildProcessWithoutNullStreams;
4
+ export declare function resolveFfmpegPath(): string;
5
+ export declare function waitForEncoder(encoder: ChildProcessWithoutNullStreams): Promise<void>;
@@ -0,0 +1,61 @@
1
+ import { spawn } from "node:child_process";
2
+ import { once } from "node:events";
3
+ import { createRequire } from "node:module";
4
+ const require = createRequire(import.meta.url);
5
+ export function startEncoderProcess(descriptor) {
6
+ const gopFrames = Math.max(1, Math.round(descriptor.framesPerSecond * descriptor.gopDurationMs / 1000));
7
+ const ffmpeg = spawn(resolveFfmpegPath(), [
8
+ "-loglevel", "error",
9
+ "-f", "image2pipe",
10
+ "-r", String(descriptor.framesPerSecond),
11
+ "-i", "pipe:0",
12
+ "-an",
13
+ "-vf", `scale=${descriptor.width}:${descriptor.height}:force_original_aspect_ratio=decrease,pad=${descriptor.width}:${descriptor.height}:(ow-iw)/2:(oh-ih)/2`,
14
+ "-c:v", "libx264",
15
+ "-preset", "veryfast",
16
+ "-tune", "zerolatency",
17
+ "-pix_fmt", "yuv420p",
18
+ "-profile:v", "baseline",
19
+ "-level", "3.0",
20
+ "-g", String(gopFrames),
21
+ "-keyint_min", String(gopFrames),
22
+ "-sc_threshold", "0",
23
+ "-force_key_frames", `expr:gte(t,n_forced*${descriptor.gopDurationMs / 1000})`,
24
+ "-b:v", `${descriptor.targetBitrateKbps}k`,
25
+ "-maxrate", `${descriptor.targetBitrateKbps}k`,
26
+ "-bufsize", `${descriptor.targetBitrateKbps * 2}k`,
27
+ "-movflags", "frag_keyframe+empty_moov+default_base_moof+separate_moof",
28
+ "-frag_duration", String(descriptor.segmentDurationMs * 1000),
29
+ "-f", "mp4",
30
+ "pipe:1"
31
+ ], {
32
+ stdio: ["pipe", "pipe", "pipe"]
33
+ });
34
+ ffmpeg.once("error", (error) => {
35
+ throw error;
36
+ });
37
+ return ffmpeg;
38
+ }
39
+ export function resolveFfmpegPath() {
40
+ const configuredPath = process.env.TESTPULSE_FFMPEG_PATH?.trim();
41
+ if (configuredPath) {
42
+ return configuredPath;
43
+ }
44
+ try {
45
+ const bundledPath = require("ffmpeg-static");
46
+ if (typeof bundledPath === "string" && bundledPath.trim()) {
47
+ return bundledPath;
48
+ }
49
+ }
50
+ catch {
51
+ // Fall back to PATH lookup below.
52
+ }
53
+ return "ffmpeg";
54
+ }
55
+ export async function waitForEncoder(encoder) {
56
+ const [code] = await once(encoder, "close");
57
+ if (code && code !== 0) {
58
+ const stderr = encoder.stderr.read()?.toString() ?? "";
59
+ throw new Error(`ffmpeg exited with code ${code}. ${stderr}`.trim());
60
+ }
61
+ }
@@ -0,0 +1,2 @@
1
+ import type { LiveCaptureHandle, LiveCaptureOptions, LivePage } from "./types.js";
2
+ export declare function startLiveCapture(page: LivePage, options?: LiveCaptureOptions): Promise<LiveCaptureHandle>;
@@ -0,0 +1,103 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { CaptureScheduler } from "./CaptureScheduler.js";
3
+ import { startEncoderProcess, waitForEncoder } from "./EncoderProcess.js";
4
+ import { resolveLiveCaptureSession } from "./LiveSessionResolver.js";
5
+ import { wireFragmentedMp4Stream } from "./Mp4FragmentParser.js";
6
+ import { SegmentQueue } from "./SegmentQueue.js";
7
+ import { StreamUploader } from "./StreamUploader.js";
8
+ import { createTimingLogger, logTiming } from "./telemetry.js";
9
+ export async function startLiveCapture(page, options = {}) {
10
+ const session = await resolveLiveCaptureSession(options);
11
+ const intervalMs = options.intervalMs ?? session.intervalMs;
12
+ const profile = options.profile ?? session.profile ?? "focus";
13
+ const descriptor = await buildDescriptor(page, profile, intervalMs);
14
+ const streamId = randomUUID();
15
+ const captureStartedAt = Date.now();
16
+ const logTimingOnce = createTimingLogger(captureStartedAt);
17
+ const encoder = startEncoderProcess(descriptor);
18
+ const uploader = new StreamUploader(session, streamId, descriptor, captureStartedAt);
19
+ const uploadQueue = new SegmentQueue((error) => {
20
+ console.warn(`[TestPulse] Live streaming disabled for run ${session.runId}: ${error.message}`);
21
+ });
22
+ const scheduler = new CaptureScheduler(page, encoder, descriptor, intervalMs, logTimingOnce);
23
+ let stopped = false;
24
+ let previewUploaded = false;
25
+ let sequence = 0;
26
+ let gopId = 0;
27
+ logTiming("captureStart", captureStartedAt);
28
+ wireFragmentedMp4Stream(encoder.stdout, {
29
+ onInitSegment(payload) {
30
+ void enqueueSegment("init", payload);
31
+ },
32
+ onMediaSegment(payload) {
33
+ if ((sequence + 1) % Math.max(1, Math.round(descriptor.gopDurationMs / descriptor.segmentDurationMs)) === 0) {
34
+ gopId += 1;
35
+ }
36
+ void enqueueSegment("media", payload);
37
+ }
38
+ });
39
+ await uploader.start();
40
+ const initialFrame = await scheduler.captureInitialFrame();
41
+ if (initialFrame && !previewUploaded && !stopped) {
42
+ try {
43
+ await uploader.uploadPreview(initialFrame);
44
+ previewUploaded = true;
45
+ logTimingOnce("previewPosted", { bytes: initialFrame.length });
46
+ }
47
+ catch (error) {
48
+ console.warn(`[TestPulse] Live preview upload skipped for run ${session.runId}: ${error instanceof Error ? error.message : String(error)}`);
49
+ }
50
+ }
51
+ scheduler.writeInitialFrame(initialFrame);
52
+ scheduler.start();
53
+ return {
54
+ async stop() {
55
+ if (stopped) {
56
+ return;
57
+ }
58
+ stopped = true;
59
+ scheduler.stop();
60
+ encoder.stdin.end();
61
+ await waitForEncoder(encoder);
62
+ await uploadQueue.flush();
63
+ await uploader.stop();
64
+ }
65
+ };
66
+ function enqueueSegment(segmentKind, payload) {
67
+ const segmentSequence = ++sequence;
68
+ return uploadQueue.enqueue(async () => {
69
+ await uploader.uploadSegment({
70
+ sequence: segmentSequence,
71
+ gopId,
72
+ segmentKind,
73
+ payload,
74
+ publishedAtUtc: new Date().toISOString()
75
+ });
76
+ });
77
+ }
78
+ }
79
+ async function buildDescriptor(page, profile, intervalMs) {
80
+ const viewport = page.viewportSize() ?? { width: 1280, height: 720 };
81
+ const focusWidth = Math.min(1280, viewport.width);
82
+ const focusHeight = even(Math.min(720, viewport.height));
83
+ const gridWidth = 640;
84
+ const gridHeight = 360;
85
+ const sampledFramesPerSecond = profile === "grid"
86
+ ? Math.max(1, Math.round(1000 / Math.max(250, intervalMs ?? 250)))
87
+ : Math.max(1, Math.round(1000 / Math.max(83, intervalMs ?? 83)));
88
+ const framesPerSecond = Math.max(8, sampledFramesPerSecond);
89
+ return {
90
+ streamId: randomUUID(),
91
+ profile,
92
+ mimeCodec: "video/mp4; codecs=\"avc1.42E01E\"",
93
+ width: even(profile === "grid" ? gridWidth : focusWidth),
94
+ height: profile === "grid" ? gridHeight : focusHeight,
95
+ framesPerSecond,
96
+ targetBitrateKbps: profile === "grid" ? 300 : 1200,
97
+ segmentDurationMs: 250,
98
+ gopDurationMs: 500
99
+ };
100
+ }
101
+ function even(value) {
102
+ return value % 2 === 0 ? value : value - 1;
103
+ }
@@ -0,0 +1,2 @@
1
+ import type { LiveCaptureOptions, LiveCaptureState } from "./types.js";
2
+ export declare function resolveLiveCaptureSession(options: LiveCaptureOptions, now?: () => number, wait?: (ms: number) => Promise<unknown>): Promise<LiveCaptureState>;
@@ -0,0 +1,66 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { getLiveCaptureStateDirectory, getLiveCaptureStatePath } from "./LiveSessionState.js";
3
+ export async function resolveLiveCaptureSession(options, now = () => Date.now(), wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
4
+ if (options.endpoint && options.projectId && options.apiKey && options.runId && options.streamPublisherToken) {
5
+ return {
6
+ endpoint: options.endpoint,
7
+ projectId: options.projectId,
8
+ apiKey: options.apiKey,
9
+ runId: options.runId,
10
+ streamPublisherToken: options.streamPublisherToken,
11
+ createdAt: new Date(now()).toISOString()
12
+ };
13
+ }
14
+ const deadline = now() + 30_000;
15
+ while (now() < deadline) {
16
+ const session = options.sessionKey
17
+ ? await tryReadStateFile(getLiveCaptureStatePath(options.sessionKey, options.stateDirectory), now)
18
+ : await tryReadLatestStateFile(options.stateDirectory, now);
19
+ if (session) {
20
+ return session;
21
+ }
22
+ await wait(250);
23
+ }
24
+ const path = options.sessionKey
25
+ ? getLiveCaptureStatePath(options.sessionKey, options.stateDirectory)
26
+ : getLiveCaptureStateDirectory(options.stateDirectory);
27
+ throw new Error(`TestPulse live streaming session state was not found at ${path}.`);
28
+ }
29
+ async function tryReadLatestStateFile(stateDirectory, now) {
30
+ try {
31
+ const directory = getLiveCaptureStateDirectory(stateDirectory);
32
+ const entries = (await readdir(directory))
33
+ .filter((entry) => entry.endsWith(".json"))
34
+ .sort();
35
+ let freshest = null;
36
+ let freshestCreatedAt = -Infinity;
37
+ for (const entry of entries) {
38
+ const candidate = await tryReadStateFile(`${directory}/${entry}`, now);
39
+ if (!candidate) {
40
+ continue;
41
+ }
42
+ const createdAt = Date.parse(candidate.createdAt);
43
+ if (createdAt > freshestCreatedAt) {
44
+ freshest = candidate;
45
+ freshestCreatedAt = createdAt;
46
+ }
47
+ }
48
+ return freshest;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ async function tryReadStateFile(path, now) {
55
+ try {
56
+ const payload = JSON.parse(await readFile(path, "utf8"));
57
+ const createdAt = Date.parse(payload.createdAt);
58
+ if (payload.endpoint && payload.projectId && payload.apiKey && payload.runId && payload.streamPublisherToken && !Number.isNaN(createdAt) && createdAt > now() - 60_000) {
59
+ return payload;
60
+ }
61
+ }
62
+ catch {
63
+ // Reporter may not have created the state file yet.
64
+ }
65
+ return null;
66
+ }
@@ -0,0 +1,6 @@
1
+ import type { LiveCaptureState } from "./types.js";
2
+ export declare function getLiveCaptureSessionKey(sessionKey?: string): string;
3
+ export declare function getLiveCaptureStateDirectory(stateDirectory?: string): string;
4
+ export declare function getLiveCaptureStatePath(sessionKey?: string, stateDirectory?: string): string;
5
+ export declare function writeLiveCaptureState(state: LiveCaptureState, sessionKey?: string, stateDirectory?: string): Promise<void>;
6
+ export declare function deleteLiveCaptureState(sessionKey?: string, stateDirectory?: string): Promise<void>;
@@ -0,0 +1,20 @@
1
+ import { mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ export function getLiveCaptureSessionKey(sessionKey) {
5
+ return sessionKey?.trim() || "default";
6
+ }
7
+ export function getLiveCaptureStateDirectory(stateDirectory) {
8
+ return stateDirectory?.trim() || join(tmpdir(), "testpulse-live-capture");
9
+ }
10
+ export function getLiveCaptureStatePath(sessionKey, stateDirectory) {
11
+ return join(getLiveCaptureStateDirectory(stateDirectory), `${getLiveCaptureSessionKey(sessionKey)}.json`);
12
+ }
13
+ export async function writeLiveCaptureState(state, sessionKey, stateDirectory) {
14
+ const path = getLiveCaptureStatePath(sessionKey, stateDirectory);
15
+ await mkdir(getLiveCaptureStateDirectory(stateDirectory), { recursive: true });
16
+ await writeFile(path, JSON.stringify(state), "utf8");
17
+ }
18
+ export async function deleteLiveCaptureState(sessionKey, stateDirectory) {
19
+ await rm(getLiveCaptureStatePath(sessionKey, stateDirectory), { force: true });
20
+ }
@@ -0,0 +1,4 @@
1
+ export declare function wireFragmentedMp4Stream(stdout: NodeJS.ReadableStream, handlers: {
2
+ onInitSegment(payload: Buffer): void;
3
+ onMediaSegment(payload: Buffer): void;
4
+ }): void;
@@ -0,0 +1,32 @@
1
+ export function wireFragmentedMp4Stream(stdout, handlers) {
2
+ let buffer = Buffer.alloc(0);
3
+ const initBoxes = [];
4
+ let initEmitted = false;
5
+ let pendingFragment = [];
6
+ stdout.on("data", (chunk) => {
7
+ buffer = Buffer.concat([buffer, chunk]);
8
+ while (buffer.length >= 8) {
9
+ const size = buffer.readUInt32BE(0);
10
+ if (size <= 0 || buffer.length < size) {
11
+ return;
12
+ }
13
+ const box = buffer.subarray(0, size);
14
+ buffer = buffer.subarray(size);
15
+ const type = box.subarray(4, 8).toString("ascii");
16
+ if (!initEmitted) {
17
+ initBoxes.push(box);
18
+ if (type === "moov") {
19
+ initEmitted = true;
20
+ handlers.onInitSegment(Buffer.concat(initBoxes));
21
+ initBoxes.length = 0;
22
+ }
23
+ continue;
24
+ }
25
+ pendingFragment.push(box);
26
+ if (type === "mdat") {
27
+ handlers.onMediaSegment(Buffer.concat(pendingFragment));
28
+ pendingFragment = [];
29
+ }
30
+ }
31
+ });
32
+ }
@@ -0,0 +1,9 @@
1
+ export declare class SegmentQueue {
2
+ private readonly onError;
3
+ private chain;
4
+ private disabled;
5
+ private firstError;
6
+ constructor(onError: (error: Error) => void);
7
+ enqueue(task: () => Promise<void>): Promise<void>;
8
+ flush(): Promise<void>;
9
+ }
@@ -0,0 +1,32 @@
1
+ export class SegmentQueue {
2
+ onError;
3
+ chain = Promise.resolve();
4
+ disabled = false;
5
+ firstError = null;
6
+ constructor(onError) {
7
+ this.onError = onError;
8
+ }
9
+ enqueue(task) {
10
+ if (this.disabled) {
11
+ return this.chain;
12
+ }
13
+ this.chain = this.chain.then(task).catch((error) => {
14
+ const resolved = error instanceof Error ? error : new Error(String(error));
15
+ if (!this.firstError) {
16
+ this.firstError = resolved;
17
+ this.onError(resolved);
18
+ }
19
+ this.disabled = true;
20
+ });
21
+ return this.chain;
22
+ }
23
+ async flush() {
24
+ await this.chain.catch((error) => {
25
+ const resolved = error instanceof Error ? error : new Error(String(error));
26
+ if (!this.firstError) {
27
+ this.firstError = resolved;
28
+ this.onError(resolved);
29
+ }
30
+ });
31
+ }
32
+ }