@piwitests/reporter 0.4.2

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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +234 -0
  3. package/dist/compression.d.ts +5 -0
  4. package/dist/compression.js +39 -0
  5. package/dist/config-wrapper.d.ts +21 -0
  6. package/dist/config-wrapper.js +64 -0
  7. package/dist/config.d.ts +104 -0
  8. package/dist/config.js +127 -0
  9. package/dist/crash-recovery.d.ts +23 -0
  10. package/dist/crash-recovery.js +105 -0
  11. package/dist/file-handler.d.ts +38 -0
  12. package/dist/file-handler.js +166 -0
  13. package/dist/fixtures.d.ts +25 -0
  14. package/dist/fixtures.js +156 -0
  15. package/dist/global-setup-module.d.ts +2 -0
  16. package/dist/global-setup-module.js +4 -0
  17. package/dist/helpers.d.ts +44 -0
  18. package/dist/helpers.js +288 -0
  19. package/dist/http-client.d.ts +42 -0
  20. package/dist/http-client.js +154 -0
  21. package/dist/index.d.ts +8 -0
  22. package/dist/index.js +6 -0
  23. package/dist/logger.d.ts +26 -0
  24. package/dist/logger.js +43 -0
  25. package/dist/metadata-collector.d.ts +32 -0
  26. package/dist/metadata-collector.js +243 -0
  27. package/dist/reporter.d.ts +65 -0
  28. package/dist/reporter.js +341 -0
  29. package/dist/run-submitter.d.ts +66 -0
  30. package/dist/run-submitter.js +184 -0
  31. package/dist/serializer.d.ts +45 -0
  32. package/dist/serializer.js +104 -0
  33. package/dist/skip-classify.d.ts +27 -0
  34. package/dist/skip-classify.js +40 -0
  35. package/dist/step-analyzer.d.ts +97 -0
  36. package/dist/step-analyzer.js +216 -0
  37. package/dist/stream-buffer.d.ts +17 -0
  38. package/dist/stream-buffer.js +102 -0
  39. package/dist/stream-manager.d.ts +74 -0
  40. package/dist/stream-manager.js +338 -0
  41. package/dist/types.d.ts +251 -0
  42. package/dist/types.js +14 -0
  43. package/dist/uploader.d.ts +86 -0
  44. package/dist/uploader.js +191 -0
  45. package/package.json +62 -0
@@ -0,0 +1,216 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.categorizeStep = categorizeStep;
4
+ exports.flattenSteps = flattenSteps;
5
+ exports.collectStepMetrics = collectStepMetrics;
6
+ exports.percentile = percentile;
7
+ exports.computePerformanceSummary = computePerformanceSummary;
8
+ exports.extractTestStepEvents = extractTestStepEvents;
9
+ exports.extractWaitEvents = extractWaitEvents;
10
+ /**
11
+ * Categorise a Playwright step into `navigation`, `action`, `input`,
12
+ * `assertion`, `wait`, `api`, `hook`, or `other`.
13
+ *
14
+ * Supports two title formats:
15
+ * - Legacy api-path titles ("page.goto", "locator.click", "page.waitForTimeout")
16
+ * - Modern human-readable titles introduced in newer Playwright versions
17
+ * ("Navigate to \"{url}\"", "Click", "Wait for timeout", "Wait for load state").
18
+ * Hook/fixture/expect steps are detected via Playwright's own `category`.
19
+ */
20
+ function categorizeStep(title, pwCategory) {
21
+ if (!title)
22
+ return 'other';
23
+ if (pwCategory === 'hook' || pwCategory === 'fixture')
24
+ return pwCategory;
25
+ if (pwCategory === 'expect')
26
+ return 'assertion';
27
+ const lower = title.toLowerCase();
28
+ // Waits — modern "Wait for timeout/function/selector/state/navigation/load state/url/event"
29
+ // and legacy "*.waitFor*" (locator.waitFor, page.waitForLoadState, frame.waitForTimeout, ...).
30
+ if (lower.startsWith('wait for') ||
31
+ lower.startsWith('locator.waitfor') ||
32
+ lower.startsWith('page.waitfor') ||
33
+ lower.startsWith('frame.waitfor'))
34
+ return 'wait';
35
+ // Navigation — modern "Navigate to ...", "Go back", "Go forward", "Reload"; legacy "page.goto" etc.
36
+ if (lower.startsWith('navigate to') ||
37
+ lower.startsWith('go back') ||
38
+ lower.startsWith('go forward') ||
39
+ lower.startsWith('reload') ||
40
+ lower.startsWith('page.goto') ||
41
+ lower.startsWith('page.reload') ||
42
+ lower.startsWith('page.goback') ||
43
+ lower.startsWith('page.goforward'))
44
+ return 'navigation';
45
+ // Actions — clicks, taps, checks, selects, hovers
46
+ if (lower.startsWith('click') ||
47
+ lower.startsWith('double click') ||
48
+ lower.startsWith('check') ||
49
+ lower.startsWith('uncheck') ||
50
+ lower.startsWith('tap') ||
51
+ lower.startsWith('hover') ||
52
+ lower.startsWith('select option') ||
53
+ lower.startsWith('drag') ||
54
+ lower.startsWith('locator.click') ||
55
+ lower.startsWith('locator.dblclick') ||
56
+ lower.startsWith('locator.check') ||
57
+ lower.startsWith('locator.uncheck') ||
58
+ lower.startsWith('locator.selectoption') ||
59
+ lower.startsWith('locator.tap'))
60
+ return 'action';
61
+ // Input — fill, type, press, insert text, set input files
62
+ if (lower.startsWith('fill ') ||
63
+ lower === 'fill' ||
64
+ lower.startsWith('type') ||
65
+ lower.startsWith('press') ||
66
+ lower.startsWith('insert ') ||
67
+ lower.startsWith('set input files') ||
68
+ lower.startsWith('locator.fill') ||
69
+ lower.startsWith('locator.type') ||
70
+ lower.startsWith('locator.press') ||
71
+ lower.startsWith('locator.clear') ||
72
+ lower.startsWith('locator.setinputfiles'))
73
+ return 'input';
74
+ // Assertions — legacy "expect..." titles (modern ones caught via pwCategory above)
75
+ if (lower.startsWith('expect') || lower.startsWith('locator.expect') || lower.startsWith('page.expect'))
76
+ return 'assertion';
77
+ if (lower.startsWith('apirequestcontext') || lower.startsWith('apiresponse'))
78
+ return 'api';
79
+ if (lower === 'before hooks' || lower === 'after hooks' || lower.startsWith('fixture:'))
80
+ return 'hook';
81
+ return 'other';
82
+ }
83
+ /** Recursively flatten a nested step tree into a flat list. Uses Playwright's built-in category when available. */
84
+ function flattenSteps(steps) {
85
+ const result = [];
86
+ for (const step of steps) {
87
+ result.push({
88
+ title: step.title,
89
+ duration: step.duration,
90
+ category: categorizeStep(step.title, step.category),
91
+ });
92
+ if (step.steps?.length > 0)
93
+ result.push(...flattenSteps(step.steps));
94
+ }
95
+ return result;
96
+ }
97
+ /** Collect step metrics (flat steps, slowest step, navigation stats) from a Playwright step array */
98
+ function collectStepMetrics(steps) {
99
+ const flatSteps = flattenSteps(steps);
100
+ const totalStepDuration = steps.reduce((sum, s) => sum + (s.duration || 0), 0);
101
+ let slowestStep = null;
102
+ for (const s of flatSteps) {
103
+ if (!slowestStep || s.duration > slowestStep.duration)
104
+ slowestStep = { title: s.title, duration: s.duration };
105
+ }
106
+ const navSteps = flatSteps.filter((s) => s.category === 'navigation');
107
+ const waitSteps = flatSteps.filter((s) => s.category === 'wait');
108
+ const waitTotalDuration = waitSteps.reduce((sum, s) => sum + (s.duration || 0), 0);
109
+ return {
110
+ steps: flatSteps,
111
+ totalStepDuration,
112
+ slowestStep,
113
+ navigationCount: navSteps.length,
114
+ navigationTotalDuration: navSteps.reduce((sum, s) => sum + (s.duration || 0), 0),
115
+ waitTotalDuration,
116
+ waitCount: waitSteps.length,
117
+ };
118
+ }
119
+ /** Calculate the p-th percentile from a sorted array of numbers */
120
+ function percentile(sortedArr, p) {
121
+ if (sortedArr.length === 0)
122
+ return 0;
123
+ const index = Math.ceil((p / 100) * sortedArr.length) - 1;
124
+ return sortedArr[Math.max(0, index)];
125
+ }
126
+ /** Compute run-level performance summary (averages, percentiles, slowest tests) from all test cases */
127
+ function computePerformanceSummary(testCases) {
128
+ const durations = testCases.filter((tc) => tc.duration != null).map((tc) => tc.duration);
129
+ if (durations.length === 0)
130
+ return {};
131
+ const sorted = [...durations].sort((a, b) => a - b);
132
+ const sum = durations.reduce((a, b) => a + b, 0);
133
+ const result = {
134
+ avgTestDuration: Math.round(sum / durations.length),
135
+ p50TestDuration: percentile(sorted, 50),
136
+ p90TestDuration: percentile(sorted, 90),
137
+ p95TestDuration: percentile(sorted, 95),
138
+ slowestTests: [...testCases]
139
+ .filter((tc) => tc.duration != null)
140
+ .sort((a, b) => b.duration - a.duration)
141
+ .slice(0, 5)
142
+ .map((tc) => ({ title: tc.title, duration: tc.duration })),
143
+ };
144
+ let totalNavDur = 0;
145
+ let totalNavCount = 0;
146
+ for (const tc of testCases) {
147
+ if (tc.performanceMetrics) {
148
+ totalNavDur += tc.performanceMetrics.navigationTotalDuration || 0;
149
+ totalNavCount += tc.performanceMetrics.navigationCount || 0;
150
+ }
151
+ }
152
+ result.totalNavigationDuration = totalNavDur;
153
+ result.avgNavigationDuration = totalNavCount > 0 ? Math.round(totalNavDur / totalNavCount) : 0;
154
+ let totalWasted = 0;
155
+ for (const tc of testCases) {
156
+ if (tc.performanceMetrics) {
157
+ totalWasted += tc.performanceMetrics.waitTotalDuration || 0;
158
+ }
159
+ }
160
+ result.totalWastedTimeMs = totalWasted;
161
+ return result;
162
+ }
163
+ /**
164
+ * Extract hook and fixture step events with absolute timings from a Playwright
165
+ * step tree. These are used by the WorkersTimeline to render hook segments.
166
+ *
167
+ * Returns only top-level hook/fixture steps (beforeEach, afterEach, fixture
168
+ * setup/teardown) — their sub-steps are included implicitly in their duration.
169
+ */
170
+ function extractTestStepEvents(steps, _testStartTime) {
171
+ const events = [];
172
+ for (const step of steps) {
173
+ const cat = categorizeStep(step.title, step.category);
174
+ if (cat !== 'hook' && cat !== 'fixture')
175
+ continue;
176
+ if (!step.startTime)
177
+ continue;
178
+ const startedAt = step.startTime instanceof Date ? step.startTime.getTime() : step.startTime;
179
+ events.push({
180
+ title: step.title,
181
+ category: cat,
182
+ startedAt,
183
+ duration: step.duration || 0,
184
+ status: step.error ? 'failed' : 'passed',
185
+ location: step.location ? `${step.location.file}:${step.location.line}:${step.location.column}` : null,
186
+ });
187
+ }
188
+ return events;
189
+ }
190
+ /**
191
+ * Recursively extract wait-category steps from the Playwright step tree
192
+ * with absolute timings. These are rendered as semi-transparent amber bars
193
+ * on WorkersTimeline to visualize wasted time.
194
+ */
195
+ function extractWaitEvents(steps, insideWait = false) {
196
+ const events = [];
197
+ for (const step of steps) {
198
+ const cat = categorizeStep(step.title, step.category);
199
+ const isWait = cat === 'wait';
200
+ if (isWait && !insideWait && step.startTime) {
201
+ const startedAt = step.startTime instanceof Date ? step.startTime.getTime() : step.startTime;
202
+ events.push({
203
+ title: step.title,
204
+ category: 'wait',
205
+ startedAt,
206
+ duration: step.duration || 0,
207
+ status: 'wasted',
208
+ location: step.location ? `${step.location.file}:${step.location.line}:${step.location.column}` : null,
209
+ });
210
+ }
211
+ if (step.steps?.length > 0) {
212
+ events.push(...extractWaitEvents(step.steps, insideWait || isWait));
213
+ }
214
+ }
215
+ return events;
216
+ }
@@ -0,0 +1,17 @@
1
+ import type { StreamEvent } from './types.js';
2
+ /**
3
+ * Persistent JSONL buffer on disk. Events are appended to a temp file so they
4
+ * survive a crash and can be replayed when the reporter restarts.
5
+ */
6
+ export declare class StreamBuffer {
7
+ private filePath;
8
+ constructor(projectName: string);
9
+ /** Append one or more events to the on-disk buffer */
10
+ append(events: StreamEvent[]): void;
11
+ /** Load all buffered events from disk, clearing the file */
12
+ load(): StreamEvent[];
13
+ /** Delete the buffer file from disk */
14
+ clear(): void;
15
+ /** Remove the buffer file if it is older than `maxAgeMs` (default 2 hours). Used on startup to discard orphaned data. */
16
+ clearStale(maxAgeMs?: number): void;
17
+ }
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.StreamBuffer = void 0;
37
+ const path = __importStar(require("path"));
38
+ const os = __importStar(require("os"));
39
+ const fs = __importStar(require("fs"));
40
+ const helpers_js_1 = require("./helpers.js");
41
+ /**
42
+ * Persistent JSONL buffer on disk. Events are appended to a temp file so they
43
+ * survive a crash and can be replayed when the reporter restarts.
44
+ */
45
+ class StreamBuffer {
46
+ constructor(projectName) {
47
+ this.filePath = path.join(os.tmpdir(), `piwi-dashboard-stream-${(0, helpers_js_1.hashForProject)(projectName)}.jsonl`);
48
+ }
49
+ /** Append one or more events to the on-disk buffer */
50
+ append(events) {
51
+ if (events.length === 0)
52
+ return;
53
+ try {
54
+ const lines = events.map((e) => JSON.stringify(e) + '\n').join('');
55
+ fs.appendFileSync(this.filePath, lines, 'utf8');
56
+ }
57
+ catch {
58
+ // Non-fatal
59
+ }
60
+ }
61
+ /** Load all buffered events from disk, clearing the file */
62
+ load() {
63
+ try {
64
+ if (fs.existsSync(this.filePath)) {
65
+ const content = fs.readFileSync(this.filePath, 'utf8');
66
+ return content
67
+ .split('\n')
68
+ .filter(Boolean)
69
+ .map((line) => JSON.parse(line));
70
+ }
71
+ }
72
+ catch {
73
+ // Non-fatal
74
+ }
75
+ return [];
76
+ }
77
+ /** Delete the buffer file from disk */
78
+ clear() {
79
+ try {
80
+ if (fs.existsSync(this.filePath))
81
+ fs.unlinkSync(this.filePath);
82
+ }
83
+ catch {
84
+ // Non-fatal
85
+ }
86
+ }
87
+ /** Remove the buffer file if it is older than `maxAgeMs` (default 2 hours). Used on startup to discard orphaned data. */
88
+ clearStale(maxAgeMs = 7200000) {
89
+ try {
90
+ if (fs.existsSync(this.filePath)) {
91
+ const stats = fs.statSync(this.filePath);
92
+ if (Date.now() - stats.mtimeMs > maxAgeMs) {
93
+ fs.unlinkSync(this.filePath);
94
+ }
95
+ }
96
+ }
97
+ catch {
98
+ // Non-fatal
99
+ }
100
+ }
101
+ }
102
+ exports.StreamBuffer = StreamBuffer;
@@ -0,0 +1,74 @@
1
+ import type { PiwiDashboardOptions, ShardInfo } from './config.js';
2
+ import { HttpClient } from './http-client.js';
3
+ import { StreamBuffer } from './stream-buffer.js';
4
+ import { CrashRecovery } from './crash-recovery.js';
5
+ import { Uploader } from './uploader.js';
6
+ import { FileHandler } from './file-handler.js';
7
+ import { Logger } from './logger.js';
8
+ import type { CollectedTestCase, StreamEvent, FilterDetails } from './types.js';
9
+ /**
10
+ * Manages the streaming protocol: queues events (begin / complete), flushes
11
+ * them in batches, schedules retries on failure, and handles per-test-case
12
+ * live file uploads.
13
+ */
14
+ export declare class StreamManager {
15
+ private readonly httpClient;
16
+ private readonly streamBuffer;
17
+ private readonly recovery;
18
+ private readonly uploader;
19
+ private readonly fileHandler;
20
+ private readonly options;
21
+ private readonly logger;
22
+ private pendingEvents;
23
+ private pendingBeginEvents;
24
+ private flushTimer;
25
+ private flushPromises;
26
+ private liveUploadPromises;
27
+ private readonly limitLiveUpload;
28
+ private retryCount;
29
+ private retryTimer;
30
+ private readonly maxRetryDelay;
31
+ /** Tracks cases whose files have already been uploaded live, so `uploadRemaining` can skip them. */
32
+ private readonly uploadedCaseFiles;
33
+ private _enabled;
34
+ private _runId;
35
+ private _token;
36
+ private _auth;
37
+ private _startPromise;
38
+ /** Whether the streaming session is active */
39
+ get enabled(): boolean;
40
+ /** Server-assigned run ID (`null` until the stream opens) */
41
+ get runId(): number | null;
42
+ /** Stream authentication token (`null` until the stream opens) */
43
+ get token(): string | null;
44
+ /** Resolved auth string (API key or session cookie) used in stream requests */
45
+ get auth(): string | null;
46
+ /** Promise that resolves when the stream has been fully initialised */
47
+ get startPromise(): Promise<void> | null;
48
+ /**
49
+ * @param httpClient HTTP client for server communication.
50
+ * @param streamBuffer On-disk buffer for crash-safe event persistence.
51
+ * @param recovery Crash-recovery handler for uploading stale payloads on startup.
52
+ * @param uploader Uploader for per-test-case file uploads.
53
+ * @param fileHandler File-discovery helper for finding traces and attachments.
54
+ * @param options Piwi Dashboard reporter options.
55
+ * @param logger Prefixed logger.
56
+ */
57
+ constructor(httpClient: HttpClient, streamBuffer: StreamBuffer, recovery: CrashRecovery, uploader: Uploader, fileHandler: FileHandler, options: PiwiDashboardOptions, logger?: Logger);
58
+ /** Begin the streaming session after `onBegin` fires. Non-blocking — the actual handshake runs asynchronously. */
59
+ start(startTime: string, metadata: Record<string, any>, instanceId: string, playwrightVersion?: string | null, shardInfo?: ShardInfo | null, isFullRun?: boolean, filterDetails?: FilterDetails | null): void;
60
+ private _doStart;
61
+ /** Queue a test-case `begin` event. Held in a pre-start buffer if the stream is not yet open, then prepended so it arrives before the matching `complete` event. */
62
+ queueBeginEvent(event: StreamEvent): void;
63
+ /** Queue a test-case event. Triggers an immediate flush when the batch size is reached, otherwise schedules a timer-based flush. */
64
+ queueEvent(event: StreamEvent): void;
65
+ /** Flush all pending events to the server. Returns a promise that resolves to `true` on success or `false` on failure (events are re-queued for retry). */
66
+ flush(): Promise<boolean> | null;
67
+ private scheduleRetry;
68
+ /** Drain all pending and buffered events before the run finishes. Retries up to 10 times with exponential back-off. */
69
+ drain(): Promise<void>;
70
+ /** Schedule a live upload of trace and attachment files for a test case. Skips cases with no files. Concurrency is limited to 2 simultaneous uploads. */
71
+ scheduleLiveUpload(tc: CollectedTestCase): void;
72
+ /** Wait for all live uploads to settle, then upload files for any test cases that weren't uploaded live */
73
+ uploadRemaining(testCases: CollectedTestCase[]): Promise<void>;
74
+ }