From 7be6a83151897b2f187f109658400e6f02b4f397 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 13:49:47 +0000 Subject: [PATCH 01/11] Add Playwright fixture for automatic CSS coverage collection Exposes a `test` fixture via `@projectwallace/css-code-coverage/playwright` that starts/stops CSS coverage around each test and writes JSON files to disk automatically. The output directory defaults to `css-coverage` and is configurable via the `cssCoverageDir` fixture option in playwright.config.ts. Closes #134 --- README.md | 39 +++++++++++++++++++++++++++++++++ package.json | 10 +++++++-- src/playwright/index.ts | 48 +++++++++++++++++++++++++++++++++++++++++ tsdown.config.ts | 18 ++++++++++++++++ 4 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 src/playwright/index.ts diff --git a/README.md b/README.md index afec454..7c5a8e8 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,45 @@ import { calculate_coverage } from '@projectwallace/css-code-coverage' let report = calculcate_coverage(coverage) ``` +### Playwright fixture (automatic) + +Use the built-in Playwright fixture to automatically collect CSS coverage for every test and write results to disk. + +**1. Create a fixtures file** (e.g. `tests/fixtures.ts`): + +```ts +export { test, expect } from '@projectwallace/css-code-coverage/playwright' +``` + +**2. Import it in your tests** instead of `@playwright/test`: + +```ts +import { test, expect } from './fixtures.js' + +test('my test', async ({ page }) => { + await page.goto('https://example.com') + // CSS coverage is collected automatically — no extra code needed +}) +``` + +**3. Configure the output directory** (optional) in `playwright.config.ts`: + +```ts +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + use: { + cssCoverageDir: 'css-coverage', // default value + }, +}) +``` + +Coverage JSON files are written to the configured directory and attached to the Playwright HTML report. Pass the directory to the `css-coverage` CLI to analyze results: + +```sh +css-coverage --coverage-dir=./css-coverage --min-coverage=0.8 +``` + ### Browser devtools In Edge, Chrome or chromium you can manually collect coverage in the browser's DevTools. In all cases you'll generate coverage data manually and the browser will let you export the data to a JSON file. Note that this JSON contains both JS coverage as well as the CSS coverage. Learn how it works: diff --git a/package.json b/package.json index b6ed1d2..b37a109 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,14 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./playwright": { + "types": "./dist/playwright/index.d.mts", + "default": "./dist/playwright/index.mjs" + } }, "publishConfig": { "access": "public", diff --git a/src/playwright/index.ts b/src/playwright/index.ts new file mode 100644 index 0000000..74550ac --- /dev/null +++ b/src/playwright/index.ts @@ -0,0 +1,48 @@ +import { test as base_test } from '@playwright/test' +import * as fs from 'node:fs/promises' +import * as path from 'node:path' + +type CssCoverageFixtures = { + cssCoverage: void +} + +type CssCoverageOptions = { + cssCoverageDir: string +} + +export const test = base_test.extend({ + cssCoverageDir: [ + 'css-coverage', + { option: true, scope: 'worker' }, + ], + + cssCoverage: [ + async ({ page, cssCoverageDir }, use, testInfo) => { + await page.coverage.startCSSCoverage() + await use() + let coverage = await page.coverage.stopCSSCoverage() + + let parts = testInfo.titlePath.map((s) => + s + .replaceAll(/\s+|\/|\./g, '-') + .replaceAll(/[^a-zA-Z0-9-_]/g, '') + .toLowerCase(), + ) + let file_name = parts.join('-') + '.json' + + let dir = path.resolve(process.cwd(), cssCoverageDir) + await fs.mkdir(dir, { recursive: true }) + let file_path = path.join(dir, file_name) + + await fs.writeFile(file_path, JSON.stringify(coverage)) + + await testInfo.attach('css-coverage', { + path: file_path, + contentType: 'application/json', + }) + }, + { auto: true }, + ], +}) + +export { expect } from '@playwright/test' diff --git a/tsdown.config.ts b/tsdown.config.ts index 38a1692..e6d26f4 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -16,6 +16,24 @@ export default defineConfig([ }), ], }, + { + entry: 'src/playwright/index.ts', + platform: 'node', + format: 'esm', + outDir: 'dist/playwright', + publint: true, + deps: { + neverBundle: ['@playwright/test'], + }, + plugins: [ + codecovVitePlugin({ + enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined, + bundleName: 'playwright.js', + uploadToken: process.env.CODECOV_TOKEN, + telemetry: false, + }), + ], + }, { entry: 'src/cli/cli.ts', platform: 'node', From 120b3eb6084e2160035a47d84e6a9a343ce3f845 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 13:52:09 +0000 Subject: [PATCH 02/11] Remove expect re-export and auto fixture from playwright fixture Users should explicitly request cssCoverage in each test rather than having it run automatically for all tests. The expect re-export was unnecessary since users import it directly from @playwright/test. --- README.md | 24 +++++++++++++++--------- src/playwright/index.ts | 4 +--- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7c5a8e8..3dd8cc4 100644 --- a/README.md +++ b/README.md @@ -56,27 +56,33 @@ import { calculate_coverage } from '@projectwallace/css-code-coverage' let report = calculcate_coverage(coverage) ``` -### Playwright fixture (automatic) +### Playwright fixture -Use the built-in Playwright fixture to automatically collect CSS coverage for every test and write results to disk. +Use the built-in Playwright fixture to collect CSS coverage per test and write results to disk. -**1. Create a fixtures file** (e.g. `tests/fixtures.ts`): +**1. Extend your fixtures file** (e.g. `tests/fixtures.ts`): ```ts -export { test, expect } from '@projectwallace/css-code-coverage/playwright' +import { test as base, expect } from '@playwright/test' +import { test as withCssCoverage } from '@projectwallace/css-code-coverage/playwright' + +export const test = base.extend(withCssCoverage) +export { expect } ``` -**2. Import it in your tests** instead of `@playwright/test`: +**2. Use `cssCoverage` in any test** where you want to collect coverage: ```ts import { test, expect } from './fixtures.js' -test('my test', async ({ page }) => { +test('my test', async ({ page, cssCoverage }) => { await page.goto('https://example.com') - // CSS coverage is collected automatically — no extra code needed + // CSS coverage is collected and written to disk when the test finishes }) ``` +The fixture starts coverage when the test begins and stops it when the test ends. JSON files are written to the output directory and attached to the Playwright HTML report. + **3. Configure the output directory** (optional) in `playwright.config.ts`: ```ts @@ -84,12 +90,12 @@ import { defineConfig } from '@playwright/test' export default defineConfig({ use: { - cssCoverageDir: 'css-coverage', // default value + cssCoverageDir: 'css-coverage', // default }, }) ``` -Coverage JSON files are written to the configured directory and attached to the Playwright HTML report. Pass the directory to the `css-coverage` CLI to analyze results: +Pass the directory to the `css-coverage` CLI to analyze results: ```sh css-coverage --coverage-dir=./css-coverage --min-coverage=0.8 diff --git a/src/playwright/index.ts b/src/playwright/index.ts index 74550ac..d26e360 100644 --- a/src/playwright/index.ts +++ b/src/playwright/index.ts @@ -41,8 +41,6 @@ export const test = base_test.extend({ contentType: 'application/json', }) }, - { auto: true }, + {}, ], }) - -export { expect } from '@playwright/test' From d33b62b3aa2e219d6b9416c79101118d10866418 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 13:58:49 +0000 Subject: [PATCH 03/11] Add docs for enabling cssCoverage fixture across an entire test file --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 3dd8cc4..3e80f73 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,19 @@ test('my test', async ({ page, cssCoverage }) => { The fixture starts coverage when the test begins and stops it when the test ends. JSON files are written to the output directory and attached to the Playwright HTML report. +**Enable for an entire test file** by requesting the fixture in `test.beforeEach`: + +```ts +import { test, expect } from './fixtures.js' + +test.beforeEach(async ({ cssCoverage }) => { + // every test in this file will now collect CSS coverage +}) + +test('first test', async ({ page }) => { ... }) +test('second test', async ({ page }) => { ... }) +``` + **3. Configure the output directory** (optional) in `playwright.config.ts`: ```ts From a3e6d1c218a867d111a8f83e69e55c527070ea04 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 13:59:28 +0000 Subject: [PATCH 04/11] Fix formatting --- README.md | 10 +++++----- src/playwright/index.ts | 5 +---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 3e80f73..d4fa879 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,8 @@ export { expect } import { test, expect } from './fixtures.js' test('my test', async ({ page, cssCoverage }) => { - await page.goto('https://example.com') - // CSS coverage is collected and written to disk when the test finishes + await page.goto('https://example.com') + // CSS coverage is collected and written to disk when the test finishes }) ``` @@ -102,9 +102,9 @@ test('second test', async ({ page }) => { ... }) import { defineConfig } from '@playwright/test' export default defineConfig({ - use: { - cssCoverageDir: 'css-coverage', // default - }, + use: { + cssCoverageDir: 'css-coverage', // default + }, }) ``` diff --git a/src/playwright/index.ts b/src/playwright/index.ts index d26e360..503ac0d 100644 --- a/src/playwright/index.ts +++ b/src/playwright/index.ts @@ -11,10 +11,7 @@ type CssCoverageOptions = { } export const test = base_test.extend({ - cssCoverageDir: [ - 'css-coverage', - { option: true, scope: 'worker' }, - ], + cssCoverageDir: ['css-coverage', { option: true, scope: 'worker' }], cssCoverage: [ async ({ page, cssCoverageDir }, use, testInfo) => { From 5a61cb091e3bbb174c0bc8b455803e4e184b8b0f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 08:53:28 +0000 Subject: [PATCH 05/11] Add tests for playwright fixture, fix worker-scoped option, fix mergeTests usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tests cover: configured output directory, default directory, file naming from titlePath, and file content being valid JSON coverage data - Drop scope: 'worker' from cssCoverageDir so it can be set per describe block with test.use() — worker-scoped options can only be set at file top-level which is too restrictive for users - Use mergeTests() instead of test.extend() when composing the fixture, which is the correct Playwright API for merging test objects - Update README to reflect mergeTests usage --- README.md | 4 +- src/playwright.test.ts | 116 ++++++++++++++++++++++++++++++++++++++++ src/playwright/index.ts | 2 +- 3 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 src/playwright.test.ts diff --git a/README.md b/README.md index d4fa879..e24f419 100644 --- a/README.md +++ b/README.md @@ -63,10 +63,10 @@ Use the built-in Playwright fixture to collect CSS coverage per test and write r **1. Extend your fixtures file** (e.g. `tests/fixtures.ts`): ```ts -import { test as base, expect } from '@playwright/test' +import { mergeTests, test as base, expect } from '@playwright/test' import { test as withCssCoverage } from '@projectwallace/css-code-coverage/playwright' -export const test = base.extend(withCssCoverage) +export const test = mergeTests(base, withCssCoverage) export { expect } ``` diff --git a/src/playwright.test.ts b/src/playwright.test.ts new file mode 100644 index 0000000..8f7b46e --- /dev/null +++ b/src/playwright.test.ts @@ -0,0 +1,116 @@ +import { mergeTests, test as base, expect } from '@playwright/test' +import { test as withCssCoverage } from './playwright/index.js' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' + +const test = mergeTests(base, withCssCoverage) + +function expected_filename(title_path: string[]): string { + return ( + title_path + .map((s) => + s + .replaceAll(/\s+|\/|\./g, '-') + .replaceAll(/[^a-zA-Z0-9-_]/g, '') + .toLowerCase(), + ) + .join('-') + '.json' + ) +} + +test.describe('cssCoverageDir option', () => { + let custom_dir = path.join(os.tmpdir(), 'css-code-coverage-fixture-dir-test') + let filename = '' + + test.use({ cssCoverageDir: custom_dir }) + + test.beforeAll(async () => { + await fs.rm(custom_dir, { recursive: true, force: true }) + }) + + test('writes coverage to the configured directory', async ({ page, cssCoverage }, testInfo) => { + await page.setContent('') + filename = expected_filename(testInfo.titlePath) + }) + + test.afterAll(async () => { + await expect(fs.access(path.join(custom_dir, filename))).resolves.toBeUndefined() + await fs.rm(custom_dir, { recursive: true, force: true }) + }) +}) + +test.describe('default cssCoverageDir', () => { + let default_dir = path.join(process.cwd(), 'css-coverage') + let filename = '' + + test('defaults to css-coverage relative to cwd', async ({ page, cssCoverage }, testInfo) => { + await page.setContent('') + filename = expected_filename(testInfo.titlePath) + }) + + test.afterAll(async () => { + await expect(fs.access(path.join(default_dir, filename))).resolves.toBeUndefined() + await fs.rm(default_dir, { recursive: true, force: true }) + }) +}) + +test.describe('file naming', () => { + let dir = path.join(os.tmpdir(), 'css-code-coverage-fixture-naming-test') + let filename = '' + + test.use({ cssCoverageDir: dir }) + + test.beforeAll(async () => { + await fs.rm(dir, { recursive: true, force: true }) + }) + + test('derives filename from the test title path', async ({ page, cssCoverage }, testInfo) => { + await page.setContent('') + filename = expected_filename(testInfo.titlePath) + }) + + test.afterAll(async () => { + let files = await fs.readdir(dir) + expect(files).toContain(filename) + await fs.rm(dir, { recursive: true, force: true }) + }) +}) + +test.describe('file content', () => { + let dir = path.join(os.tmpdir(), 'css-code-coverage-fixture-content-test') + let filename = '' + + test.use({ cssCoverageDir: dir }) + + test.beforeAll(async () => { + await fs.rm(dir, { recursive: true, force: true }) + }) + + test('writes valid JSON', async ({ page, cssCoverage }, testInfo) => { + await page.setContent('') + filename = expected_filename(testInfo.titlePath) + }) + + test('content is an array of coverage entries', async ({ page, cssCoverage }, testInfo) => { + await page.setContent('

hi

') + filename = expected_filename(testInfo.titlePath) + }) + + test.afterAll(async () => { + let files = await fs.readdir(dir) + for (let file of files) { + let content = await fs.readFile(path.join(dir, file), 'utf-8') + let parsed = JSON.parse(content) + expect(Array.isArray(parsed)).toBe(true) + for (let entry of parsed) { + expect(entry).toMatchObject({ + url: expect.any(String), + text: expect.any(String), + ranges: expect.any(Array), + }) + } + } + await fs.rm(dir, { recursive: true, force: true }) + }) +}) diff --git a/src/playwright/index.ts b/src/playwright/index.ts index 503ac0d..c1e3e4f 100644 --- a/src/playwright/index.ts +++ b/src/playwright/index.ts @@ -11,7 +11,7 @@ type CssCoverageOptions = { } export const test = base_test.extend({ - cssCoverageDir: ['css-coverage', { option: true, scope: 'worker' }], + cssCoverageDir: ['css-coverage', { option: true }], cssCoverage: [ async ({ page, cssCoverageDir }, use, testInfo) => { From e394783155b3e66ea3bb656a38446d9db3bd240a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 09:11:43 +0000 Subject: [PATCH 06/11] Extract slugify(), share TEST_CONTENT, simplify auto-coverage docs - Extract slugify() as a named exported function in the playwright fixture with a comment explaining why it's needed (titlePath entries contain '/' which would otherwise create subdirectories) - Import slugify in tests instead of duplicating the regex logic - Extract repeated HTML string to TEST_CONTENT const in tests - Collapse beforeEach auto-coverage example to a single line in README --- README.md | 6 ++---- src/playwright.test.ts | 30 ++++++++++-------------------- src/playwright/index.ts | 17 ++++++++++------- 3 files changed, 22 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index e24f419..c53b00f 100644 --- a/README.md +++ b/README.md @@ -83,14 +83,12 @@ test('my test', async ({ page, cssCoverage }) => { The fixture starts coverage when the test begins and stops it when the test ends. JSON files are written to the output directory and attached to the Playwright HTML report. -**Enable for an entire test file** by requesting the fixture in `test.beforeEach`: +**Enable for an entire test file** with a single line: ```ts import { test, expect } from './fixtures.js' -test.beforeEach(async ({ cssCoverage }) => { - // every test in this file will now collect CSS coverage -}) +test.beforeEach(async ({ cssCoverage }) => {}) test('first test', async ({ page }) => { ... }) test('second test', async ({ page }) => { ... }) diff --git a/src/playwright.test.ts b/src/playwright.test.ts index 8f7b46e..73373a0 100644 --- a/src/playwright.test.ts +++ b/src/playwright.test.ts @@ -1,22 +1,15 @@ import { mergeTests, test as base, expect } from '@playwright/test' -import { test as withCssCoverage } from './playwright/index.js' +import { test as withCssCoverage, slugify } from './playwright/index.js' import * as fs from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' const test = mergeTests(base, withCssCoverage) +const TEST_CONTENT = '' + function expected_filename(title_path: string[]): string { - return ( - title_path - .map((s) => - s - .replaceAll(/\s+|\/|\./g, '-') - .replaceAll(/[^a-zA-Z0-9-_]/g, '') - .toLowerCase(), - ) - .join('-') + '.json' - ) + return title_path.map(slugify).join('-') + '.json' } test.describe('cssCoverageDir option', () => { @@ -30,7 +23,7 @@ test.describe('cssCoverageDir option', () => { }) test('writes coverage to the configured directory', async ({ page, cssCoverage }, testInfo) => { - await page.setContent('') + await page.setContent(TEST_CONTENT) filename = expected_filename(testInfo.titlePath) }) @@ -45,7 +38,7 @@ test.describe('default cssCoverageDir', () => { let filename = '' test('defaults to css-coverage relative to cwd', async ({ page, cssCoverage }, testInfo) => { - await page.setContent('') + await page.setContent(TEST_CONTENT) filename = expected_filename(testInfo.titlePath) }) @@ -66,7 +59,7 @@ test.describe('file naming', () => { }) test('derives filename from the test title path', async ({ page, cssCoverage }, testInfo) => { - await page.setContent('') + await page.setContent(TEST_CONTENT) filename = expected_filename(testInfo.titlePath) }) @@ -79,7 +72,6 @@ test.describe('file naming', () => { test.describe('file content', () => { let dir = path.join(os.tmpdir(), 'css-code-coverage-fixture-content-test') - let filename = '' test.use({ cssCoverageDir: dir }) @@ -87,14 +79,12 @@ test.describe('file content', () => { await fs.rm(dir, { recursive: true, force: true }) }) - test('writes valid JSON', async ({ page, cssCoverage }, testInfo) => { - await page.setContent('') - filename = expected_filename(testInfo.titlePath) + test('writes valid JSON', async ({ page, cssCoverage }) => { + await page.setContent(TEST_CONTENT) }) - test('content is an array of coverage entries', async ({ page, cssCoverage }, testInfo) => { + test('content is an array of coverage entries', async ({ page, cssCoverage }) => { await page.setContent('

hi

') - filename = expected_filename(testInfo.titlePath) }) test.afterAll(async () => { diff --git a/src/playwright/index.ts b/src/playwright/index.ts index c1e3e4f..bf59859 100644 --- a/src/playwright/index.ts +++ b/src/playwright/index.ts @@ -10,6 +10,15 @@ type CssCoverageOptions = { cssCoverageDir: string } +// Needed because titlePath entries can contain '/' (creates subdirs), spaces, +// dots, and other chars that are invalid or problematic in file names. +export function slugify(s: string): string { + return s + .replaceAll(/\s+|\/|\./g, '-') + .replaceAll(/[^a-zA-Z0-9-_]/g, '') + .toLowerCase() +} + export const test = base_test.extend({ cssCoverageDir: ['css-coverage', { option: true }], @@ -19,13 +28,7 @@ export const test = base_test.extend({ await use() let coverage = await page.coverage.stopCSSCoverage() - let parts = testInfo.titlePath.map((s) => - s - .replaceAll(/\s+|\/|\./g, '-') - .replaceAll(/[^a-zA-Z0-9-_]/g, '') - .toLowerCase(), - ) - let file_name = parts.join('-') + '.json' + let file_name = testInfo.titlePath.map(slugify).join('-') + '.json' let dir = path.resolve(process.cwd(), cssCoverageDir) await fs.mkdir(dir, { recursive: true }) From 22380ffcb8cbe5d04757b38015fd74b1663d9864 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 09:28:20 +0000 Subject: [PATCH 07/11] Replace test fixture export with plain save_css_coverage() function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avoids importing @playwright/test entirely, removing the implicit dependency that would require it as a peerDependency or dependency in the published package. Users wire save_css_coverage() into their own Playwright fixture, keeping full control over the fixture lifecycle. Tests no longer need a browser — mock coverage data is passed directly to save_css_coverage(), making them faster and simpler. --- README.md | 38 ++++++++---- src/playwright.test.ts | 132 +++++++++++++++------------------------- src/playwright/index.ts | 50 ++++++--------- tsdown.config.ts | 3 - 4 files changed, 93 insertions(+), 130 deletions(-) diff --git a/README.md b/README.md index c53b00f..db330e8 100644 --- a/README.md +++ b/README.md @@ -58,15 +58,28 @@ let report = calculcate_coverage(coverage) ### Playwright fixture -Use the built-in Playwright fixture to collect CSS coverage per test and write results to disk. +Use `save_css_coverage` to collect and save CSS coverage from within a Playwright fixture. -**1. Extend your fixtures file** (e.g. `tests/fixtures.ts`): +**1. Add a fixture** (e.g. `tests/fixtures.ts`): ```ts -import { mergeTests, test as base, expect } from '@playwright/test' -import { test as withCssCoverage } from '@projectwallace/css-code-coverage/playwright' - -export const test = mergeTests(base, withCssCoverage) +import { test as base, expect } from '@playwright/test' +import { save_css_coverage } from '@projectwallace/css-code-coverage/playwright' + +export const test = base.extend({ + cssCoverage: [ + async ({ page }, use, testInfo) => { + await page.coverage.startCSSCoverage() + await use() + let coverage = await page.coverage.stopCSSCoverage() + await save_css_coverage(coverage, { + title_path: testInfo.titlePath, + attach: testInfo.attach.bind(testInfo), + }) + }, + {}, + ], +}) export { expect } ``` @@ -94,18 +107,17 @@ test('first test', async ({ page }) => { ... }) test('second test', async ({ page }) => { ... }) ``` -**3. Configure the output directory** (optional) in `playwright.config.ts`: +**Configure the output directory** (optional) via the `dir` option: ```ts -import { defineConfig } from '@playwright/test' - -export default defineConfig({ - use: { - cssCoverageDir: 'css-coverage', // default - }, +await save_css_coverage(coverage, { + dir: 'css-coverage', // default + title_path: testInfo.titlePath, }) ``` +Or set it globally in `playwright.config.ts` by passing the configured path to `save_css_coverage` from a shared fixture. + Pass the directory to the `css-coverage` CLI to analyze results: ```sh diff --git a/src/playwright.test.ts b/src/playwright.test.ts index 73373a0..b78d9ae 100644 --- a/src/playwright.test.ts +++ b/src/playwright.test.ts @@ -1,106 +1,74 @@ -import { mergeTests, test as base, expect } from '@playwright/test' -import { test as withCssCoverage, slugify } from './playwright/index.js' +import { test, expect } from '@playwright/test' +import { save_css_coverage, slugify } from './playwright/index.js' +import type { Coverage } from './lib/parse-coverage.js' import * as fs from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' -const test = mergeTests(base, withCssCoverage) +const MOCK_COVERAGE: Coverage[] = [ + { + url: 'http://example.com/style.css', + text: 'body { color: red }', + ranges: [{ start: 0, end: 19 }], + }, +] -const TEST_CONTENT = '' - -function expected_filename(title_path: string[]): string { - return title_path.map(slugify).join('-') + '.json' -} - -test.describe('cssCoverageDir option', () => { - let custom_dir = path.join(os.tmpdir(), 'css-code-coverage-fixture-dir-test') - let filename = '' - - test.use({ cssCoverageDir: custom_dir }) +test.describe('save_css_coverage', () => { + let dir = path.join(os.tmpdir(), 'css-code-coverage-save-test') test.beforeAll(async () => { - await fs.rm(custom_dir, { recursive: true, force: true }) - }) - - test('writes coverage to the configured directory', async ({ page, cssCoverage }, testInfo) => { - await page.setContent(TEST_CONTENT) - filename = expected_filename(testInfo.titlePath) + await fs.rm(dir, { recursive: true, force: true }) }) test.afterAll(async () => { - await expect(fs.access(path.join(custom_dir, filename))).resolves.toBeUndefined() - await fs.rm(custom_dir, { recursive: true, force: true }) + await fs.rm(dir, { recursive: true, force: true }) }) -}) - -test.describe('default cssCoverageDir', () => { - let default_dir = path.join(process.cwd(), 'css-coverage') - let filename = '' - test('defaults to css-coverage relative to cwd', async ({ page, cssCoverage }, testInfo) => { - await page.setContent(TEST_CONTENT) - filename = expected_filename(testInfo.titlePath) + test('writes file to the configured directory', async () => { + let title_path = ['writes file to the configured directory'] + await save_css_coverage(MOCK_COVERAGE, { dir, title_path }) + await expect( + fs.access(path.join(dir, title_path.map(slugify).join('-') + '.json')), + ).resolves.toBeUndefined() }) - test.afterAll(async () => { - await expect(fs.access(path.join(default_dir, filename))).resolves.toBeUndefined() + test('defaults dir to css-coverage relative to cwd', async () => { + let default_dir = path.join(process.cwd(), 'css-coverage') + let title_path = ['defaults dir to css-coverage relative to cwd'] + await save_css_coverage(MOCK_COVERAGE, { title_path }) + await expect( + fs.access(path.join(default_dir, title_path.map(slugify).join('-') + '.json')), + ).resolves.toBeUndefined() await fs.rm(default_dir, { recursive: true, force: true }) }) -}) - -test.describe('file naming', () => { - let dir = path.join(os.tmpdir(), 'css-code-coverage-fixture-naming-test') - let filename = '' - - test.use({ cssCoverageDir: dir }) - - test.beforeAll(async () => { - await fs.rm(dir, { recursive: true, force: true }) - }) - test('derives filename from the test title path', async ({ page, cssCoverage }, testInfo) => { - await page.setContent(TEST_CONTENT) - filename = expected_filename(testInfo.titlePath) - }) - - test.afterAll(async () => { + test('derives filename from title_path', async () => { + let title_path = ['My Suite', 'my test/name.ts'] + await save_css_coverage(MOCK_COVERAGE, { dir, title_path }) let files = await fs.readdir(dir) - expect(files).toContain(filename) - await fs.rm(dir, { recursive: true, force: true }) - }) -}) - -test.describe('file content', () => { - let dir = path.join(os.tmpdir(), 'css-code-coverage-fixture-content-test') - - test.use({ cssCoverageDir: dir }) - - test.beforeAll(async () => { - await fs.rm(dir, { recursive: true, force: true }) - }) - - test('writes valid JSON', async ({ page, cssCoverage }) => { - await page.setContent(TEST_CONTENT) + expect(files).toContain('my-suite-my-test-name-ts.json') }) - test('content is an array of coverage entries', async ({ page, cssCoverage }) => { - await page.setContent('

hi

') + test('calls attach with correct arguments', async () => { + let attached: { name: string; path: string; contentType: string } | undefined + let title_path = ['calls attach with correct arguments'] + await save_css_coverage(MOCK_COVERAGE, { + dir, + title_path, + attach: async (name, opts) => { + attached = { name, ...opts } + }, + }) + expect(attached?.name).toBe('css-coverage') + expect(attached?.contentType).toBe('application/json') + expect(attached?.path).toContain('.json') }) - test.afterAll(async () => { - let files = await fs.readdir(dir) - for (let file of files) { - let content = await fs.readFile(path.join(dir, file), 'utf-8') - let parsed = JSON.parse(content) - expect(Array.isArray(parsed)).toBe(true) - for (let entry of parsed) { - expect(entry).toMatchObject({ - url: expect.any(String), - text: expect.any(String), - ranges: expect.any(Array), - }) - } - } - await fs.rm(dir, { recursive: true, force: true }) + test('file content matches the coverage input', async () => { + let title_path = ['file content matches the coverage input'] + await save_css_coverage(MOCK_COVERAGE, { dir, title_path }) + let file_path = path.join(dir, title_path.map(slugify).join('-') + '.json') + let content = JSON.parse(await fs.readFile(file_path, 'utf-8')) + expect(content).toEqual(MOCK_COVERAGE) }) }) diff --git a/src/playwright/index.ts b/src/playwright/index.ts index bf59859..74959d3 100644 --- a/src/playwright/index.ts +++ b/src/playwright/index.ts @@ -1,46 +1,32 @@ -import { test as base_test } from '@playwright/test' import * as fs from 'node:fs/promises' import * as path from 'node:path' - -type CssCoverageFixtures = { - cssCoverage: void -} - -type CssCoverageOptions = { - cssCoverageDir: string -} +import type { Coverage } from '../lib/parse-coverage.js' // Needed because titlePath entries can contain '/' (creates subdirs), spaces, // dots, and other chars that are invalid or problematic in file names. export function slugify(s: string): string { return s .replaceAll(/\s+|\/|\./g, '-') - .replaceAll(/[^a-zA-Z0-9-_]/g, '') + .replaceAll(/[^a-z0-9-_]/gi, '') .toLowerCase() } -export const test = base_test.extend({ - cssCoverageDir: ['css-coverage', { option: true }], +export async function save_css_coverage( + coverage: Coverage[], + options: { + dir?: string + title_path: string[] + attach?: (name: string, options: { path: string; contentType: string }) => Promise + }, +): Promise { + let { dir = 'css-coverage', title_path, attach } = options - cssCoverage: [ - async ({ page, cssCoverageDir }, use, testInfo) => { - await page.coverage.startCSSCoverage() - await use() - let coverage = await page.coverage.stopCSSCoverage() + let file_name = title_path.map(slugify).join('-') + '.json' + let resolved_dir = path.resolve(process.cwd(), dir) + await fs.mkdir(resolved_dir, { recursive: true }) + let file_path = path.join(resolved_dir, file_name) - let file_name = testInfo.titlePath.map(slugify).join('-') + '.json' + await fs.writeFile(file_path, JSON.stringify(coverage)) - let dir = path.resolve(process.cwd(), cssCoverageDir) - await fs.mkdir(dir, { recursive: true }) - let file_path = path.join(dir, file_name) - - await fs.writeFile(file_path, JSON.stringify(coverage)) - - await testInfo.attach('css-coverage', { - path: file_path, - contentType: 'application/json', - }) - }, - {}, - ], -}) + await attach?.('css-coverage', { path: file_path, contentType: 'application/json' }) +} diff --git a/tsdown.config.ts b/tsdown.config.ts index e6d26f4..2e5b069 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -22,9 +22,6 @@ export default defineConfig([ format: 'esm', outDir: 'dist/playwright', publint: true, - deps: { - neverBundle: ['@playwright/test'], - }, plugins: [ codecovVitePlugin({ enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined, From c411bc6c0214e26f6231c6a3cd20baa7b4177067 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 09:36:23 +0000 Subject: [PATCH 08/11] Remove playwright subpath export, add fixture recipe to README The subpath added no real value once reduced to a plain helper function. The full fixture setup is simple enough to paste directly, and keeping it in the README means users own the code and can adapt it freely. --- README.md | 52 ++++++++++++----------------- package.json | 10 ++---- src/playwright.test.ts | 74 ----------------------------------------- src/playwright/index.ts | 32 ------------------ tsdown.config.ts | 15 --------- 5 files changed, 24 insertions(+), 159 deletions(-) delete mode 100644 src/playwright.test.ts delete mode 100644 src/playwright/index.ts diff --git a/README.md b/README.md index db330e8..e7f6247 100644 --- a/README.md +++ b/README.md @@ -58,13 +58,12 @@ let report = calculcate_coverage(coverage) ### Playwright fixture -Use `save_css_coverage` to collect and save CSS coverage from within a Playwright fixture. - -**1. Add a fixture** (e.g. `tests/fixtures.ts`): +Add a `cssCoverage` fixture to automatically collect and save CSS coverage for each test. Create a fixtures file (e.g. `tests/fixtures.ts`): ```ts import { test as base, expect } from '@playwright/test' -import { save_css_coverage } from '@projectwallace/css-code-coverage/playwright' +import * as fs from 'node:fs/promises' +import * as path from 'node:path' export const test = base.extend({ cssCoverage: [ @@ -72,10 +71,22 @@ export const test = base.extend({ await page.coverage.startCSSCoverage() await use() let coverage = await page.coverage.stopCSSCoverage() - await save_css_coverage(coverage, { - title_path: testInfo.titlePath, - attach: testInfo.attach.bind(testInfo), - }) + + let file_name = + testInfo.titlePath + .map((s) => + s + .replaceAll(/\s+|\/|\./g, '-') + .replaceAll(/[^a-z0-9-_]/gi, '') + .toLowerCase(), + ) + .join('-') + '.json' + + let dir = path.join(process.cwd(), 'css-coverage') + await fs.mkdir(dir, { recursive: true }) + let file_path = path.join(dir, file_name) + await fs.writeFile(file_path, JSON.stringify(coverage)) + await testInfo.attach('css-coverage', { path: file_path, contentType: 'application/json' }) }, {}, ], @@ -83,42 +94,23 @@ export const test = base.extend({ export { expect } ``` -**2. Use `cssCoverage` in any test** where you want to collect coverage: +Use `cssCoverage` in any test where you want to collect coverage: ```ts import { test, expect } from './fixtures.js' test('my test', async ({ page, cssCoverage }) => { await page.goto('https://example.com') - // CSS coverage is collected and written to disk when the test finishes }) ``` -The fixture starts coverage when the test begins and stops it when the test ends. JSON files are written to the output directory and attached to the Playwright HTML report. - -**Enable for an entire test file** with a single line: +Or enable it for an entire file with a single line: ```ts -import { test, expect } from './fixtures.js' - test.beforeEach(async ({ cssCoverage }) => {}) - -test('first test', async ({ page }) => { ... }) -test('second test', async ({ page }) => { ... }) ``` -**Configure the output directory** (optional) via the `dir` option: - -```ts -await save_css_coverage(coverage, { - dir: 'css-coverage', // default - title_path: testInfo.titlePath, -}) -``` - -Or set it globally in `playwright.config.ts` by passing the configured path to `save_css_coverage` from a shared fixture. - -Pass the directory to the `css-coverage` CLI to analyze results: +Pass the output directory to the `css-coverage` CLI to analyze results: ```sh css-coverage --coverage-dir=./css-coverage --min-coverage=0.8 diff --git a/package.json b/package.json index b37a109..b6ed1d2 100644 --- a/package.json +++ b/package.json @@ -30,14 +30,8 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./playwright": { - "types": "./dist/playwright/index.d.mts", - "default": "./dist/playwright/index.mjs" - } + "types": "./dist/index.d.ts", + "default": "./dist/index.js" }, "publishConfig": { "access": "public", diff --git a/src/playwright.test.ts b/src/playwright.test.ts deleted file mode 100644 index b78d9ae..0000000 --- a/src/playwright.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { test, expect } from '@playwright/test' -import { save_css_coverage, slugify } from './playwright/index.js' -import type { Coverage } from './lib/parse-coverage.js' -import * as fs from 'node:fs/promises' -import * as os from 'node:os' -import * as path from 'node:path' - -const MOCK_COVERAGE: Coverage[] = [ - { - url: 'http://example.com/style.css', - text: 'body { color: red }', - ranges: [{ start: 0, end: 19 }], - }, -] - -test.describe('save_css_coverage', () => { - let dir = path.join(os.tmpdir(), 'css-code-coverage-save-test') - - test.beforeAll(async () => { - await fs.rm(dir, { recursive: true, force: true }) - }) - - test.afterAll(async () => { - await fs.rm(dir, { recursive: true, force: true }) - }) - - test('writes file to the configured directory', async () => { - let title_path = ['writes file to the configured directory'] - await save_css_coverage(MOCK_COVERAGE, { dir, title_path }) - await expect( - fs.access(path.join(dir, title_path.map(slugify).join('-') + '.json')), - ).resolves.toBeUndefined() - }) - - test('defaults dir to css-coverage relative to cwd', async () => { - let default_dir = path.join(process.cwd(), 'css-coverage') - let title_path = ['defaults dir to css-coverage relative to cwd'] - await save_css_coverage(MOCK_COVERAGE, { title_path }) - await expect( - fs.access(path.join(default_dir, title_path.map(slugify).join('-') + '.json')), - ).resolves.toBeUndefined() - await fs.rm(default_dir, { recursive: true, force: true }) - }) - - test('derives filename from title_path', async () => { - let title_path = ['My Suite', 'my test/name.ts'] - await save_css_coverage(MOCK_COVERAGE, { dir, title_path }) - let files = await fs.readdir(dir) - expect(files).toContain('my-suite-my-test-name-ts.json') - }) - - test('calls attach with correct arguments', async () => { - let attached: { name: string; path: string; contentType: string } | undefined - let title_path = ['calls attach with correct arguments'] - await save_css_coverage(MOCK_COVERAGE, { - dir, - title_path, - attach: async (name, opts) => { - attached = { name, ...opts } - }, - }) - expect(attached?.name).toBe('css-coverage') - expect(attached?.contentType).toBe('application/json') - expect(attached?.path).toContain('.json') - }) - - test('file content matches the coverage input', async () => { - let title_path = ['file content matches the coverage input'] - await save_css_coverage(MOCK_COVERAGE, { dir, title_path }) - let file_path = path.join(dir, title_path.map(slugify).join('-') + '.json') - let content = JSON.parse(await fs.readFile(file_path, 'utf-8')) - expect(content).toEqual(MOCK_COVERAGE) - }) -}) diff --git a/src/playwright/index.ts b/src/playwright/index.ts deleted file mode 100644 index 74959d3..0000000 --- a/src/playwright/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as fs from 'node:fs/promises' -import * as path from 'node:path' -import type { Coverage } from '../lib/parse-coverage.js' - -// Needed because titlePath entries can contain '/' (creates subdirs), spaces, -// dots, and other chars that are invalid or problematic in file names. -export function slugify(s: string): string { - return s - .replaceAll(/\s+|\/|\./g, '-') - .replaceAll(/[^a-z0-9-_]/gi, '') - .toLowerCase() -} - -export async function save_css_coverage( - coverage: Coverage[], - options: { - dir?: string - title_path: string[] - attach?: (name: string, options: { path: string; contentType: string }) => Promise - }, -): Promise { - let { dir = 'css-coverage', title_path, attach } = options - - let file_name = title_path.map(slugify).join('-') + '.json' - let resolved_dir = path.resolve(process.cwd(), dir) - await fs.mkdir(resolved_dir, { recursive: true }) - let file_path = path.join(resolved_dir, file_name) - - await fs.writeFile(file_path, JSON.stringify(coverage)) - - await attach?.('css-coverage', { path: file_path, contentType: 'application/json' }) -} diff --git a/tsdown.config.ts b/tsdown.config.ts index 2e5b069..38a1692 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -16,21 +16,6 @@ export default defineConfig([ }), ], }, - { - entry: 'src/playwright/index.ts', - platform: 'node', - format: 'esm', - outDir: 'dist/playwright', - publint: true, - plugins: [ - codecovVitePlugin({ - enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined, - bundleName: 'playwright.js', - uploadToken: process.env.CODECOV_TOKEN, - telemetry: false, - }), - ], - }, { entry: 'src/cli/cli.ts', platform: 'node', From 459f31f1f42830466dff1b336ff312c6429f0ec0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 09:38:13 +0000 Subject: [PATCH 09/11] Make cssCoverage fixture auto so it runs for every test --- README.md | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e7f6247..c7a80ac 100644 --- a/README.md +++ b/README.md @@ -88,28 +88,22 @@ export const test = base.extend({ await fs.writeFile(file_path, JSON.stringify(coverage)) await testInfo.attach('css-coverage', { path: file_path, contentType: 'application/json' }) }, - {}, + { auto: true }, ], }) export { expect } ``` -Use `cssCoverage` in any test where you want to collect coverage: +Import `test` from your fixtures file instead of `@playwright/test` and coverage is collected automatically for every test: ```ts import { test, expect } from './fixtures.js' -test('my test', async ({ page, cssCoverage }) => { +test('my test', async ({ page }) => { await page.goto('https://example.com') }) ``` -Or enable it for an entire file with a single line: - -```ts -test.beforeEach(async ({ cssCoverage }) => {}) -``` - Pass the output directory to the `css-coverage` CLI to analyze results: ```sh From cc0bf9e512515a118049a547e59e2b9807ed4af9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 09:39:45 +0000 Subject: [PATCH 10/11] Add comments to README fixture explaining each step --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index c7a80ac..8d6bfc0 100644 --- a/README.md +++ b/README.md @@ -68,10 +68,17 @@ import * as path from 'node:path' export const test = base.extend({ cssCoverage: [ async ({ page }, use, testInfo) => { + // Start collecting CSS coverage before the test runs await page.coverage.startCSSCoverage() + + // Run the test await use() + + // Collect the coverage data after the test finishes let coverage = await page.coverage.stopCSSCoverage() + // Build a unique, human-readable filename from the test's title path, + // replacing characters that are invalid or ambiguous in file names let file_name = testInfo.titlePath .map((s) => @@ -82,12 +89,17 @@ export const test = base.extend({ ) .join('-') + '.json' + // Write the coverage data to disk let dir = path.join(process.cwd(), 'css-coverage') await fs.mkdir(dir, { recursive: true }) let file_path = path.join(dir, file_name) await fs.writeFile(file_path, JSON.stringify(coverage)) + + // Attach the file to the Playwright HTML report for easy inspection await testInfo.attach('css-coverage', { path: file_path, contentType: 'application/json' }) }, + // auto: true makes this fixture run for every test without needing + // to explicitly request it as a parameter { auto: true }, ], }) From 8e5f78b4790a2b1cdfc9563451b5d4d18dc3a4a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 09:41:01 +0000 Subject: [PATCH 11/11] Rename Playwright fixture section heading --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8d6bfc0..a786c8c 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ import { calculate_coverage } from '@projectwallace/css-code-coverage' let report = calculcate_coverage(coverage) ``` -### Playwright fixture +### Auto-collect from Playwright tests Add a `cssCoverage` fixture to automatically collect and save CSS coverage for each test. Create a fixtures file (e.g. `tests/fixtures.ts`):