@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 +103 -0
- package/index.d.ts +63 -0
- package/package.json +18 -3
- package/src/cli.js +83 -3
- package/src/coverage/config-loader.js +121 -0
- package/src/coverage/config.js +130 -0
- package/src/coverage/files.js +55 -0
- package/src/coverage/fixtures.js +33 -0
- package/src/coverage/global-setup.js +15 -0
- package/src/coverage/index.js +30 -0
- package/src/coverage/report.js +192 -0
- package/src/coverage/reporter.js +57 -0
- package/src/coverage/v8-tracker.js +172 -0
- package/src/index.js +50 -2
- package/src/register-tty.cjs +33 -0
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.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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)
|