@rpcbase/test 0.176.0 → 0.178.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,103 @@
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
+ ### Per-folder thresholds
31
+
32
+ Need stricter coverage in a sub-tree? Extend the same `thresholds` object with glob keys (mirroring Jest's `coverageThreshold` syntax):
33
+
34
+ ```ts
35
+ export default {
36
+ workspaceRoot,
37
+ thresholds: {
38
+ global: {
39
+ statements: 90,
40
+ lines: 85,
41
+ functions: 85,
42
+ branches: 70,
43
+ },
44
+ "lib/core/**": {
45
+ statements: 98,
46
+ lines: 95,
47
+ },
48
+ "lib/components/**": {
49
+ functions: 92,
50
+ },
51
+ },
52
+ }
53
+ ```
54
+
55
+ - When `thresholds` only has metric keys, it behaves exactly like before.
56
+ - Adding `thresholds.global` lets you keep a default floor while overriding specific directories.
57
+ - Globs run against POSIX-style paths relative to `workspaceRoot`, but absolute paths work too if you prefer.
58
+ - Metrics you omit inside an override inherit from the global thresholds (or the 75/75/75/60 defaults).
59
+ - If a glob matches no files you'll get a warning and the override is skipped, so typos are easy to spot.
60
+
61
+ ## 2. Import `test` / `expect` directly
62
+
63
+ ```ts
64
+ // spec/my-component.spec.ts
65
+ import { test, expect } from "@rpcbase/test"
66
+
67
+ test("renders", async ({ page }) => {
68
+ await page.goto("/playground")
69
+ await expect(page.locator("button"))..toBeVisible()
70
+ })
71
+ ```
72
+
73
+ The exported `test` already records coverage via CDP and writes per-test payloads automatically.
74
+
75
+ ## 3. Build your Playwright config via the shared wrapper
76
+
77
+ ```ts
78
+ // playwright.config.ts
79
+ import { defineConfig, devices } from "@rpcbase/test"
80
+
81
+ export default defineConfig({
82
+ testDir: "./spec",
83
+ reporter: [["list"]],
84
+ use: {
85
+ baseURL: "http://localhost:9198",
86
+ launchOptions: { headless: true },
87
+ },
88
+ projects: [
89
+ {
90
+ name: "chromium",
91
+ use: { ...devices["Desktop Chrome"] },
92
+ },
93
+ ],
94
+ })
95
+ ```
96
+
97
+ Whenever coverage is enabled, the wrapper appends the shared reporter so Istanbul aggregation + threshold enforcement run after the suite.
98
+
99
+ ## 4. Run tests with `rb-test`
100
+
101
+ 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.
102
+
103
+ 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,63 @@
1
+ export * from "playwright/test"
2
+
3
+ interface CoverageThresholds {
4
+ branches: number
5
+ functions: number
6
+ lines: number
7
+ statements: number
8
+ }
9
+
10
+ type CoverageThresholdMap = {
11
+ global?: Partial<CoverageThresholds>
12
+ [pattern: string]: Partial<CoverageThresholds> | number | undefined
13
+ }
14
+
15
+ type CoverageThresholdOption = Partial<CoverageThresholds> | CoverageThresholdMap
16
+
17
+ interface CoverageThresholdTarget {
18
+ id: string
19
+ pattern: string
20
+ thresholds: CoverageThresholds
21
+ }
22
+
23
+ interface CoverageHarnessOptions {
24
+ workspaceRoot: string
25
+ libDirs?: string[]
26
+ testResultsDir?: string
27
+ coverageReportSubdir?: string
28
+ coverageFileName?: string
29
+ envPrefix?: string
30
+ thresholds?: CoverageThresholdOption
31
+ disabledEnvVar?: string
32
+ }
33
+
34
+ interface CoverageConfig extends CoverageHarnessOptions {
35
+ libRoots: string[]
36
+ testResultsRoot: string
37
+ coverageReportDir: string
38
+ coverageFileName: string
39
+ thresholds: CoverageThresholds
40
+ thresholdTargets: CoverageThresholdTarget[]
41
+ coverageEnabled: boolean
42
+ disabledEnvVar: string
43
+ }
44
+
45
+ interface CoverageHarness {
46
+ config: CoverageConfig
47
+ globalSetup(...args: any[]): Promise<void>
48
+ extendTest<T>(baseTest: T): T
49
+ reporterEntry(): [string, Record<string, unknown>?]
50
+ }
51
+
52
+ export function clearDatabase(dbName: string): Promise<void>
53
+ export function createCoverageHarness(options: CoverageHarnessOptions): CoverageHarness
54
+ export function createCoverageConfig(options: CoverageHarnessOptions): CoverageConfig
55
+
56
+ export type {
57
+ CoverageHarness,
58
+ CoverageHarnessOptions,
59
+ CoverageConfig,
60
+ CoverageThresholds,
61
+ CoverageThresholdOption,
62
+ CoverageThresholdTarget,
63
+ }
package/package.json CHANGED
@@ -1,7 +1,15 @@
1
1
  {
2
2
  "name": "@rpcbase/test",
3
- "version": "0.176.0",
3
+ "version": "0.178.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,16 @@
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
+ "picomatch": "2.3.1",
55
+ "v8-to-istanbul": "9.2.0"
42
56
  },
57
+ "peerDependencies": {},
43
58
  "devDependencies": {}
44
59
  }
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,130 @@
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
+ const THRESHOLD_KEYS = Object.keys(DEFAULT_THRESHOLDS)
12
+
13
+ function resolveDir(root, target, fallback) {
14
+ if (!target) {
15
+ return path.resolve(root, fallback)
16
+ }
17
+
18
+ if (path.isAbsolute(target)) {
19
+ return target
20
+ }
21
+
22
+ return path.resolve(root, target)
23
+ }
24
+
25
+ export function createCoverageConfig(options = {}) {
26
+ const { workspaceRoot } = options
27
+ if (!workspaceRoot) {
28
+ throw new Error("createCoverageConfig requires a workspaceRoot")
29
+ }
30
+
31
+ const resolvedWorkspaceRoot = path.resolve(workspaceRoot)
32
+
33
+ const libDirs = Array.isArray(options.libDirs) && options.libDirs.length > 0
34
+ ? options.libDirs
35
+ : ["lib"]
36
+
37
+ const libRoots = libDirs.map((dir) =>
38
+ path.isAbsolute(dir) ? path.normalize(dir) : path.resolve(resolvedWorkspaceRoot, dir),
39
+ )
40
+
41
+ const testResultsRoot = resolveDir(resolvedWorkspaceRoot, options.testResultsDir, "test-results")
42
+ const coverageReportDir = resolveDir(testResultsRoot, options.coverageReportSubdir, "coverage")
43
+ const coverageFileName = options.coverageFileName ?? "v8-coverage.json"
44
+ const disabledEnvVar = options.disabledEnvVar ?? "RB_DISABLE_COVERAGE"
45
+ const coverageEnabled = process.env[disabledEnvVar] !== "1"
46
+
47
+ const { global: thresholds, targets: thresholdTargets } = normalizeThresholdOptions(options.thresholds)
48
+
49
+ return {
50
+ workspaceRoot: resolvedWorkspaceRoot,
51
+ libRoots,
52
+ testResultsRoot,
53
+ coverageReportDir,
54
+ coverageFileName,
55
+ thresholds,
56
+ thresholdTargets,
57
+ coverageEnabled,
58
+ disabledEnvVar,
59
+ }
60
+ }
61
+
62
+ function normalizeThresholdOptions(rawThresholds) {
63
+ const globalThresholds = { ...DEFAULT_THRESHOLDS }
64
+ const targets = []
65
+
66
+ if (!isPlainObject(rawThresholds)) {
67
+ return { global: globalThresholds, targets }
68
+ }
69
+
70
+ for (const key of THRESHOLD_KEYS) {
71
+ const value = rawThresholds[key]
72
+ if (isThresholdValue(value)) {
73
+ globalThresholds[key] = value
74
+ }
75
+ }
76
+
77
+ if (Object.prototype.hasOwnProperty.call(rawThresholds, "global")) {
78
+ if (!isPlainObject(rawThresholds.global)) {
79
+ throw new Error("coverage thresholds: the `global` override must be an object of metric values")
80
+ }
81
+ Object.assign(globalThresholds, pickThresholdOverrides(rawThresholds.global))
82
+ }
83
+
84
+ for (const [pattern, overrides] of Object.entries(rawThresholds)) {
85
+ if (pattern === "global" || THRESHOLD_KEYS.includes(pattern)) {
86
+ continue
87
+ }
88
+
89
+ if (!isPlainObject(overrides)) {
90
+ throw new Error(
91
+ `coverage thresholds: override for "${pattern}" must be an object containing coverage metrics`,
92
+ )
93
+ }
94
+
95
+ targets.push({
96
+ id: pattern,
97
+ pattern,
98
+ thresholds: {
99
+ ...globalThresholds,
100
+ ...pickThresholdOverrides(overrides),
101
+ },
102
+ })
103
+ }
104
+
105
+ return { global: globalThresholds, targets }
106
+ }
107
+
108
+ function pickThresholdOverrides(source) {
109
+ const overrides = {}
110
+ if (!isPlainObject(source)) {
111
+ return overrides
112
+ }
113
+
114
+ for (const key of THRESHOLD_KEYS) {
115
+ const value = source[key]
116
+ if (isThresholdValue(value)) {
117
+ overrides[key] = value
118
+ }
119
+ }
120
+
121
+ return overrides
122
+ }
123
+
124
+ function isPlainObject(value) {
125
+ return value !== null && typeof value === "object" && !Array.isArray(value)
126
+ }
127
+
128
+ function isThresholdValue(value) {
129
+ return typeof value === "number" && Number.isFinite(value)
130
+ }
@@ -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,192 @@
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 picomatch from "picomatch"
8
+ import v8ToIstanbul from "v8-to-istanbul"
9
+
10
+ import { findCoverageFiles } from "./files.js"
11
+
12
+
13
+ const TEXT_REPORT_FILENAME = "coverage.txt"
14
+
15
+ export class CoverageThresholdError extends Error {
16
+ constructor(message) {
17
+ super(message)
18
+ this.name = "CoverageThresholdError"
19
+ }
20
+ }
21
+
22
+ export async function generateCoverageReport(config) {
23
+ const coverageFiles = await findCoverageFiles(config)
24
+
25
+ if (coverageFiles.length === 0) {
26
+ console.warn("[coverage] no V8 coverage artifacts were generated")
27
+ return
28
+ }
29
+
30
+ const coverageLib = resolveCoverageLib()
31
+ const coverageMap = coverageLib.createCoverageMap({})
32
+
33
+ for (const file of coverageFiles) {
34
+ const payload = await readCoverageFile(file)
35
+ if (!payload) {
36
+ continue
37
+ }
38
+
39
+ for (const script of payload.scripts) {
40
+ await mergeScriptCoverage(coverageMap, script)
41
+ }
42
+ }
43
+
44
+ if (coverageMap.files().length === 0) {
45
+ console.warn("[coverage] no library files matched the coverage filters")
46
+ return
47
+ }
48
+
49
+ await fs.rm(config.coverageReportDir, { recursive: true, force: true })
50
+ await fs.mkdir(config.coverageReportDir, { recursive: true })
51
+
52
+ const context = createContext({
53
+ dir: config.coverageReportDir,
54
+ coverageMap,
55
+ defaultSummarizer: "pkg",
56
+ })
57
+
58
+ reports.create("text", { maxCols: process.stdout.columns ?? 120 }).execute(context)
59
+ reports.create("text", { file: TEXT_REPORT_FILENAME }).execute(context)
60
+
61
+ console.log(`[coverage] Full text report saved to ${path.join(config.coverageReportDir, TEXT_REPORT_FILENAME)}`)
62
+
63
+ const summary = coverageMap.getCoverageSummary()
64
+ enforceThresholds(summary, config.thresholds, "global")
65
+
66
+ const targets = Array.isArray(config.thresholdTargets) ? config.thresholdTargets : []
67
+ if (targets.length > 0) {
68
+ const fileSummaries = buildFileSummaries(coverageMap, config.workspaceRoot)
69
+ for (const target of targets) {
70
+ const matcher = createGlobMatcher(target.pattern)
71
+ const matchResult = collectTargetSummary(fileSummaries, matcher, coverageLib)
72
+
73
+ if (matchResult.matched === 0) {
74
+ console.warn(
75
+ `[coverage] threshold pattern "${target.pattern}" did not match any files — skipping`,
76
+ )
77
+ continue
78
+ }
79
+
80
+ enforceThresholds(matchResult.summary, target.thresholds, target.pattern)
81
+ }
82
+ }
83
+ }
84
+
85
+ async function mergeScriptCoverage(coverageMap, script) {
86
+ const scriptPath = script.absolutePath
87
+ if (!scriptPath) {
88
+ return
89
+ }
90
+
91
+ const source = script.source && script.source.length > 0
92
+ ? script.source
93
+ : await fs.readFile(scriptPath, "utf8")
94
+
95
+ const converter = v8ToIstanbul(scriptPath, 0, { source })
96
+ await converter.load()
97
+ converter.applyCoverage(script.functions)
98
+ coverageMap.merge(converter.toIstanbul())
99
+ }
100
+
101
+ async function readCoverageFile(file) {
102
+ try {
103
+ const raw = await fs.readFile(file, "utf8")
104
+ return JSON.parse(raw)
105
+ } catch (error) {
106
+ console.warn(`[coverage] failed to parse ${file}:`, error)
107
+ return null
108
+ }
109
+ }
110
+
111
+ function enforceThresholds(summary, thresholds, label = "global") {
112
+ const failures = []
113
+
114
+ for (const metric of Object.keys(thresholds)) {
115
+ const minimum = thresholds[metric]
116
+ const actual = summary[metric]?.pct ?? 0
117
+ if (actual < minimum) {
118
+ failures.push({ metric, actual, minimum })
119
+ }
120
+ }
121
+
122
+ if (failures.length === 0) {
123
+ return
124
+ }
125
+
126
+ const details = failures
127
+ .map(({ metric, actual, minimum }) => `${metric}: ${actual.toFixed(2)}% < ${minimum}%`)
128
+ .join("; ")
129
+
130
+ throw new CoverageThresholdError(`[coverage] thresholds not met (target: ${label}) — ${details}`)
131
+ }
132
+
133
+ function resolveCoverageLib() {
134
+ const candidate = libCoverage
135
+ if (typeof candidate.createCoverageMap === "function") {
136
+ return candidate
137
+ }
138
+
139
+ if (candidate.default && typeof candidate.default.createCoverageMap === "function") {
140
+ return candidate.default
141
+ }
142
+
143
+ throw new Error("istanbul-lib-coverage exports are unavailable")
144
+ }
145
+
146
+ function buildFileSummaries(coverageMap, workspaceRoot) {
147
+ const normalizedRoot = path.resolve(workspaceRoot)
148
+ return coverageMap.files().map((filePath) => {
149
+ const normalizedAbsolute = path.resolve(filePath)
150
+ const summary = coverageMap.fileCoverageFor(filePath).toSummary()
151
+ const relativePath = path.relative(normalizedRoot, normalizedAbsolute)
152
+ const candidates = new Set([toPosix(normalizedAbsolute)])
153
+
154
+ if (relativePath && !relativePath.startsWith("..")) {
155
+ const relativePosix = toPosix(relativePath)
156
+ candidates.add(relativePosix)
157
+ candidates.add(`./${relativePosix}`)
158
+ candidates.add(`/${relativePosix}`)
159
+ }
160
+
161
+ return {
162
+ summary,
163
+ candidates: Array.from(candidates),
164
+ }
165
+ })
166
+ }
167
+
168
+ function collectTargetSummary(fileSummaries, matcher, coverageLib) {
169
+ const summary = coverageLib.createCoverageSummary()
170
+ let matched = 0
171
+
172
+ for (const file of fileSummaries) {
173
+ if (file.candidates.some((candidate) => matcher(candidate))) {
174
+ summary.merge(file.summary)
175
+ matched += 1
176
+ }
177
+ }
178
+
179
+ return { summary, matched }
180
+ }
181
+
182
+ function createGlobMatcher(pattern) {
183
+ const normalized = toPosix(String(pattern ?? "")).trim()
184
+ if (!normalized) {
185
+ return () => false
186
+ }
187
+ return picomatch(normalized, { dot: true })
188
+ }
189
+
190
+ function toPosix(input) {
191
+ return input.split(path.sep).join("/")
192
+ }
@@ -0,0 +1,57 @@
1
+ import fs from "node:fs/promises"
2
+
3
+ import { CoverageThresholdError, 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(result) {
25
+ if (!this.config.coverageEnabled) {
26
+ return
27
+ }
28
+
29
+ try {
30
+ await generateCoverageReport(this.config)
31
+ } catch (error) {
32
+ if (error instanceof CoverageThresholdError) {
33
+ console.error(error.message)
34
+ setFailureExitCode(result)
35
+ return
36
+ }
37
+
38
+ throw error
39
+ }
40
+ }
41
+ }
42
+
43
+ export { CoverageReporter }
44
+ export default CoverageReporter
45
+
46
+ function setFailureExitCode(result) {
47
+ if (result && typeof result === "object") {
48
+ result.status = "failed"
49
+ if (typeof result.exitCode !== "number" || result.exitCode === 0) {
50
+ result.exitCode = 1
51
+ }
52
+ }
53
+
54
+ if (!process.exitCode || process.exitCode === 0) {
55
+ process.exitCode = 1
56
+ }
57
+ }
@@ -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)