@testdino/playwright 1.0.7 → 1.0.9

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