@testdino/playwright 1.0.7 → 1.0.8

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/dist/index.mjs CHANGED
@@ -1,12 +1,18 @@
1
- import { randomUUID } from 'crypto';
1
+ import { randomUUID, createHash } from 'crypto';
2
2
  import { existsSync, readFileSync, statSync, createReadStream } from 'fs';
3
3
  import WebSocket from 'ws';
4
4
  import axios from 'axios';
5
- import { readFile } from 'fs/promises';
5
+ import { basename, extname, relative } from 'path';
6
+ import { rm, mkdir, readFile } from 'fs/promises';
6
7
  import { execa } from 'execa';
7
8
  import { type, release, platform, cpus, totalmem, hostname } from 'os';
8
9
  import { version } from 'process';
9
- import { basename, extname } from 'path';
10
+ import istanbulCoverage from 'istanbul-lib-coverage';
11
+ import { createContext } from 'istanbul-lib-report';
12
+ import { create } from 'istanbul-reports';
13
+ import { test as test$1 } from '@playwright/test';
14
+ export { expect } from '@playwright/test';
15
+ import chalk from 'chalk';
10
16
 
11
17
  var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
12
18
  get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
@@ -366,8 +372,12 @@ var WebSocketClient = class {
366
372
  }
367
373
  }
368
374
  };
369
-
370
- // src/utils/index.ts
375
+ function normalizePath(filePath, rootDir) {
376
+ if (rootDir && filePath.startsWith(rootDir)) {
377
+ return relative(rootDir, filePath);
378
+ }
379
+ return filePath;
380
+ }
371
381
  function sleep(ms) {
372
382
  return new Promise((resolve) => setTimeout(resolve, ms));
373
383
  }
@@ -1514,7 +1524,7 @@ var PlaywrightMetadataCollector = class extends BaseMetadataCollector {
1514
1524
  const skeletonSuite = {
1515
1525
  title: childSuite.title,
1516
1526
  type: childSuite.type === "file" ? "file" : "describe",
1517
- tests: childSuite.tests.map((test) => this.buildSkeletonTest(test))
1527
+ tests: childSuite.tests.map((test2) => this.buildSkeletonTest(test2))
1518
1528
  };
1519
1529
  if (childSuite.type === "file" && childSuite.location) {
1520
1530
  skeletonSuite.file = childSuite.location.file;
@@ -1536,24 +1546,24 @@ var PlaywrightMetadataCollector = class extends BaseMetadataCollector {
1536
1546
  /**
1537
1547
  * Build skeleton test from TestCase
1538
1548
  */
1539
- buildSkeletonTest(test) {
1549
+ buildSkeletonTest(test2) {
1540
1550
  const skeletonTest = {
1541
- testId: test.id,
1542
- title: test.title,
1551
+ testId: test2.id,
1552
+ title: test2.title,
1543
1553
  location: {
1544
- file: test.location.file,
1545
- line: test.location.line,
1546
- column: test.location.column
1554
+ file: test2.location.file,
1555
+ line: test2.location.line,
1556
+ column: test2.location.column
1547
1557
  }
1548
1558
  };
1549
- if (test.tags && test.tags.length > 0) {
1550
- skeletonTest.tags = test.tags;
1559
+ if (test2.tags && test2.tags.length > 0) {
1560
+ skeletonTest.tags = test2.tags;
1551
1561
  }
1552
- if (test.expectedStatus) {
1553
- skeletonTest.expectedStatus = test.expectedStatus;
1562
+ if (test2.expectedStatus) {
1563
+ skeletonTest.expectedStatus = test2.expectedStatus;
1554
1564
  }
1555
- if (test.annotations && test.annotations.length > 0) {
1556
- skeletonTest.annotations = test.annotations.map((ann) => ({
1565
+ if (test2.annotations && test2.annotations.length > 0) {
1566
+ skeletonTest.annotations = test2.annotations.map((ann) => ({
1557
1567
  type: ann.type,
1558
1568
  description: ann.description
1559
1569
  }));
@@ -1924,8 +1934,324 @@ var ArtifactUploader = class {
1924
1934
  return this.sasToken.uniqueId;
1925
1935
  }
1926
1936
  };
1927
-
1928
- // src/reporter/log.ts
1937
+ function toIstanbulMapData(map) {
1938
+ return map;
1939
+ }
1940
+ function fromIstanbulMapData(data) {
1941
+ return data;
1942
+ }
1943
+ var CoverageMerger = class {
1944
+ coverageMap = istanbulCoverage.createCoverageMap({});
1945
+ hasData = false;
1946
+ includePatterns;
1947
+ excludePatterns;
1948
+ onError;
1949
+ constructor(options) {
1950
+ this.includePatterns = options?.include;
1951
+ this.excludePatterns = options?.exclude;
1952
+ this.onError = options?.onError;
1953
+ }
1954
+ /**
1955
+ * Merge a coverage fragment from a completed test.
1956
+ * The fragment is merged directly and can be GC'd immediately.
1957
+ */
1958
+ addFragment(fragment) {
1959
+ if (!fragment.istanbul) return;
1960
+ try {
1961
+ const filtered = this.filterCoverageMap(fragment.istanbul);
1962
+ this.coverageMap.merge(toIstanbulMapData(filtered));
1963
+ if (this.coverageMap.files().length > 0) {
1964
+ this.hasData = true;
1965
+ }
1966
+ } catch (error) {
1967
+ const msg = `[TestDino] Failed to merge coverage fragment: ${error instanceof Error ? error.message : String(error)}`;
1968
+ if (this.onError) {
1969
+ this.onError(msg);
1970
+ } else {
1971
+ console.warn(msg);
1972
+ }
1973
+ }
1974
+ }
1975
+ /**
1976
+ * Filter files from a coverage map based on include/exclude patterns.
1977
+ */
1978
+ filterCoverageMap(coverageMap) {
1979
+ const hasInclude = this.includePatterns && this.includePatterns.length > 0;
1980
+ const hasExclude = this.excludePatterns && this.excludePatterns.length > 0;
1981
+ if (!hasInclude && !hasExclude) return coverageMap;
1982
+ const filtered = {};
1983
+ for (const [filePath, fileCoverage] of Object.entries(coverageMap)) {
1984
+ if (hasExclude && this.excludePatterns.some((pattern) => filePath.includes(pattern))) {
1985
+ continue;
1986
+ }
1987
+ if (hasInclude && !this.includePatterns.some((pattern) => filePath.includes(pattern))) {
1988
+ continue;
1989
+ }
1990
+ filtered[filePath] = fileCoverage;
1991
+ }
1992
+ return filtered;
1993
+ }
1994
+ /**
1995
+ * Whether any coverage data has been collected.
1996
+ */
1997
+ get hasCoverage() {
1998
+ return this.hasData;
1999
+ }
2000
+ /**
2001
+ * Compute aggregate summary metrics from the merged coverage map.
2002
+ */
2003
+ computeSummary() {
2004
+ const globalSummary = this.coverageMap.getCoverageSummary();
2005
+ return {
2006
+ lines: extractMetric(globalSummary.lines),
2007
+ branches: extractMetric(globalSummary.branches),
2008
+ functions: extractMetric(globalSummary.functions),
2009
+ statements: extractMetric(globalSummary.statements)
2010
+ };
2011
+ }
2012
+ /**
2013
+ * Compute per-file coverage metrics.
2014
+ * Normalizes file paths to be relative to git root.
2015
+ */
2016
+ computeFileCoverage(gitRoot) {
2017
+ const root = gitRoot || process.cwd();
2018
+ return this.coverageMap.files().map((filePath) => {
2019
+ const fileCoverage = this.coverageMap.fileCoverageFor(filePath);
2020
+ const fileSummary = fileCoverage.toSummary();
2021
+ const normalizedPath = normalizePath(filePath, root);
2022
+ return {
2023
+ path: normalizedPath,
2024
+ lines: extractMetric(fileSummary.lines),
2025
+ branches: extractMetric(fileSummary.branches),
2026
+ functions: extractMetric(fileSummary.functions),
2027
+ statements: extractMetric(fileSummary.statements)
2028
+ };
2029
+ });
2030
+ }
2031
+ /**
2032
+ * Get the raw merged coverage map (for local report generation or compact extraction).
2033
+ */
2034
+ getRawCoverageMap() {
2035
+ return this.coverageMap;
2036
+ }
2037
+ /**
2038
+ * Get the merged coverage map as a plain JSON object.
2039
+ */
2040
+ toJSON() {
2041
+ return fromIstanbulMapData(this.coverageMap.toJSON());
2042
+ }
2043
+ };
2044
+ function extractMetric(metric) {
2045
+ return {
2046
+ total: metric.total,
2047
+ covered: metric.covered,
2048
+ pct: metric.pct
2049
+ };
2050
+ }
2051
+ function extractCompactCounts(coverageMapJSON, gitRoot) {
2052
+ const files = {};
2053
+ let fileCount = 0;
2054
+ for (const [filePath, fileCoverage] of Object.entries(coverageMapJSON)) {
2055
+ const normalizedPath = normalizePath(filePath, gitRoot);
2056
+ files[normalizedPath] = {
2057
+ s: fileCoverage.s,
2058
+ f: fileCoverage.f,
2059
+ b: fileCoverage.b,
2060
+ totals: {
2061
+ s: Object.keys(fileCoverage.statementMap || {}).length,
2062
+ f: Object.keys(fileCoverage.fnMap || {}).length,
2063
+ b: countBranchPaths(fileCoverage.branchMap || {})
2064
+ },
2065
+ shapeHash: computeShapeHash(fileCoverage)
2066
+ };
2067
+ fileCount++;
2068
+ }
2069
+ return { files, fileCount };
2070
+ }
2071
+ function countBranchPaths(branchMap) {
2072
+ let total = 0;
2073
+ for (const branch of Object.values(branchMap)) {
2074
+ total += (branch.locations || []).length;
2075
+ }
2076
+ return total;
2077
+ }
2078
+ function computeShapeHash(fileCoverage) {
2079
+ const branchMap = fileCoverage.branchMap || {};
2080
+ const shape = {
2081
+ s: Object.keys(fileCoverage.statementMap || {}).length,
2082
+ f: Object.keys(fileCoverage.fnMap || {}).length,
2083
+ b: countBranchPaths(branchMap),
2084
+ bp: Object.values(branchMap).map((b) => (b.locations || []).length)
2085
+ };
2086
+ return createHash("sha256").update(JSON.stringify(shape)).digest("hex").slice(0, 12);
2087
+ }
2088
+ async function generateIstanbulHtmlReport(coverageMerger, options) {
2089
+ await rm(options.outputDir, { recursive: true, force: true }).catch(() => {
2090
+ });
2091
+ await mkdir(options.outputDir, { recursive: true });
2092
+ const coverageMap = coverageMerger.getRawCoverageMap();
2093
+ const context = createContext({
2094
+ dir: options.outputDir,
2095
+ watermarks: {
2096
+ statements: [50, 80],
2097
+ functions: [50, 80],
2098
+ branches: [50, 80],
2099
+ lines: [50, 80]
2100
+ },
2101
+ coverageMap
2102
+ });
2103
+ const reporter = create("html", {
2104
+ skipEmpty: false,
2105
+ subdir: ""
2106
+ });
2107
+ reporter.execute(context);
2108
+ return `${options.outputDir}/index.html`;
2109
+ }
2110
+ var COVERAGE_EXTRACT_TIMEOUT_MS = 3e4;
2111
+ async function extractCoverageFromPage(page, timeoutMs = COVERAGE_EXTRACT_TIMEOUT_MS) {
2112
+ return Promise.race([
2113
+ page.evaluate(() => globalThis.__coverage__ ?? null),
2114
+ new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs))
2115
+ ]).catch(() => null);
2116
+ }
2117
+ async function attachCoverageToTestInfo(testInfo, coverage) {
2118
+ const fragment = {
2119
+ istanbul: coverage
2120
+ };
2121
+ await testInfo.attach("testdino-coverage", {
2122
+ body: JSON.stringify(fragment),
2123
+ contentType: "application/json"
2124
+ });
2125
+ }
2126
+ var coverageFixtures = {
2127
+ _testdinoCoverage: [
2128
+ async ({ page }, use, testInfo) => {
2129
+ await use();
2130
+ const istanbulCoverage2 = await extractCoverageFromPage(page);
2131
+ if (istanbulCoverage2) {
2132
+ await attachCoverageToTestInfo(testInfo, istanbulCoverage2);
2133
+ }
2134
+ },
2135
+ { auto: true }
2136
+ ]
2137
+ };
2138
+ var test = test$1.extend(
2139
+ coverageFixtures
2140
+ );
2141
+ function stripAnsi(str) {
2142
+ return str.replace(/\u001b\[[0-9;]*m/g, "");
2143
+ }
2144
+ function pad(str, len) {
2145
+ const vLen = stripAnsi(str).length;
2146
+ return vLen >= len ? str : str + " ".repeat(len - vLen);
2147
+ }
2148
+ function padStart(str, len) {
2149
+ const vLen = stripAnsi(str).length;
2150
+ return vLen >= len ? str : " ".repeat(len - vLen) + str;
2151
+ }
2152
+ function formatDuration(ms) {
2153
+ if (ms < 1e3) return `${ms}ms`;
2154
+ const seconds = ms / 1e3;
2155
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
2156
+ const minutes = Math.floor(seconds / 60);
2157
+ const remainingSeconds = (seconds % 60).toFixed(0);
2158
+ return `${minutes}m ${remainingSeconds}s`;
2159
+ }
2160
+ function shortenPath(filePath) {
2161
+ const markers = ["/src/", "/lib/", "/app/"];
2162
+ for (const marker of markers) {
2163
+ const idx = filePath.indexOf(marker);
2164
+ if (idx !== -1) return filePath.slice(idx + 1);
2165
+ }
2166
+ const parts = filePath.split("/");
2167
+ return parts.length > 2 ? parts.slice(-2).join("/") : filePath;
2168
+ }
2169
+ function colorPct(pct) {
2170
+ const color = pct >= 80 ? chalk.green : pct >= 50 ? chalk.yellow : chalk.red;
2171
+ return color(pct === 100 ? "100%" : `${pct.toFixed(1)}%`);
2172
+ }
2173
+ function printCoverageTable(event) {
2174
+ const { summary, files } = event;
2175
+ const row = (content) => ` ${chalk.dim("\u2502")} ${content}`;
2176
+ const nameW = 30;
2177
+ const colW = 10;
2178
+ console.log(row(`${chalk.bold("Coverage")} ${chalk.dim(`${files.length} files`)}`));
2179
+ console.log(row(""));
2180
+ console.log(
2181
+ row(
2182
+ ` ${chalk.dim(`${pad("File", nameW)}${padStart("Stmts", colW)}${padStart("Branch", colW)}${padStart("Funcs", colW)}${padStart("Lines", colW)}`)}`
2183
+ )
2184
+ );
2185
+ for (const file of files) {
2186
+ const name = shortenPath(file.path);
2187
+ const short = name.length > nameW ? name.slice(0, nameW - 1) + "~" : name;
2188
+ console.log(
2189
+ row(
2190
+ ` ${pad(short, nameW)}` + padStart(colorPct(file.statements.pct), colW) + padStart(colorPct(file.branches.pct), colW) + padStart(colorPct(file.functions.pct), colW) + padStart(colorPct(file.lines.pct), colW)
2191
+ )
2192
+ );
2193
+ }
2194
+ console.log(row(` ${chalk.dim("\u2500".repeat(nameW + colW * 4))}`));
2195
+ console.log(
2196
+ row(
2197
+ ` ${chalk.bold(pad("All files", nameW))}` + padStart(colorPct(summary.statements.pct), colW) + padStart(colorPct(summary.branches.pct), colW) + padStart(colorPct(summary.functions.pct), colW) + padStart(colorPct(summary.lines.pct), colW)
2198
+ )
2199
+ );
2200
+ }
2201
+ function printRunSummary(result, streamingSuccess, data) {
2202
+ const W = 72;
2203
+ const topBorder = ` ${chalk.dim(`\u250C${"\u2500".repeat(W)}\u2510`)}`;
2204
+ const bottomBorder = ` ${chalk.dim(`\u2514${"\u2500".repeat(W)}\u2518`)}`;
2205
+ const divider = ` ${chalk.dim(`\u251C${"\u2500".repeat(W)}\u2524`)}`;
2206
+ const row = (content) => ` ${chalk.dim("\u2502")} ${content}`;
2207
+ console.log("");
2208
+ console.log(topBorder);
2209
+ console.log(row(chalk.bold("TestDino Run Summary")));
2210
+ console.log(divider);
2211
+ console.log(row(`${chalk.dim("Run")} ${data.runId}`));
2212
+ const git = data.runMetadata?.git;
2213
+ if (git?.branch || git?.commit?.hash) {
2214
+ const branch = git.branch ? chalk.cyan(git.branch) : "";
2215
+ const sha = git.commit?.hash ? chalk.dim(git.commit.hash.slice(0, 7)) : "";
2216
+ const sep = branch && sha ? ` ${chalk.dim("@")} ` : "";
2217
+ const msg = git.commit?.message ? ` ${chalk.dim(git.commit.message.split("\n")[0].slice(0, 50))}` : "";
2218
+ console.log(row(`${chalk.dim("Git")} ${branch}${sep}${sha}${msg}`));
2219
+ }
2220
+ console.log(divider);
2221
+ const statusColor = result.status === "passed" ? chalk.green : result.status === "failed" ? chalk.red : chalk.yellow;
2222
+ const statusLabel = result.status === "timedout" ? "timed out" : result.status;
2223
+ console.log(
2224
+ row(
2225
+ `${chalk.bold("Tests")} ${statusColor(statusLabel.toUpperCase())} ${chalk.dim(formatDuration(result.duration))}`
2226
+ )
2227
+ );
2228
+ const counts = [];
2229
+ if (data.testCounts.passed > 0) counts.push(chalk.green(`${data.testCounts.passed} passed`));
2230
+ if (data.testCounts.failed > 0) counts.push(chalk.red(`${data.testCounts.failed} failed`));
2231
+ if (data.testCounts.flaky > 0) counts.push(chalk.yellow(`${data.testCounts.flaky} flaky`));
2232
+ if (data.testCounts.skipped > 0) counts.push(chalk.dim(`${data.testCounts.skipped} skipped`));
2233
+ if (data.testCounts.timedOut > 0) counts.push(chalk.red(`${data.testCounts.timedOut} timed out`));
2234
+ if (data.testCounts.interrupted > 0) counts.push(chalk.yellow(`${data.testCounts.interrupted} interrupted`));
2235
+ const retriedStr = data.testCounts.retried > 0 ? ` ${chalk.dim(`(${data.testCounts.retried} retries)`)}` : "";
2236
+ console.log(row(` ${counts.join(chalk.dim(" \xB7 "))} ${chalk.dim(`of ${data.totalTests}`)}${retriedStr}`));
2237
+ console.log(divider);
2238
+ const shardStr = data.shardInfo ? `${data.shardInfo.current}/${data.shardInfo.total}` : "\u2014";
2239
+ console.log(
2240
+ row(
2241
+ `${pad(`${chalk.dim("Workers")} ${data.workerCount > 0 ? data.workerCount : "\u2014"}`, 28)}${pad(`${chalk.dim("Shard")} ${shardStr}`, 28)}${chalk.dim("Projects")} ${data.projectNames.size > 0 ? Array.from(data.projectNames).join(", ") : "\u2014"}`
2242
+ )
2243
+ );
2244
+ console.log(divider);
2245
+ const transport = data.useHttpFallback ? "HTTP" : "WebSocket";
2246
+ const streamIcon = streamingSuccess ? chalk.green("sent") : chalk.red("failed");
2247
+ console.log(row(`${chalk.bold("Stream")} ${streamIcon} ${chalk.dim(`via ${transport}`)}`));
2248
+ if (data.lastCoverageEvent) {
2249
+ console.log(divider);
2250
+ printCoverageTable(data.lastCoverageEvent);
2251
+ }
2252
+ console.log(bottomBorder);
2253
+ console.log("");
2254
+ }
1929
2255
  var createReporterLog = (options) => ({
1930
2256
  success: (msg) => console.log(`\u2705 TestDino: ${msg}`),
1931
2257
  warn: (msg) => console.warn(`\u26A0\uFE0F TestDino: ${msg}`),
@@ -1935,12 +2261,15 @@ var createReporterLog = (options) => ({
1935
2261
  if (options.debug) {
1936
2262
  console.log(`\u{1F50D} TestDino: ${msg}`);
1937
2263
  }
1938
- }
2264
+ },
2265
+ printRunSummary,
2266
+ printCoverageTable
1939
2267
  });
1940
2268
 
1941
2269
  // src/reporter/index.ts
1942
2270
  var MAX_CONSOLE_CHUNK_SIZE = 1e4;
1943
2271
  var MAX_BUFFER_SIZE = 10;
2272
+ var COVERAGE_FILE_COUNT_WARNING = 500;
1944
2273
  var TestdinoReporter = class {
1945
2274
  config;
1946
2275
  wsClient = null;
@@ -1971,11 +2300,32 @@ var TestdinoReporter = class {
1971
2300
  pendingTestEndPromises = /* @__PURE__ */ new Set();
1972
2301
  // Logger for consistent output
1973
2302
  log;
2303
+ // Coverage collection
2304
+ coverageEnabled = false;
2305
+ coverageMerger = null;
2306
+ warnedCoverageDisconnect = false;
2307
+ coverageThresholdFailed = false;
2308
+ // Test result tracking for summary
2309
+ testCounts = { passed: 0, failed: 0, skipped: 0, timedOut: 0, interrupted: 0, flaky: 0, retried: 0 };
2310
+ totalTests = 0;
2311
+ lastCoverageEvent = null;
2312
+ // Detailed tracking for summary output
2313
+ projectNames = /* @__PURE__ */ new Set();
2314
+ runMetadata = null;
2315
+ workerCount = 0;
1974
2316
  constructor(config = {}) {
1975
2317
  const cliConfig = this.loadCliConfig();
1976
2318
  this.config = { ...config, ...cliConfig };
1977
2319
  this.runId = randomUUID();
1978
2320
  this.log = createReporterLog({ debug: this.config.debug ?? false });
2321
+ this.coverageEnabled = this.config.coverage?.enabled ?? false;
2322
+ if (this.coverageEnabled) {
2323
+ this.coverageMerger = new CoverageMerger({
2324
+ include: this.config.coverage?.include,
2325
+ exclude: this.config.coverage?.exclude,
2326
+ onError: (msg) => this.log.warn(msg)
2327
+ });
2328
+ }
1979
2329
  this.buffer = new EventBuffer({
1980
2330
  maxSize: MAX_BUFFER_SIZE,
1981
2331
  onFlush: async (events) => {
@@ -2017,6 +2367,9 @@ var TestdinoReporter = class {
2017
2367
  if (cliConfig.artifacts !== void 0 && typeof cliConfig.artifacts === "boolean") {
2018
2368
  mappedConfig.artifacts = cliConfig.artifacts;
2019
2369
  }
2370
+ if (typeof cliConfig.coverage === "object" && cliConfig.coverage !== null) {
2371
+ mappedConfig.coverage = cliConfig.coverage;
2372
+ }
2020
2373
  return mappedConfig;
2021
2374
  } catch (error) {
2022
2375
  if (isDebugEnabled()) {
@@ -2071,6 +2424,8 @@ var TestdinoReporter = class {
2071
2424
  const serverUrl = this.getServerUrl();
2072
2425
  try {
2073
2426
  const metadata = await this.collectMetadata(config, suite);
2427
+ this.runMetadata = metadata;
2428
+ this.workerCount = config.workers;
2074
2429
  this.httpClient = new HttpClient({ token, serverUrl });
2075
2430
  const auth = await this.httpClient.authenticate();
2076
2431
  this.sessionId = auth.sessionId;
@@ -2156,35 +2511,35 @@ var TestdinoReporter = class {
2156
2511
  /**
2157
2512
  * Called for each test before it starts
2158
2513
  */
2159
- async onTestBegin(test, result) {
2514
+ async onTestBegin(test2, result) {
2160
2515
  if (!this.initPromise || this.initFailed) return;
2161
2516
  const event = {
2162
2517
  type: "test:begin",
2163
2518
  runId: this.runId,
2164
2519
  ...this.getEventMetadata(),
2165
2520
  // Test identification
2166
- testId: test.id,
2167
- title: test.title,
2168
- titlePath: test.titlePath(),
2521
+ testId: test2.id,
2522
+ title: test2.title,
2523
+ titlePath: test2.titlePath(),
2169
2524
  // Location information
2170
2525
  location: {
2171
- file: test.location.file,
2172
- line: test.location.line,
2173
- column: test.location.column
2526
+ file: test2.location.file,
2527
+ line: test2.location.line,
2528
+ column: test2.location.column
2174
2529
  },
2175
2530
  // Test configuration
2176
- tags: test.tags,
2177
- expectedStatus: test.expectedStatus,
2178
- timeout: test.timeout,
2179
- retries: test.retries,
2180
- annotations: this.extractAnnotations(test.annotations),
2531
+ tags: test2.tags,
2532
+ expectedStatus: test2.expectedStatus,
2533
+ timeout: test2.timeout,
2534
+ retries: test2.retries,
2535
+ annotations: this.extractAnnotations(test2.annotations),
2181
2536
  // Execution context
2182
2537
  retry: result.retry,
2183
2538
  workerIndex: result.workerIndex,
2184
2539
  parallelIndex: result.parallelIndex,
2185
- repeatEachIndex: test.repeatEachIndex,
2540
+ repeatEachIndex: test2.repeatEachIndex,
2186
2541
  // Hierarchy information
2187
- parentSuite: this.extractParentSuite(test.parent),
2542
+ parentSuite: this.extractParentSuite(test2.parent),
2188
2543
  // Timing
2189
2544
  startTime: result.startTime.getTime()
2190
2545
  };
@@ -2193,15 +2548,15 @@ var TestdinoReporter = class {
2193
2548
  /**
2194
2549
  * Called when a test step begins
2195
2550
  */
2196
- async onStepBegin(test, result, step) {
2551
+ async onStepBegin(test2, result, step) {
2197
2552
  if (!this.initPromise || this.initFailed) return;
2198
2553
  const event = {
2199
2554
  type: "step:begin",
2200
2555
  runId: this.runId,
2201
2556
  ...this.getEventMetadata(),
2202
2557
  // Step Identification
2203
- testId: test.id,
2204
- stepId: `${test.id}-${step.titlePath().join("-")}`,
2558
+ testId: test2.id,
2559
+ stepId: `${test2.id}-${step.titlePath().join("-")}`,
2205
2560
  title: step.title,
2206
2561
  titlePath: step.titlePath(),
2207
2562
  // Step Classification
@@ -2229,7 +2584,7 @@ var TestdinoReporter = class {
2229
2584
  /**
2230
2585
  * Called when a test step ends
2231
2586
  */
2232
- async onStepEnd(test, result, step) {
2587
+ async onStepEnd(test2, result, step) {
2233
2588
  if (!this.initPromise || this.initFailed) return;
2234
2589
  const status = step.error ? "failed" : "passed";
2235
2590
  const event = {
@@ -2237,8 +2592,8 @@ var TestdinoReporter = class {
2237
2592
  runId: this.runId,
2238
2593
  ...this.getEventMetadata(),
2239
2594
  // Step Identification
2240
- testId: test.id,
2241
- stepId: `${test.id}-${step.titlePath().join("-")}`,
2595
+ testId: test2.id,
2596
+ stepId: `${test2.id}-${step.titlePath().join("-")}`,
2242
2597
  title: step.title,
2243
2598
  titlePath: step.titlePath(),
2244
2599
  // Timing
@@ -2265,9 +2620,46 @@ var TestdinoReporter = class {
2265
2620
  * Called after each test.
2266
2621
  * Playwright does not await onTestEnd promises—pending work is awaited in onEnd.
2267
2622
  */
2268
- onTestEnd(test, result) {
2623
+ onTestEnd(test2, result) {
2269
2624
  if (!this.initPromise || this.initFailed) return;
2270
- const workPromise = this.processTestEnd(test, result);
2625
+ if (!this.coverageEnabled && !this.warnedCoverageDisconnect) {
2626
+ const hasCoverageAttachment = result.attachments.some((a) => a.name === "testdino-coverage");
2627
+ if (hasCoverageAttachment) {
2628
+ this.log.warn(
2629
+ "Coverage data detected but coverage.enabled is false \u2014 set coverage: { enabled: true } to collect coverage"
2630
+ );
2631
+ this.warnedCoverageDisconnect = true;
2632
+ }
2633
+ }
2634
+ if (this.coverageEnabled && this.coverageMerger) {
2635
+ this.extractCoverageFromResult(test2, result, this.coverageMerger);
2636
+ }
2637
+ const projectName = this.getProjectName(test2);
2638
+ if (projectName) {
2639
+ this.projectNames.add(projectName);
2640
+ }
2641
+ if (result.retry > 0) {
2642
+ this.testCounts.retried++;
2643
+ }
2644
+ const isFinalAttempt = result.status === "passed" || result.retry === test2.retries;
2645
+ if (isFinalAttempt) {
2646
+ this.totalTests++;
2647
+ const outcome = test2.outcome();
2648
+ if (outcome === "flaky") {
2649
+ this.testCounts.flaky++;
2650
+ } else if (result.status === "passed") {
2651
+ this.testCounts.passed++;
2652
+ } else if (result.status === "failed") {
2653
+ this.testCounts.failed++;
2654
+ } else if (result.status === "skipped") {
2655
+ this.testCounts.skipped++;
2656
+ } else if (result.status === "timedOut") {
2657
+ this.testCounts.timedOut++;
2658
+ } else if (result.status === "interrupted") {
2659
+ this.testCounts.interrupted++;
2660
+ }
2661
+ }
2662
+ const workPromise = this.processTestEnd(test2, result);
2271
2663
  this.pendingTestEndPromises.add(workPromise);
2272
2664
  workPromise.finally(() => {
2273
2665
  this.pendingTestEndPromises.delete(workPromise);
@@ -2277,18 +2669,18 @@ var TestdinoReporter = class {
2277
2669
  * Process test end event asynchronously
2278
2670
  * Uploads attachments and adds test:end event to buffer
2279
2671
  */
2280
- async processTestEnd(test, result) {
2672
+ async processTestEnd(test2, result) {
2281
2673
  try {
2282
- const attachmentsWithUrls = await this.uploadAttachments(result.attachments, test.id);
2674
+ const attachmentsWithUrls = await this.uploadAttachments(result.attachments, test2.id);
2283
2675
  const event = {
2284
2676
  type: "test:end",
2285
2677
  runId: this.runId,
2286
2678
  ...this.getEventMetadata(),
2287
2679
  // Test Identification
2288
- testId: test.id,
2680
+ testId: test2.id,
2289
2681
  // Status Information
2290
2682
  status: result.status,
2291
- outcome: test.outcome(),
2683
+ outcome: test2.outcome(),
2292
2684
  // Timing
2293
2685
  duration: result.duration,
2294
2686
  // Execution Context
@@ -2339,6 +2731,43 @@ var TestdinoReporter = class {
2339
2731
  this.log.debug(`Waiting for ${this.pendingTestEndPromises.size} pending test:end events...`);
2340
2732
  await Promise.allSettled(Array.from(this.pendingTestEndPromises));
2341
2733
  }
2734
+ if (this.coverageEnabled && this.coverageMerger?.hasCoverage) {
2735
+ try {
2736
+ if (this.projectNames.size > 1 && !this.config.coverage?.projects?.length) {
2737
+ this.log.warn(
2738
+ `Coverage collected from ${this.projectNames.size} projects (${Array.from(this.projectNames).join(", ")}). Set coverage.projects to a single project to avoid duplicate counts`
2739
+ );
2740
+ }
2741
+ if (this.config.coverage?.projects?.length) {
2742
+ const unmatched = this.config.coverage.projects.filter((p) => !this.projectNames.has(p));
2743
+ if (unmatched.length > 0) {
2744
+ this.log.warn(
2745
+ `coverage.projects contains unmatched values: ${unmatched.join(", ")}. Available projects: ${Array.from(this.projectNames).join(", ")}`
2746
+ );
2747
+ }
2748
+ }
2749
+ const coverageEvent = this.buildCoverageEvent(this.coverageMerger);
2750
+ await this.buffer.add(coverageEvent);
2751
+ this.lastCoverageEvent = coverageEvent;
2752
+ const thresholdFailures = this.checkCoverageThresholds(coverageEvent.summary);
2753
+ if (thresholdFailures.length > 0) {
2754
+ this.coverageThresholdFailed = true;
2755
+ }
2756
+ if (this.config.coverage?.localReport) {
2757
+ const outputDir = this.config.coverage.localReportDir || "./testdino-coverage";
2758
+ const reportPath = await generateIstanbulHtmlReport(this.coverageMerger, {
2759
+ outputDir
2760
+ });
2761
+ this.log.info(`Coverage Report: ${reportPath}`);
2762
+ }
2763
+ } catch (error) {
2764
+ this.log.warn(`Failed to build coverage event: ${error instanceof Error ? error.message : String(error)}`);
2765
+ }
2766
+ } else if (this.coverageEnabled && !this.coverageMerger?.hasCoverage) {
2767
+ this.log.warn(
2768
+ "Coverage enabled but no data was collected. Ensure your app is instrumented with Istanbul (babel-plugin-istanbul) and tests use { test } from @testdino/playwright"
2769
+ );
2770
+ }
2342
2771
  const event = {
2343
2772
  type: "run:end",
2344
2773
  runId: this.runId,
@@ -2389,6 +2818,28 @@ var TestdinoReporter = class {
2389
2818
  this.log.error(`Failed to send run:end event: ${errorMessage}`);
2390
2819
  }
2391
2820
  }
2821
+ const summaryData = {
2822
+ runId: this.runId,
2823
+ runMetadata: this.runMetadata,
2824
+ testCounts: this.testCounts,
2825
+ totalTests: this.totalTests,
2826
+ workerCount: this.workerCount,
2827
+ projectNames: this.projectNames,
2828
+ shardInfo: this.shardInfo,
2829
+ useHttpFallback: this.useHttpFallback,
2830
+ lastCoverageEvent: this.lastCoverageEvent
2831
+ };
2832
+ this.log.printRunSummary(result, delivered, summaryData);
2833
+ if (this.coverageThresholdFailed && this.lastCoverageEvent) {
2834
+ const failures = this.checkCoverageThresholds(this.lastCoverageEvent.summary);
2835
+ this.log.error("Coverage thresholds not met:");
2836
+ for (const msg of failures) {
2837
+ this.log.error(` ${msg}`);
2838
+ }
2839
+ this.wsClient?.close();
2840
+ this.removeSignalHandlers();
2841
+ return { status: "failed" };
2842
+ }
2392
2843
  this.wsClient?.close();
2393
2844
  this.removeSignalHandlers();
2394
2845
  }
@@ -2409,7 +2860,7 @@ var TestdinoReporter = class {
2409
2860
  /**
2410
2861
  * Called when standard output is produced in worker process
2411
2862
  */
2412
- async onStdOut(chunk, test, result) {
2863
+ async onStdOut(chunk, test2, result) {
2413
2864
  if (!this.initPromise || this.initFailed) return;
2414
2865
  const { text, truncated } = this.truncateChunk(chunk);
2415
2866
  const event = {
@@ -2419,7 +2870,7 @@ var TestdinoReporter = class {
2419
2870
  // Console Output
2420
2871
  text,
2421
2872
  // Test Association (optional)
2422
- testId: test?.id,
2873
+ testId: test2?.id,
2423
2874
  retry: result?.retry,
2424
2875
  // Truncation Indicator
2425
2876
  truncated
@@ -2429,7 +2880,7 @@ var TestdinoReporter = class {
2429
2880
  /**
2430
2881
  * Called when standard error is produced in worker process
2431
2882
  */
2432
- async onStdErr(chunk, test, result) {
2883
+ async onStdErr(chunk, test2, result) {
2433
2884
  if (!this.initPromise || this.initFailed) return;
2434
2885
  const { text, truncated } = this.truncateChunk(chunk);
2435
2886
  const event = {
@@ -2439,7 +2890,7 @@ var TestdinoReporter = class {
2439
2890
  // Console Error Output
2440
2891
  text,
2441
2892
  // Test Association (optional)
2442
- testId: test?.id,
2893
+ testId: test2?.id,
2443
2894
  retry: result?.retry,
2444
2895
  // Truncation Indicator
2445
2896
  truncated
@@ -2998,8 +3449,98 @@ var TestdinoReporter = class {
2998
3449
  getBaseServerUrl(serverUrl) {
2999
3450
  return serverUrl.replace(/\/api\/reporter$/, "");
3000
3451
  }
3452
+ /**
3453
+ * Walk up the suite hierarchy to find the project name for a test.
3454
+ */
3455
+ getProjectName(test2) {
3456
+ let suite = test2.parent;
3457
+ while (suite) {
3458
+ const project = suite.project();
3459
+ if (project) {
3460
+ return project.name || project.use?.defaultBrowserType || "default";
3461
+ }
3462
+ suite = suite.parent;
3463
+ }
3464
+ return void 0;
3465
+ }
3466
+ /**
3467
+ * Extract coverage fragment from test result attachments and merge incrementally.
3468
+ * The fixture attaches coverage as an in-memory JSON attachment named 'testdino-coverage'.
3469
+ *
3470
+ * Respects coverage.projects filter to avoid duplicate coverage from multiple browser projects.
3471
+ */
3472
+ extractCoverageFromResult(test2, result, merger) {
3473
+ if (this.config.coverage?.projects) {
3474
+ const projectName = this.getProjectName(test2);
3475
+ if (projectName && !this.config.coverage.projects.includes(projectName)) {
3476
+ return;
3477
+ }
3478
+ }
3479
+ const coverageAttachment = result.attachments.find((a) => a.name === "testdino-coverage");
3480
+ if (!coverageAttachment?.body) return;
3481
+ try {
3482
+ const fragment = JSON.parse(coverageAttachment.body.toString());
3483
+ merger.addFragment(fragment);
3484
+ } catch (error) {
3485
+ this.log.debug(
3486
+ `Malformed coverage attachment, skipping: ${error instanceof Error ? error.message : String(error)}`
3487
+ );
3488
+ }
3489
+ }
3490
+ /**
3491
+ * Build a coverage:data event from the merged coverage data.
3492
+ *
3493
+ * Non-sharded runs: summary + per-file metrics only (small payload).
3494
+ * Sharded runs: also includes compact hit counts for server-side cross-shard merging.
3495
+ */
3496
+ buildCoverageEvent(merger) {
3497
+ const rootDir = this.runMetadata?.playwright?.rootDir || process.cwd();
3498
+ const summary = merger.computeSummary();
3499
+ const files = merger.computeFileCoverage(rootDir);
3500
+ const isSharded = !!this.shardInfo;
3501
+ if (files.length > COVERAGE_FILE_COUNT_WARNING) {
3502
+ this.log.warn(`Coverage includes ${files.length} files \u2014 consider using coverage.projects to reduce scope`);
3503
+ }
3504
+ const event = {
3505
+ type: "coverage:data",
3506
+ runId: this.runId,
3507
+ ...this.getEventMetadata(),
3508
+ summary,
3509
+ files,
3510
+ metadata: {
3511
+ instrumentationType: "istanbul",
3512
+ fileCount: files.length,
3513
+ sharded: isSharded
3514
+ }
3515
+ };
3516
+ if (isSharded) {
3517
+ const coverageMapJSON = merger.toJSON();
3518
+ event.compactCounts = extractCompactCounts(coverageMapJSON, rootDir);
3519
+ event.shard = this.shardInfo;
3520
+ }
3521
+ return event;
3522
+ }
3523
+ /**
3524
+ * Check coverage against configured thresholds.
3525
+ * Returns an array of failure messages (empty if all thresholds pass).
3526
+ */
3527
+ checkCoverageThresholds(summary) {
3528
+ const thresholds = this.config.coverage?.thresholds;
3529
+ if (!thresholds) return [];
3530
+ const failures = [];
3531
+ const check = (name, actual, threshold) => {
3532
+ if (threshold !== void 0 && actual < threshold) {
3533
+ failures.push(`${name}: ${actual.toFixed(2)}% < ${threshold}%`);
3534
+ }
3535
+ };
3536
+ check("Statements", summary.statements.pct, thresholds.statements);
3537
+ check("Branches", summary.branches.pct, thresholds.branches);
3538
+ check("Functions", summary.functions.pct, thresholds.functions);
3539
+ check("Lines", summary.lines.pct, thresholds.lines);
3540
+ return failures;
3541
+ }
3001
3542
  };
3002
3543
 
3003
- export { TestdinoReporter as default };
3544
+ export { coverageFixtures, TestdinoReporter as default, test };
3004
3545
  //# sourceMappingURL=index.mjs.map
3005
3546
  //# sourceMappingURL=index.mjs.map