@test-station/adapter-playwright 0.1.7 → 0.2.10

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 (3) hide show
  1. package/README.md +24 -0
  2. package/package.json +4 -1
  3. package/src/index.js +237 -17
package/README.md CHANGED
@@ -15,9 +15,33 @@ npm install --save-dev @test-station/adapter-playwright
15
15
  - runs Playwright with JSON reporter output
16
16
  - normalizes browser suite and test results into the shared report model
17
17
  - writes raw Playwright JSON artifacts under `raw/`
18
+ - optionally collects browser Istanbul coverage when `suite.coverage.strategy` is `browser-istanbul`
19
+ - writes suite-scoped browser coverage artifacts under `raw/`
18
20
 
19
21
  Playwright browsers must already be installed in CI or on the machine executing the suite.
20
22
 
23
+ ## Browser Coverage
24
+
25
+ When coverage is enabled for the run and the suite declares:
26
+
27
+ ```js
28
+ coverage: {
29
+ enabled: true,
30
+ strategy: 'browser-istanbul',
31
+ }
32
+ ```
33
+
34
+ the adapter sets:
35
+
36
+ - `PLAYWRIGHT_BROWSER_COVERAGE=1`
37
+ - `PLAYWRIGHT_BROWSER_COVERAGE_DIR=<temp dir>`
38
+
39
+ The Playwright suite can use those environment variables to persist `window.__coverage__` payloads. Test Station merges those payloads into the normalized suite coverage summary and retains the raw files under a stable suite-scoped artifact directory such as:
40
+
41
+ ```text
42
+ raw/<package>-<suite>-playwright-coverage/
43
+ ```
44
+
21
45
  ## Direct Use
22
46
 
23
47
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@test-station/adapter-playwright",
3
- "version": "0.1.7",
3
+ "version": "0.2.10",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -8,6 +8,9 @@
8
8
  "exports": {
9
9
  ".": "./src/index.js"
10
10
  },
11
+ "dependencies": {
12
+ "istanbul-lib-coverage": "^3.2.2"
13
+ },
11
14
  "scripts": {
12
15
  "build": "node ../../scripts/check-package.mjs ./src/index.js",
13
16
  "lint": "node ../../scripts/lint-syntax.mjs ./src"
package/src/index.js CHANGED
@@ -1,41 +1,102 @@
1
1
  import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
2
4
  import { spawn } from 'node:child_process';
5
+ import istanbulCoverage from 'istanbul-lib-coverage';
3
6
 
4
7
  export const id = 'playwright';
5
8
  export const description = 'Playwright adapter';
6
9
 
10
+ const { createCoverageMap } = istanbulCoverage;
11
+
7
12
  export function createPlaywrightAdapter() {
8
13
  return {
9
14
  id,
10
15
  description,
11
- phase: 3,
12
- async run({ project, suite }) {
16
+ phase: 8,
17
+ async run({ project, suite, execution: executionOptions }) {
13
18
  const commandSpec = parseCommandSpec(suite.command);
14
- const execution = await spawnCommand(commandSpec.command, appendPlaywrightJsonArgs(commandSpec.args), {
19
+ const browserCoverageEnabled = shouldCollectBrowserCoverage({
20
+ execution: executionOptions,
21
+ suite,
22
+ });
23
+ const coverageDir = browserCoverageEnabled
24
+ ? fs.mkdtempSync(
25
+ path.join(
26
+ os.tmpdir(),
27
+ `test-station-playwright-coverage-${slugify(suite.packageName || 'default')}-${slugify(suite.id)}-`
28
+ )
29
+ )
30
+ : null;
31
+ const commandExecution = await spawnCommand(commandSpec.command, appendPlaywrightJsonArgs(commandSpec.args), {
15
32
  cwd: suite.cwd || project.rootDir,
16
- env: resolveSuiteEnv(suite.env),
33
+ env: resolveSuiteEnv({
34
+ ...(suite.env || {}),
35
+ ...(browserCoverageEnabled
36
+ ? {
37
+ PLAYWRIGHT_BROWSER_COVERAGE: '1',
38
+ PLAYWRIGHT_BROWSER_COVERAGE_DIR: coverageDir,
39
+ }
40
+ : {}),
41
+ }),
17
42
  });
18
43
 
19
- const payload = extractJsonPayload(execution.stdout || execution.stderr);
44
+ const payload = extractJsonPayload(commandExecution.stdout || commandExecution.stderr);
20
45
  const parsed = parsePlaywrightReport(payload, suite.cwd || project.rootDir);
46
+ const rawArtifacts = [
47
+ {
48
+ relativePath: `${slugify(suite.packageName || 'default')}-${slugify(suite.id)}-playwright.json`,
49
+ content: JSON.stringify(payload, null, 2),
50
+ },
51
+ ];
52
+ const warnings = [];
53
+ let coverage = null;
54
+
55
+ if (browserCoverageEnabled && coverageDir) {
56
+ const coverageArtifactBase = `${slugify(suite.packageName || 'default')}-${slugify(suite.id)}-playwright-coverage`;
57
+ coverage = mergeBrowserCoverage({
58
+ coverageDir,
59
+ packageName: suite.packageName || null,
60
+ coverageRootDir: suite.cwd || project.rootDir,
61
+ projectRootDir: project.rootDir,
62
+ });
63
+
64
+ rawArtifacts.push({
65
+ relativePath: coverageArtifactBase,
66
+ sourcePath: coverageDir,
67
+ kind: 'directory',
68
+ });
69
+
70
+ if (coverage) {
71
+ const coverageSummaryPath = path.join(coverageDir, 'coverage-summary.json');
72
+ const mergedCoveragePath = path.join(coverageDir, 'merged-coverage.json');
73
+ fs.writeFileSync(coverageSummaryPath, `${JSON.stringify(coverage, null, 2)}\n`);
74
+ fs.writeFileSync(mergedCoveragePath, `${JSON.stringify(buildMergedCoveragePayload(coverageDir), null, 2)}\n`);
75
+ rawArtifacts.push({
76
+ relativePath: `${coverageArtifactBase}/coverage-summary.json`,
77
+ sourcePath: coverageSummaryPath,
78
+ });
79
+ rawArtifacts.push({
80
+ relativePath: `${coverageArtifactBase}/merged-coverage.json`,
81
+ sourcePath: mergedCoveragePath,
82
+ });
83
+ } else {
84
+ warnings.push('Browser coverage was requested, but no window.__coverage__ payloads were collected.');
85
+ }
86
+ }
21
87
 
22
88
  return {
23
- status: deriveSuiteStatus(parsed.summary, execution.exitCode),
24
- durationMs: execution.durationMs,
89
+ status: deriveSuiteStatus(parsed.summary, commandExecution.exitCode),
90
+ durationMs: commandExecution.durationMs,
25
91
  summary: parsed.summary,
26
- coverage: null,
92
+ coverage,
27
93
  tests: parsed.tests,
28
- warnings: [],
94
+ warnings,
29
95
  output: {
30
- stdout: execution.stdout,
31
- stderr: execution.stderr,
96
+ stdout: commandExecution.stdout,
97
+ stderr: commandExecution.stderr,
32
98
  },
33
- rawArtifacts: [
34
- {
35
- relativePath: `${slugify(suite.packageName || 'default')}-${slugify(suite.id)}-playwright.json`,
36
- content: JSON.stringify(payload, null, 2),
37
- },
38
- ],
99
+ rawArtifacts,
39
100
  };
40
101
  },
41
102
  };
@@ -68,6 +129,14 @@ function appendPlaywrightJsonArgs(args) {
68
129
  return [...filtered, '--reporter=json'];
69
130
  }
70
131
 
132
+ function shouldCollectBrowserCoverage(context) {
133
+ return Boolean(
134
+ context?.execution?.coverage
135
+ && context?.suite?.coverage?.enabled !== false
136
+ && context?.suite?.coverage?.strategy === 'browser-istanbul'
137
+ );
138
+ }
139
+
71
140
  function tokenizeCommand(command) {
72
141
  const tokens = [];
73
142
  let current = '';
@@ -170,6 +239,157 @@ function parsePlaywrightReport(report, workspaceDir) {
170
239
  };
171
240
  }
172
241
 
242
+ function mergeBrowserCoverage(options) {
243
+ const payloads = readCoveragePayloads(options.coverageDir);
244
+ if (payloads.length === 0) {
245
+ return null;
246
+ }
247
+
248
+ const coverageMap = createCoverageMap({});
249
+ for (const payload of payloads) {
250
+ const pageEntries = Array.isArray(payload.pages) ? payload.pages : [];
251
+ for (const pageEntry of pageEntries) {
252
+ if (pageEntry?.coverage && typeof pageEntry.coverage === 'object') {
253
+ coverageMap.merge(pageEntry.coverage);
254
+ }
255
+ }
256
+ }
257
+
258
+ const files = coverageMap.files()
259
+ .map((filePath) => normalizeCoverageFileEntry(coverageMap, filePath, options))
260
+ .filter(Boolean)
261
+ .sort((left, right) => left.path.localeCompare(right.path));
262
+
263
+ if (files.length === 0) {
264
+ return null;
265
+ }
266
+
267
+ return normalizeCoverageSummary({ files }, options.packageName || null);
268
+ }
269
+
270
+ function buildMergedCoveragePayload(coverageDir) {
271
+ const payloads = readCoveragePayloads(coverageDir);
272
+ const coverageMap = createCoverageMap({});
273
+ for (const payload of payloads) {
274
+ for (const pageEntry of payload.pages || []) {
275
+ if (pageEntry?.coverage && typeof pageEntry.coverage === 'object') {
276
+ coverageMap.merge(pageEntry.coverage);
277
+ }
278
+ }
279
+ }
280
+ return coverageMap.toJSON();
281
+ }
282
+
283
+ function readCoveragePayloads(coverageDir) {
284
+ if (!coverageDir || !fs.existsSync(coverageDir)) {
285
+ return [];
286
+ }
287
+
288
+ return fs.readdirSync(coverageDir)
289
+ .filter((fileName) => fileName.endsWith('.json'))
290
+ .filter((fileName) => !['coverage-summary.json', 'merged-coverage.json'].includes(fileName))
291
+ .map((fileName) => path.join(coverageDir, fileName))
292
+ .map((filePath) => {
293
+ try {
294
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
295
+ } catch {
296
+ return null;
297
+ }
298
+ })
299
+ .filter(Boolean);
300
+ }
301
+
302
+ function normalizeCoverageFileEntry(coverageMap, filePath, options = {}) {
303
+ const normalizedPath = normalizeSourcePath(filePath, options.projectRootDir);
304
+ if (options.coverageRootDir && !isWithinDirectory(normalizedPath, options.coverageRootDir)) {
305
+ return null;
306
+ }
307
+
308
+ const summary = coverageMap.fileCoverageFor(filePath).toSummary();
309
+ return {
310
+ path: normalizedPath,
311
+ lines: createCoverageMetric(summary.lines?.covered, summary.lines?.total),
312
+ statements: createCoverageMetric(summary.statements?.covered, summary.statements?.total),
313
+ functions: createCoverageMetric(summary.functions?.covered, summary.functions?.total),
314
+ branches: createCoverageMetric(summary.branches?.covered, summary.branches?.total),
315
+ packageName: options.packageName || null,
316
+ };
317
+ }
318
+
319
+ function normalizeCoverageSummary(coverage, packageName = null) {
320
+ if (!coverage) {
321
+ return null;
322
+ }
323
+
324
+ const files = Array.isArray(coverage.files)
325
+ ? coverage.files.map((file) => ({
326
+ path: file.path,
327
+ lines: file.lines || null,
328
+ statements: file.statements || null,
329
+ functions: file.functions || null,
330
+ branches: file.branches || null,
331
+ packageName: file.packageName || packageName || null,
332
+ }))
333
+ : [];
334
+
335
+ return {
336
+ lines: coverage.lines || aggregateCoverageMetric(files, 'lines'),
337
+ statements: coverage.statements || aggregateCoverageMetric(files, 'statements'),
338
+ functions: coverage.functions || aggregateCoverageMetric(files, 'functions'),
339
+ branches: coverage.branches || aggregateCoverageMetric(files, 'branches'),
340
+ files,
341
+ };
342
+ }
343
+
344
+ function aggregateCoverageMetric(files, metricKey) {
345
+ const valid = files
346
+ .map((file) => file?.[metricKey])
347
+ .filter((metric) => metric && Number.isFinite(metric.total));
348
+
349
+ if (valid.length === 0) {
350
+ return null;
351
+ }
352
+
353
+ const total = valid.reduce((sum, metric) => sum + metric.total, 0);
354
+ const covered = valid.reduce((sum, metric) => sum + metric.covered, 0);
355
+ return createCoverageMetric(covered, total);
356
+ }
357
+
358
+ function createCoverageMetric(covered, total) {
359
+ if (!Number.isFinite(total)) {
360
+ return null;
361
+ }
362
+ const safeTotal = Math.max(0, total);
363
+ const safeCovered = Number.isFinite(covered) ? Math.max(0, Math.min(safeTotal, covered)) : 0;
364
+ const pct = safeTotal === 0 ? 100 : Number(((safeCovered / safeTotal) * 100).toFixed(2));
365
+ return {
366
+ covered: safeCovered,
367
+ total: safeTotal,
368
+ pct,
369
+ };
370
+ }
371
+
372
+ function normalizeSourcePath(filePath, projectRootDir) {
373
+ let nextPath = String(filePath || '');
374
+ nextPath = nextPath.replace(/^file:\/\//, '');
375
+ nextPath = nextPath.replace(/^webpack:\/\/_N_E\//, '');
376
+ nextPath = nextPath.replace(/^webpack:\/\//, '');
377
+ nextPath = nextPath.replace(/^\.\//, '');
378
+ nextPath = nextPath.split('?')[0];
379
+ nextPath = nextPath.split('#')[0];
380
+
381
+ if (path.isAbsolute(nextPath)) {
382
+ return path.normalize(nextPath);
383
+ }
384
+
385
+ return path.resolve(projectRootDir || process.cwd(), nextPath);
386
+ }
387
+
388
+ function isWithinDirectory(targetPath, rootDir) {
389
+ const relative = path.relative(rootDir, targetPath);
390
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
391
+ }
392
+
173
393
  function collectPlaywrightTests(suite, titleTrail, bucket, rootDir) {
174
394
  const nextTrail = suite.title && !suite.title.endsWith('.spec.js') ? [...titleTrail, suite.title] : titleTrail;
175
395
  for (const spec of suite.specs || []) {