@rpcbase/test 0.176.0 → 0.177.0

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/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # @rpcbase/test helpers
2
+
3
+ `@rpcbase/test` ships the shared Playwright wiring we use across RPCBase packages: a preconfigured `test`/`expect`, automatic V8 coverage collection, and the `rb-test` CLI.
4
+
5
+ ## 1. Declare your coverage config
6
+
7
+ Create `spec/coverage.ts` (or `.js` / `.json`) in your package:
8
+
9
+ ```ts
10
+ import path from "node:path"
11
+ import { fileURLToPath } from "node:url"
12
+
13
+ const workspaceRoot = path.resolve(fileURLToPath(new URL("../", import.meta.url)))
14
+
15
+ export default {
16
+ workspaceRoot,
17
+ libDirs: ["lib"],
18
+ testResultsDir: "test-results",
19
+ thresholds: {
20
+ statements: 75,
21
+ lines: 75,
22
+ functions: 75,
23
+ branches: 60,
24
+ },
25
+ }
26
+ ```
27
+
28
+ Only `RB_DISABLE_COVERAGE=1` skips the hooks; every other option lives inside this config file.
29
+
30
+ ## 2. Import `test` / `expect` directly
31
+
32
+ ```ts
33
+ // spec/my-component.spec.ts
34
+ import { test, expect } from "@rpcbase/test"
35
+
36
+ test("renders", async ({ page }) => {
37
+ await page.goto("/playground")
38
+ await expect(page.locator("button"))..toBeVisible()
39
+ })
40
+ ```
41
+
42
+ The exported `test` already records coverage via CDP and writes per-test payloads automatically.
43
+
44
+ ## 3. Build your Playwright config via the shared wrapper
45
+
46
+ ```ts
47
+ // playwright.config.ts
48
+ import { defineConfig, devices } from "@rpcbase/test"
49
+
50
+ export default defineConfig({
51
+ testDir: "./spec",
52
+ reporter: [["list"]],
53
+ use: {
54
+ baseURL: "http://localhost:9198",
55
+ launchOptions: { headless: true },
56
+ },
57
+ projects: [
58
+ {
59
+ name: "chromium",
60
+ use: { ...devices["Desktop Chrome"] },
61
+ },
62
+ ],
63
+ })
64
+ ```
65
+
66
+ Whenever coverage is enabled, the wrapper appends the shared reporter so Istanbul aggregation + threshold enforcement run after the suite.
67
+
68
+ ## 4. Run tests with `rb-test`
69
+
70
+ Each package keeps its `npm test` script as `tsc --noEmit && rb-test`. The CLI wraps Playwright’s binary, applies the repo defaults, and prints failures cleanly inside the Codex harness.
71
+
72
+ Need to debug without coverage? Set `RB_DISABLE_COVERAGE=1 npm test` and the hooks short-circuit.
package/index.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ export * from "playwright/test"
2
+
3
+ interface CoverageThresholds {
4
+ branches: number
5
+ functions: number
6
+ lines: number
7
+ statements: number
8
+ }
9
+
10
+ interface CoverageHarnessOptions {
11
+ workspaceRoot: string
12
+ libDirs?: string[]
13
+ testResultsDir?: string
14
+ coverageReportSubdir?: string
15
+ coverageFileName?: string
16
+ envPrefix?: string
17
+ thresholds?: Partial<CoverageThresholds>
18
+ disabledEnvVar?: string
19
+ }
20
+
21
+ interface CoverageConfig extends CoverageHarnessOptions {
22
+ libRoots: string[]
23
+ testResultsRoot: string
24
+ coverageReportDir: string
25
+ coverageFileName: string
26
+ thresholds: CoverageThresholds
27
+ coverageEnabled: boolean
28
+ disabledEnvVar: string
29
+ }
30
+
31
+ interface CoverageHarness {
32
+ config: CoverageConfig
33
+ globalSetup(...args: any[]): Promise<void>
34
+ extendTest<T>(baseTest: T): T
35
+ reporterEntry(): [string, Record<string, unknown>?]
36
+ }
37
+
38
+ export function clearDatabase(dbName: string): Promise<void>
39
+ export function createCoverageHarness(options: CoverageHarnessOptions): CoverageHarness
40
+ export function createCoverageConfig(options: CoverageHarnessOptions): CoverageConfig
41
+
42
+ export type {
43
+ CoverageHarness,
44
+ CoverageHarnessOptions,
45
+ CoverageConfig,
46
+ CoverageThresholds,
47
+ }
package/package.json CHANGED
@@ -1,7 +1,15 @@
1
1
  {
2
2
  "name": "@rpcbase/test",
3
- "version": "0.176.0",
3
+ "version": "0.177.0",
4
4
  "type": "module",
5
+ "types": "./index.d.ts",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./index.d.ts",
9
+ "import": "./src/index.js",
10
+ "default": "./src/index.js"
11
+ }
12
+ },
5
13
  "bin": {
6
14
  "rb-test": "./src/cli.js"
7
15
  },
@@ -36,9 +44,15 @@
36
44
  }
37
45
  },
38
46
  "dependencies": {
39
- "@playwright/test": "1.55.0",
47
+ "@playwright/test": "1.56.1",
48
+ "esbuild": "0.24.0",
49
+ "istanbul-lib-coverage": "3.2.2",
50
+ "istanbul-lib-report": "3.0.1",
51
+ "istanbul-reports": "3.1.6",
40
52
  "lodash": "4.17.21",
41
- "mongoose": "8.18.0"
53
+ "mongoose": "8.18.0",
54
+ "v8-to-istanbul": "9.2.0"
42
55
  },
56
+ "peerDependencies": {},
43
57
  "devDependencies": {}
44
58
  }
package/src/cli.js CHANGED
@@ -3,6 +3,12 @@
3
3
  import { spawn } from "child_process"
4
4
  import fs from "fs"
5
5
  import path from "path"
6
+ import { createRequire } from "module"
7
+ import { fileURLToPath } from "url"
8
+
9
+
10
+ const require = createRequire(import.meta.url)
11
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url))
6
12
 
7
13
 
8
14
  const isAider = process.env.IS_AIDER === "yes"
@@ -20,7 +26,7 @@ function runTests() {
20
26
  path.join(process.cwd(), "playwright.config.ts"),
21
27
  )
22
28
  ? path.join(process.cwd(), "playwright.config.ts")
23
- : path.join(__dirname, "playwright.config.ts")
29
+ : path.join(moduleDir, "playwright.config.ts")
24
30
 
25
31
  const hasCustomConfig = userArgs.some((arg) => {
26
32
  if (arg === "--config" || arg === "-c") {
@@ -41,11 +47,19 @@ function runTests() {
41
47
  const stdoutBuffer = []
42
48
  const stderrBuffer = []
43
49
 
50
+ ensureJsxRuntimeShim(process.cwd())
51
+ const launcher = resolvePlaywrightLauncher()
52
+ const nodeOptions = appendNodeRequire(process.env.NODE_OPTIONS, path.join(moduleDir, "register-tty.cjs"))
53
+ const env = {
54
+ ...process.env,
55
+ NODE_OPTIONS: nodeOptions,
56
+ }
44
57
  const playwright = spawn(
45
- "./node_modules/.bin/playwright",
46
- playwrightArgs,
58
+ launcher.command,
59
+ [...launcher.args, ...playwrightArgs],
47
60
  {
48
61
  shell: false,
62
+ env,
49
63
  },
50
64
  )
51
65
 
@@ -94,3 +108,69 @@ function runTests() {
94
108
  runTests()
95
109
  .then(() => process.exit(0))
96
110
  .catch(() => process.exit(1))
111
+
112
+ function resolvePlaywrightLauncher() {
113
+ const cliPath = resolveCliPath()
114
+ if (cliPath) {
115
+ return {
116
+ command: process.execPath,
117
+ args: [cliPath],
118
+ }
119
+ }
120
+
121
+ const localBin = path.resolve(process.cwd(), "node_modules/.bin/playwright")
122
+ if (fs.existsSync(localBin)) {
123
+ return {
124
+ command: localBin,
125
+ args: [],
126
+ }
127
+ }
128
+
129
+ return {
130
+ command: "playwright",
131
+ args: [],
132
+ }
133
+ }
134
+
135
+ function resolveCliPath() {
136
+ const searchRoots = [process.cwd(), moduleDir]
137
+
138
+ for (const base of searchRoots) {
139
+ try {
140
+ const pkgPath = require.resolve("@playwright/test/package.json", { paths: [base] })
141
+ const cliPath = path.join(path.dirname(pkgPath), "cli.js")
142
+ if (fs.existsSync(cliPath)) {
143
+ return cliPath
144
+ }
145
+ } catch (_error) {
146
+ // continue searching
147
+ }
148
+ }
149
+
150
+ return null
151
+ }
152
+
153
+ function ensureJsxRuntimeShim(projectRoot) {
154
+ const shimDir = path.join(projectRoot, "node_modules", "playwright")
155
+ fs.mkdirSync(shimDir, { recursive: true })
156
+ const shims = [
157
+ { file: "jsx-runtime.js", target: "react/jsx-runtime" },
158
+ { file: "jsx-dev-runtime.js", target: "react/jsx-dev-runtime" },
159
+ ]
160
+
161
+ for (const { file, target } of shims) {
162
+ const filePath = path.join(shimDir, file)
163
+ if (!fs.existsSync(filePath)) {
164
+ const content = `export * from "${target}";\nexport { default } from "${target}";\n`
165
+ fs.writeFileSync(filePath, content, "utf8")
166
+ }
167
+ }
168
+ }
169
+
170
+ function appendNodeRequire(existing, modulePath) {
171
+ const flag = `--require=${modulePath}`
172
+ if (!existing || existing.length === 0) {
173
+ return flag
174
+ }
175
+ return `${existing} ${flag}`
176
+ }
@@ -0,0 +1,121 @@
1
+ import fs from "node:fs/promises"
2
+ import path from "node:path"
3
+ import os from "node:os"
4
+ import crypto from "node:crypto"
5
+ import { pathToFileURL } from "node:url"
6
+
7
+ import { build } from "esbuild"
8
+
9
+
10
+ const COVERAGE_CANDIDATES = [
11
+ "spec/coverage.ts",
12
+ "spec/coverage.mts",
13
+ "spec/coverage.cts",
14
+ "spec/coverage.js",
15
+ "spec/coverage.mjs",
16
+ "spec/coverage.cjs",
17
+ "spec/coverage.json",
18
+ ]
19
+
20
+ export async function loadCoverageOptions() {
21
+ const projectRoot = process.cwd()
22
+ const resolved = await findCoverageFile(projectRoot)
23
+
24
+ if (!resolved) {
25
+ throw new Error(
26
+ "Coverage config not found. Create one of: spec/coverage.{ts,js,json} with your coverage settings.",
27
+ )
28
+ }
29
+
30
+ const raw = await importCoverageModule(resolved)
31
+ if (!raw || typeof raw !== "object") {
32
+ throw new Error(`Coverage config at ${resolved} must export an object.`)
33
+ }
34
+
35
+ return normalizeOptions(raw, resolved)
36
+ }
37
+
38
+ async function findCoverageFile(root) {
39
+ for (const relative of COVERAGE_CANDIDATES) {
40
+ const candidate = path.resolve(root, relative)
41
+ try {
42
+ await fs.access(candidate)
43
+ return candidate
44
+ } catch {
45
+ // continue
46
+ }
47
+ }
48
+ return null
49
+ }
50
+
51
+ async function importCoverageModule(filePath) {
52
+ const ext = path.extname(filePath)
53
+
54
+ if (ext === ".json") {
55
+ const raw = await fs.readFile(filePath, "utf8")
56
+ return JSON.parse(raw)
57
+ }
58
+
59
+ if ([".ts", ".mts", ".cts"].includes(ext)) {
60
+ const compiledUrl = await compileTsModule(filePath)
61
+ return loadModule(compiledUrl)
62
+ }
63
+
64
+ const moduleUrl = pathToFileURL(filePath).href
65
+ return loadModule(moduleUrl)
66
+ }
67
+
68
+ async function compileTsModule(filePath) {
69
+ const stat = await fs.stat(filePath)
70
+ const hash = crypto
71
+ .createHash("sha1")
72
+ .update(filePath)
73
+ .update(String(stat.mtimeMs))
74
+ .digest("hex")
75
+
76
+ const outDir = path.join(os.tmpdir(), "rpcbase-test")
77
+ await fs.mkdir(outDir, { recursive: true })
78
+ const outfile = path.join(outDir, `coverage-${hash}.mjs`)
79
+
80
+ await build({
81
+ entryPoints: [filePath],
82
+ outfile,
83
+ platform: "node",
84
+ format: "esm",
85
+ bundle: false,
86
+ sourcemap: "inline",
87
+ logLevel: "silent",
88
+ })
89
+
90
+ return pathToFileURL(outfile).href
91
+ }
92
+
93
+ async function loadModule(url) {
94
+ const imported = await import(url)
95
+ if (imported && typeof imported.default === "object") {
96
+ return imported.default
97
+ }
98
+ return imported
99
+ }
100
+
101
+ function normalizeOptions(rawOptions, filePath) {
102
+ const options = { ...rawOptions }
103
+ const configDir = path.dirname(filePath)
104
+
105
+ const workspaceRoot = path.resolve(
106
+ options.workspaceRoot ? path.resolve(configDir, options.workspaceRoot) : process.cwd(),
107
+ )
108
+
109
+ const libDirs = Array.isArray(options.libDirs) && options.libDirs.length > 0
110
+ ? options.libDirs.map((entry) => path.resolve(workspaceRoot, entry))
111
+ : [path.resolve(workspaceRoot, "lib")]
112
+
113
+ return {
114
+ workspaceRoot,
115
+ libDirs,
116
+ testResultsDir: options.testResultsDir ?? "test-results",
117
+ coverageReportSubdir: options.coverageReportSubdir ?? "coverage",
118
+ coverageFileName: options.coverageFileName ?? "v8-coverage.json",
119
+ thresholds: options.thresholds ?? {},
120
+ }
121
+ }
@@ -0,0 +1,60 @@
1
+ import path from "node:path"
2
+
3
+
4
+ const DEFAULT_THRESHOLDS = {
5
+ branches: 60,
6
+ functions: 75,
7
+ lines: 75,
8
+ statements: 75,
9
+ }
10
+
11
+ function resolveDir(root, target, fallback) {
12
+ if (!target) {
13
+ return path.resolve(root, fallback)
14
+ }
15
+
16
+ if (path.isAbsolute(target)) {
17
+ return target
18
+ }
19
+
20
+ return path.resolve(root, target)
21
+ }
22
+
23
+ export function createCoverageConfig(options = {}) {
24
+ const { workspaceRoot } = options
25
+ if (!workspaceRoot) {
26
+ throw new Error("createCoverageConfig requires a workspaceRoot")
27
+ }
28
+
29
+ const resolvedWorkspaceRoot = path.resolve(workspaceRoot)
30
+
31
+ const libDirs = Array.isArray(options.libDirs) && options.libDirs.length > 0
32
+ ? options.libDirs
33
+ : ["lib"]
34
+
35
+ const libRoots = libDirs.map((dir) =>
36
+ path.isAbsolute(dir) ? path.normalize(dir) : path.resolve(resolvedWorkspaceRoot, dir),
37
+ )
38
+
39
+ const testResultsRoot = resolveDir(resolvedWorkspaceRoot, options.testResultsDir, "test-results")
40
+ const coverageReportDir = resolveDir(testResultsRoot, options.coverageReportSubdir, "coverage")
41
+ const coverageFileName = options.coverageFileName ?? "v8-coverage.json"
42
+ const disabledEnvVar = options.disabledEnvVar ?? "RB_DISABLE_COVERAGE"
43
+ const coverageEnabled = process.env[disabledEnvVar] !== "1"
44
+
45
+ const thresholds = {
46
+ ...DEFAULT_THRESHOLDS,
47
+ ...(options.thresholds ?? {}),
48
+ }
49
+
50
+ return {
51
+ workspaceRoot: resolvedWorkspaceRoot,
52
+ libRoots,
53
+ testResultsRoot,
54
+ coverageReportDir,
55
+ coverageFileName,
56
+ thresholds,
57
+ coverageEnabled,
58
+ disabledEnvVar,
59
+ }
60
+ }
@@ -0,0 +1,55 @@
1
+ import fs from "node:fs/promises"
2
+ import path from "node:path"
3
+
4
+
5
+ export async function findCoverageFiles(config, root = config.testResultsRoot) {
6
+ const files = []
7
+
8
+ async function walk(current) {
9
+ const entries = await fs.readdir(current, { withFileTypes: true })
10
+ await Promise.all(
11
+ entries.map(async (entry) => {
12
+ const entryPath = path.join(current, entry.name)
13
+ if (entry.isDirectory()) {
14
+ await walk(entryPath)
15
+ } else if (entry.isFile() && entry.name === config.coverageFileName) {
16
+ files.push(entryPath)
17
+ }
18
+ }),
19
+ )
20
+ }
21
+
22
+ try {
23
+ const stats = await fs.stat(root)
24
+ if (!stats.isDirectory()) {
25
+ return []
26
+ }
27
+ } catch {
28
+ return []
29
+ }
30
+
31
+ await walk(root)
32
+ return files.sort()
33
+ }
34
+
35
+ export async function removeCoverageFiles(config, root = config.testResultsRoot) {
36
+ async function walk(current) {
37
+ const entries = await fs.readdir(current, { withFileTypes: true })
38
+ await Promise.all(
39
+ entries.map(async (entry) => {
40
+ const entryPath = path.join(current, entry.name)
41
+ if (entry.isDirectory()) {
42
+ await walk(entryPath)
43
+ } else if (entry.isFile() && entry.name === config.coverageFileName) {
44
+ await fs.rm(entryPath, { force: true })
45
+ }
46
+ }),
47
+ )
48
+ }
49
+
50
+ try {
51
+ await walk(root)
52
+ } catch {
53
+ // ignore cleanup errors
54
+ }
55
+ }
@@ -0,0 +1,33 @@
1
+ import { createCoverageTracker } from "./v8-tracker.js"
2
+
3
+
4
+ function noopTracker() {
5
+ return {
6
+ async stop() {
7
+ // no-op
8
+ },
9
+ }
10
+ }
11
+
12
+ export function createCoverageFixtures(baseTest, config) {
13
+ return baseTest.extend({
14
+ page: async ({ page }, use, testInfo) => {
15
+ let tracker = noopTracker()
16
+
17
+ if (config.coverageEnabled) {
18
+ try {
19
+ tracker = await createCoverageTracker(page, config)
20
+ } catch (error) {
21
+ console.warn("[coverage] failed to initialize V8 coverage:", error)
22
+ }
23
+ }
24
+
25
+ try {
26
+ // eslint-disable-next-line react-hooks/rules-of-hooks
27
+ await use(page)
28
+ } finally {
29
+ await tracker.stop(testInfo)
30
+ }
31
+ },
32
+ })
33
+ }
@@ -0,0 +1,15 @@
1
+ import fs from "node:fs/promises"
2
+
3
+ import { removeCoverageFiles } from "./files.js"
4
+
5
+
6
+ export function createCoverageGlobalSetup(config) {
7
+ return async function globalSetup() {
8
+ if (!config.coverageEnabled) {
9
+ return
10
+ }
11
+
12
+ await removeCoverageFiles(config)
13
+ await fs.rm(config.coverageReportDir, { recursive: true, force: true })
14
+ }
15
+ }
@@ -0,0 +1,30 @@
1
+ import { fileURLToPath } from "node:url"
2
+
3
+ import { createCoverageConfig } from "./config.js"
4
+ import { createCoverageGlobalSetup } from "./global-setup.js"
5
+ import { createCoverageFixtures } from "./fixtures.js"
6
+
7
+
8
+ export function createCoverageHarness(options = {}) {
9
+ const config = createCoverageConfig(options)
10
+ const globalSetup = createCoverageGlobalSetup(config)
11
+
12
+ return {
13
+ config,
14
+ globalSetup,
15
+ extendTest(baseTest) {
16
+ return createCoverageFixtures(baseTest, config)
17
+ },
18
+ reporterEntry() {
19
+ const reporterPath = fileURLToPath(new URL("./reporter.js", import.meta.url))
20
+ return [reporterPath, { coverageConfig: config }]
21
+ },
22
+ }
23
+ }
24
+
25
+ export { CoverageReporter } from "./reporter.js"
26
+ export { createCoverageConfig } from "./config.js"
27
+ export { generateCoverageReport } from "./report.js"
28
+ export { createCoverageFixtures } from "./fixtures.js"
29
+ export { createCoverageGlobalSetup } from "./global-setup.js"
30
+ export { findCoverageFiles, removeCoverageFiles } from "./files.js"
@@ -0,0 +1,117 @@
1
+ import fs from "node:fs/promises"
2
+ import path from "node:path"
3
+
4
+ import * as libCoverage from "istanbul-lib-coverage"
5
+ import { createContext } from "istanbul-lib-report"
6
+ import reports from "istanbul-reports"
7
+ import v8ToIstanbul from "v8-to-istanbul"
8
+
9
+ import { findCoverageFiles } from "./files.js"
10
+
11
+
12
+ const TEXT_REPORT_FILENAME = "coverage.txt"
13
+
14
+ export async function generateCoverageReport(config) {
15
+ const coverageFiles = await findCoverageFiles(config)
16
+
17
+ if (coverageFiles.length === 0) {
18
+ console.warn("[coverage] no V8 coverage artifacts were generated")
19
+ return
20
+ }
21
+
22
+ const coverageMap = resolveCoverageLib().createCoverageMap({})
23
+
24
+ for (const file of coverageFiles) {
25
+ const payload = await readCoverageFile(file)
26
+ if (!payload) {
27
+ continue
28
+ }
29
+
30
+ for (const script of payload.scripts) {
31
+ await mergeScriptCoverage(coverageMap, script)
32
+ }
33
+ }
34
+
35
+ if (coverageMap.files().length === 0) {
36
+ console.warn("[coverage] no library files matched the coverage filters")
37
+ return
38
+ }
39
+
40
+ await fs.rm(config.coverageReportDir, { recursive: true, force: true })
41
+ await fs.mkdir(config.coverageReportDir, { recursive: true })
42
+
43
+ const context = createContext({
44
+ dir: config.coverageReportDir,
45
+ coverageMap,
46
+ defaultSummarizer: "pkg",
47
+ })
48
+
49
+ reports.create("text", { maxCols: process.stdout.columns ?? 120 }).execute(context)
50
+ reports.create("text", { file: TEXT_REPORT_FILENAME }).execute(context)
51
+
52
+ console.log(`[coverage] Full text report saved to ${path.join(config.coverageReportDir, TEXT_REPORT_FILENAME)}`)
53
+
54
+ const summary = coverageMap.getCoverageSummary()
55
+ enforceThresholds(summary, config.thresholds)
56
+ }
57
+
58
+ async function mergeScriptCoverage(coverageMap, script) {
59
+ const scriptPath = script.absolutePath
60
+ if (!scriptPath) {
61
+ return
62
+ }
63
+
64
+ const source = script.source && script.source.length > 0
65
+ ? script.source
66
+ : await fs.readFile(scriptPath, "utf8")
67
+
68
+ const converter = v8ToIstanbul(scriptPath, 0, { source })
69
+ await converter.load()
70
+ converter.applyCoverage(script.functions)
71
+ coverageMap.merge(converter.toIstanbul())
72
+ }
73
+
74
+ async function readCoverageFile(file) {
75
+ try {
76
+ const raw = await fs.readFile(file, "utf8")
77
+ return JSON.parse(raw)
78
+ } catch (error) {
79
+ console.warn(`[coverage] failed to parse ${file}:`, error)
80
+ return null
81
+ }
82
+ }
83
+
84
+ function enforceThresholds(summary, thresholds) {
85
+ const failures = []
86
+
87
+ for (const metric of Object.keys(thresholds)) {
88
+ const minimum = thresholds[metric]
89
+ const actual = summary[metric]?.pct ?? 0
90
+ if (actual < minimum) {
91
+ failures.push({ metric, actual, minimum })
92
+ }
93
+ }
94
+
95
+ if (failures.length === 0) {
96
+ return
97
+ }
98
+
99
+ const details = failures
100
+ .map(({ metric, actual, minimum }) => `${metric}: ${actual.toFixed(2)}% < ${minimum}%`)
101
+ .join("; ")
102
+
103
+ throw new Error(`[coverage] thresholds not met — ${details}`)
104
+ }
105
+
106
+ function resolveCoverageLib() {
107
+ const candidate = libCoverage
108
+ if (typeof candidate.createCoverageMap === "function") {
109
+ return candidate
110
+ }
111
+
112
+ if (candidate.default && typeof candidate.default.createCoverageMap === "function") {
113
+ return candidate.default
114
+ }
115
+
116
+ throw new Error("istanbul-lib-coverage exports are unavailable")
117
+ }
@@ -0,0 +1,34 @@
1
+ import fs from "node:fs/promises"
2
+
3
+ import { generateCoverageReport } from "./report.js"
4
+ import { removeCoverageFiles } from "./files.js"
5
+
6
+
7
+ class CoverageReporter {
8
+ constructor(options = {}) {
9
+ this.config = options.coverageConfig
10
+ if (!this.config) {
11
+ throw new Error("CoverageReporter requires a coverageConfig option")
12
+ }
13
+ }
14
+
15
+ async onBegin() {
16
+ if (!this.config.coverageEnabled) {
17
+ return
18
+ }
19
+
20
+ await removeCoverageFiles(this.config)
21
+ await fs.rm(this.config.coverageReportDir, { recursive: true, force: true })
22
+ }
23
+
24
+ async onEnd() {
25
+ if (!this.config.coverageEnabled) {
26
+ return
27
+ }
28
+
29
+ await generateCoverageReport(this.config)
30
+ }
31
+ }
32
+
33
+ export { CoverageReporter }
34
+ export default CoverageReporter
@@ -0,0 +1,172 @@
1
+ import fs from "node:fs/promises"
2
+ import path from "node:path"
3
+
4
+
5
+ const VITE_FS_PREFIX = "/@fs/"
6
+
7
+ export async function createCoverageTracker(page, config) {
8
+ const session = await page.context().newCDPSession(page)
9
+ const scriptMeta = new Map()
10
+ const sourceCache = new Map()
11
+
12
+ await session.send("Debugger.enable")
13
+ session.on("Debugger.scriptParsed", (event) => {
14
+ if (!event.url) {
15
+ return
16
+ }
17
+
18
+ const normalized = normalizeScriptUrl(event.url, config)
19
+ const trackable = normalized && isInsideLib(normalized.absolutePath, config.libRoots)
20
+
21
+ scriptMeta.set(event.scriptId, {
22
+ normalized: trackable ? normalized : null,
23
+ url: event.url,
24
+ })
25
+
26
+ if (trackable && !sourceCache.has(event.scriptId)) {
27
+ sourceCache.set(event.scriptId, fetchScriptSource(session, event.scriptId))
28
+ }
29
+ })
30
+
31
+ await session.send("Profiler.enable")
32
+ await session.send("Profiler.startPreciseCoverage", { callCount: false, detailed: true })
33
+
34
+ return {
35
+ async stop(testInfo) {
36
+ try {
37
+ const payload = await collectCoveragePayload(session, scriptMeta, sourceCache, testInfo, config)
38
+
39
+ if (payload.scripts.length === 0) {
40
+ return
41
+ }
42
+
43
+ const outputFile = testInfo.outputPath(config.coverageFileName)
44
+ await fs.mkdir(path.dirname(outputFile), { recursive: true })
45
+ await fs.writeFile(outputFile, JSON.stringify(payload, null, 2), "utf8")
46
+ } finally {
47
+ await shutdownSession(session)
48
+ }
49
+ },
50
+ }
51
+ }
52
+
53
+ async function collectCoveragePayload(session, scriptMeta, sourceCache, testInfo, config) {
54
+ const { result } = await session.send("Profiler.takePreciseCoverage")
55
+ await session.send("Profiler.stopPreciseCoverage")
56
+
57
+ const scripts = []
58
+
59
+ for (const script of result) {
60
+ const meta = scriptMeta.get(script.scriptId)
61
+ if (!meta || !meta.normalized) {
62
+ continue
63
+ }
64
+
65
+ const source = await resolveScriptSource(session, sourceCache, script.scriptId)
66
+ scripts.push({
67
+ absolutePath: meta.normalized.absolutePath,
68
+ relativePath: meta.normalized.relativePath,
69
+ source,
70
+ functions: script.functions,
71
+ url: meta.url,
72
+ })
73
+ }
74
+
75
+ return {
76
+ testId: testInfo.titlePath.join(" › "),
77
+ scripts,
78
+ }
79
+ }
80
+
81
+ async function resolveScriptSource(session, cache, scriptId) {
82
+ const cached = cache.get(scriptId)
83
+ if (cached) {
84
+ return cached
85
+ }
86
+
87
+ const promise = fetchScriptSource(session, scriptId)
88
+ cache.set(scriptId, promise)
89
+ return promise
90
+ }
91
+
92
+ async function fetchScriptSource(session, scriptId) {
93
+ const sourceResponse = await session.send("Debugger.getScriptSource", { scriptId })
94
+ return sourceResponse?.scriptSource ?? ""
95
+ }
96
+
97
+ async function shutdownSession(session) {
98
+ await Promise.allSettled([
99
+ session.send("Profiler.stopPreciseCoverage").catch(() => undefined),
100
+ session.send("Profiler.disable").catch(() => undefined),
101
+ session.send("Debugger.disable").catch(() => undefined),
102
+ ])
103
+ await session.detach().catch(() => undefined)
104
+ }
105
+
106
+ function normalizeScriptUrl(rawUrl, config) {
107
+ if (!rawUrl || rawUrl.startsWith("node:")) {
108
+ return null
109
+ }
110
+
111
+ const cleaned = stripQuery(rawUrl)
112
+ let pathname = cleaned
113
+
114
+ try {
115
+ const parsed = new URL(cleaned)
116
+ pathname = parsed.pathname
117
+ } catch {
118
+ // keep as-is for relative paths
119
+ }
120
+
121
+ if (!pathname) {
122
+ return null
123
+ }
124
+
125
+ let absolutePath
126
+
127
+ const decoded = decodeURIComponent(pathname)
128
+ if (decoded.startsWith(VITE_FS_PREFIX)) {
129
+ absolutePath = path.normalize(decoded.slice(VITE_FS_PREFIX.length))
130
+ } else if (decoded.startsWith("/")) {
131
+ absolutePath = path.resolve(config.workspaceRoot, `.${decoded}`)
132
+ } else {
133
+ return null
134
+ }
135
+
136
+ return createNormalizedPath(absolutePath, config.workspaceRoot)
137
+ }
138
+
139
+ function createNormalizedPath(absolutePath, workspaceRoot) {
140
+ if (!absolutePath.startsWith(workspaceRoot)) {
141
+ return null
142
+ }
143
+
144
+ const relativePath = path.relative(workspaceRoot, absolutePath)
145
+ return {
146
+ absolutePath,
147
+ relativePath,
148
+ }
149
+ }
150
+
151
+ function stripQuery(url) {
152
+ const queryIndex = url.indexOf("?")
153
+ const hashIndex = url.indexOf("#")
154
+
155
+ const endIndex = Math.min(
156
+ queryIndex === -1 ? Number.POSITIVE_INFINITY : queryIndex,
157
+ hashIndex === -1 ? Number.POSITIVE_INFINITY : hashIndex,
158
+ )
159
+
160
+ if (!Number.isFinite(endIndex)) {
161
+ return url
162
+ }
163
+
164
+ return url.slice(0, endIndex)
165
+ }
166
+
167
+ function isInsideLib(absolutePath, libRoots) {
168
+ return libRoots.some((libRoot) => {
169
+ const relative = path.relative(libRoot, absolutePath)
170
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
171
+ })
172
+ }
package/src/index.js CHANGED
@@ -1,2 +1,50 @@
1
- // export * from "./defineConfig"
2
- export * from "./clearDatabase.js"
1
+ import { test as baseTest, expect as baseExpect, defineConfig as playwrightDefineConfig, devices } from "@playwright/test"
2
+
3
+ import { loadCoverageOptions } from "./coverage/config-loader.js"
4
+ import { createCoverageHarness } from "./coverage/index.js"
5
+
6
+
7
+ export { clearDatabase } from "./clearDatabase.js"
8
+ export * from "./coverage/index.js"
9
+
10
+ const coverageOptions = await loadCoverageOptions()
11
+ const coverageHarness = createCoverageHarness(coverageOptions)
12
+
13
+ export const test = coverageHarness.extendTest(baseTest)
14
+ export const expect = baseExpect
15
+ export { devices }
16
+
17
+ export function defineConfig(userConfig = {}) {
18
+ const normalized = { ...userConfig }
19
+ const reporters = ensureReporterArray(normalized.reporter)
20
+
21
+ if (coverageHarness.config.coverageEnabled) {
22
+ const coverageReporter = coverageHarness.reporterEntry()
23
+ if (!reporters.some(([name]) => name === coverageReporter[0])) {
24
+ reporters.push(coverageReporter)
25
+ }
26
+ }
27
+
28
+ normalized.reporter = reporters
29
+
30
+ return playwrightDefineConfig(normalized)
31
+ }
32
+
33
+ function ensureReporterArray(reporter) {
34
+ if (!reporter) {
35
+ return [["list"]]
36
+ }
37
+
38
+ if (!Array.isArray(reporter)) {
39
+ return [normalizeReporterEntry(reporter)]
40
+ }
41
+
42
+ return reporter.map((entry) => normalizeReporterEntry(entry))
43
+ }
44
+
45
+ function normalizeReporterEntry(entry) {
46
+ if (Array.isArray(entry)) {
47
+ return entry
48
+ }
49
+ return [entry]
50
+ }
@@ -0,0 +1,33 @@
1
+ function ensureTTY(stream, fallbackColumns = 80, fallbackRows = 24) {
2
+ if (!stream || stream.isTTY) {
3
+ return
4
+ }
5
+
6
+ try {
7
+ stream.isTTY = true
8
+ } catch (error) {
9
+ // ignore property assignment errors
10
+ }
11
+
12
+ if (typeof stream.getWindowSize === "function") {
13
+ const [columns, rows] = stream.getWindowSize()
14
+ if (Number.isFinite(columns)) {
15
+ stream.columns = columns
16
+ }
17
+ if (Number.isFinite(rows)) {
18
+ stream.rows = rows
19
+ }
20
+ } else {
21
+ if (stream.columns == null) {
22
+ const columnsEnv = Number.parseInt(process.env.COLUMNS ?? "", 10)
23
+ stream.columns = Number.isFinite(columnsEnv) ? columnsEnv : fallbackColumns
24
+ }
25
+ if (stream.rows == null) {
26
+ const rowsEnv = Number.parseInt(process.env.LINES ?? "", 10)
27
+ stream.rows = Number.isFinite(rowsEnv) ? rowsEnv : fallbackRows
28
+ }
29
+ }
30
+ }
31
+
32
+ ensureTTY(process.stdout)
33
+ ensureTTY(process.stderr)