@rpcbase/test 0.227.0 → 0.229.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 CHANGED
@@ -98,6 +98,13 @@ Whenever coverage is enabled, the wrapper appends the shared reporter so Istanbu
98
98
 
99
99
  ## 4. Run tests with `rb-test`
100
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.
101
+ Each package uses its `npm test` script as `tsc --noEmit && rb-test`. The CLI runs two stages:
102
102
 
103
- Need to debug without coverage? Set `RB_DISABLE_COVERAGE=1 npm test` and the hooks short-circuit.
103
+ - Playwright, with any CLI flags you pass through untouched; repo defaults are applied unless you provide your own `--config`.
104
+
105
+ Coverage is enforced separately:
106
+
107
+ - Playwright uses `spec/coverage.*` via the shared reporter and will fail the run if thresholds aren’t met.
108
+ - Vitest only reads `src/coverage.json` (JSON object). If that file is missing, Vitest coverage is skipped.
109
+
110
+ Need to debug without coverage? Set `RB_DISABLE_COVERAGE=1 npm test` and the Playwright hooks short-circuit.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpcbase/test",
3
- "version": "0.227.0",
3
+ "version": "0.229.0",
4
4
  "type": "module",
5
5
  "types": "./index.d.ts",
6
6
  "exports": {
@@ -53,7 +53,8 @@
53
53
  "istanbul-reports": "3.2.0",
54
54
  "lodash": "4.17.21",
55
55
  "picomatch": "2.3.1",
56
- "v8-to-istanbul": "9.3.0"
56
+ "v8-to-istanbul": "9.3.0",
57
+ "vitest": "4.0.15"
57
58
  },
58
59
  "devDependencies": {
59
60
  "mongoose": "8.20.0"
package/src/cli.js CHANGED
@@ -2,113 +2,129 @@
2
2
 
3
3
  import { spawn } from "child_process"
4
4
  import fs from "fs"
5
+ import fsPromises from "fs/promises"
5
6
  import path from "path"
6
7
  import { createRequire } from "module"
7
8
  import { fileURLToPath } from "url"
8
9
 
10
+ import { createCoverageConfig } from "./coverage/config.js"
11
+ import { loadCoverageOptions } from "./coverage/config-loader.js"
12
+ import { removeCoverageFiles } from "./coverage/files.js"
13
+ import { CoverageThresholdError, generateCoverageReport } from "./coverage/report.js"
14
+
9
15
 
10
16
  const require = createRequire(import.meta.url)
11
17
  const moduleDir = path.dirname(fileURLToPath(import.meta.url))
12
18
 
13
19
 
20
+ const TEST_GLOB = "{src,lib}/**/*.test.{js,ts,tsx}"
21
+ const VITEST_COVERAGE_CANDIDATES = ["src/coverage.json"]
22
+
23
+
14
24
  const isAider = process.env.IS_AIDER === "yes"
15
25
 
16
26
  if (process.env.IS_AIDER !== undefined && process.env.IS_AIDER !== "yes") {
17
27
  console.warn("Warning: IS_AIDER is set to a value other than 'yes'.")
18
28
  }
19
29
 
20
- function runTests() {
21
- return new Promise((resolve, reject) => {
22
- const userArgs = process.argv.slice(2)
30
+ async function runTests() {
31
+ const userArgs = process.argv.slice(2)
23
32
 
24
- // Determine config file path
25
- const configPath = fs.existsSync(
26
- path.join(process.cwd(), "playwright.config.ts"),
27
- )
28
- ? path.join(process.cwd(), "playwright.config.ts")
29
- : path.join(moduleDir, "playwright.config.ts")
33
+ const vitestCoverage = await loadVitestCoverageConfig()
30
34
 
31
- const hasCustomConfig = userArgs.some((arg) => {
32
- if (arg === "--config" || arg === "-c") {
33
- return true
34
- }
35
+ if (vitestCoverage?.enabled) {
36
+ await cleanCoverageArtifacts(vitestCoverage.config)
37
+ }
35
38
 
36
- return arg.startsWith("--config=")
37
- })
39
+ let testError = null
38
40
 
39
- const playwrightArgs = ["test"]
41
+ try {
42
+ await runVitest(vitestCoverage)
43
+ await runPlaywright(userArgs)
44
+ } catch (error) {
45
+ testError = error
46
+ }
40
47
 
41
- if (!hasCustomConfig) {
42
- playwrightArgs.push("--config", configPath)
48
+ if (vitestCoverage?.enabled) {
49
+ try {
50
+ await finalizeCoverage(vitestCoverage.config)
51
+ } catch (error) {
52
+ if (!testError) {
53
+ testError = error
54
+ }
43
55
  }
56
+ }
44
57
 
45
- playwrightArgs.push(...userArgs)
58
+ if (testError) {
59
+ throw testError
60
+ }
61
+ }
46
62
 
47
- const stdoutBuffer = []
48
- const stderrBuffer = []
63
+ runTests()
64
+ .then(() => process.exit(0))
65
+ .catch(() => process.exit(1))
49
66
 
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
- }
57
- const playwright = spawn(
58
- launcher.command,
59
- [...launcher.args, ...playwrightArgs],
60
- {
61
- shell: false,
62
- env,
63
- },
64
- )
67
+ async function runVitest(coverage) {
68
+ const vitestArgs = ["run", TEST_GLOB, "--passWithNoTests"]
69
+ const launcher = resolveVitestLauncher()
70
+ const env = withRegisterShim(process.env)
65
71
 
66
- playwright.stdout.on("data", (data) => {
67
- if (!isAider) {
68
- process.stdout.write(data)
69
- }
70
- stdoutBuffer.push(data.toString())
71
- })
72
+ if (coverage?.enabled) {
73
+ env.NODE_V8_COVERAGE = coverage.nodeCoverageDir
74
+ }
72
75
 
73
- playwright.stderr.on("data", (data) => {
74
- if (!isAider) {
75
- process.stderr.write(data)
76
- }
77
- stderrBuffer.push(data.toString())
78
- })
76
+ await spawnWithLogs({
77
+ name: "Vitest",
78
+ launcher,
79
+ args: vitestArgs,
80
+ env,
81
+ successMessage: "Vitest suite passed!",
82
+ failureMessage: "Vitest failed",
83
+ })
79
84
 
80
- playwright.on("close", (code) => {
81
- if (code === 0) {
82
- console.log("All tests passed!")
83
- resolve()
84
- } else {
85
- console.error("Tests failed:")
85
+ if (coverage?.enabled) {
86
+ await convertNodeCoverage(coverage)
87
+ }
88
+ }
86
89
 
87
- if (isAider) {
88
- if (stdoutBuffer.length > 0) {
89
- console.error(stdoutBuffer.join(""))
90
- }
90
+ function runPlaywright(userArgs) {
91
+ // Determine config file path
92
+ const configPath = fs.existsSync(
93
+ path.join(process.cwd(), "playwright.config.ts"),
94
+ )
95
+ ? path.join(process.cwd(), "playwright.config.ts")
96
+ : path.join(moduleDir, "playwright.config.ts")
97
+
98
+ const hasCustomConfig = userArgs.some((arg) => {
99
+ if (arg === "--config" || arg === "-c") {
100
+ return true
101
+ }
91
102
 
92
- if (stderrBuffer.length > 0) {
93
- console.error(stderrBuffer.join(""))
94
- }
95
- }
103
+ return arg.startsWith("--config=")
104
+ })
96
105
 
97
- reject(new Error(`Tests failed with exit code: ${code}`))
98
- }
99
- })
106
+ const playwrightArgs = ["test"]
100
107
 
101
- playwright.on("error", (error) => {
102
- console.error("Error spawning Playwright:", error)
103
- reject(error)
104
- })
108
+ if (!hasCustomConfig) {
109
+ playwrightArgs.push("--config", configPath)
110
+ }
111
+
112
+ playwrightArgs.push(...userArgs)
113
+
114
+ ensureJsxRuntimeShim(process.cwd())
115
+ const launcher = resolvePlaywrightLauncher()
116
+ const env = withRegisterShim(process.env)
117
+
118
+ return spawnWithLogs({
119
+ name: "Playwright",
120
+ launcher,
121
+ args: playwrightArgs,
122
+ env,
123
+ successMessage: "Playwright suite passed!",
124
+ failureMessage: "Playwright failed",
105
125
  })
106
126
  }
107
127
 
108
- runTests()
109
- .then(() => process.exit(0))
110
- .catch(() => process.exit(1))
111
-
112
128
  function resolvePlaywrightLauncher() {
113
129
  const cliPath = resolveCliPath()
114
130
  if (cliPath) {
@@ -150,6 +166,239 @@ function resolveCliPath() {
150
166
  return null
151
167
  }
152
168
 
169
+ function resolveVitestLauncher() {
170
+ const searchRoots = [process.cwd(), moduleDir]
171
+
172
+ for (const base of searchRoots) {
173
+ try {
174
+ const pkgPath = require.resolve("vitest/package.json", { paths: [base] })
175
+ const pkgDir = path.dirname(pkgPath)
176
+ const pkgJson = JSON.parse(fs.readFileSync(pkgPath, "utf8"))
177
+ const binPath = typeof pkgJson.bin === "string" ? pkgJson.bin : pkgJson.bin?.vitest
178
+ if (binPath) {
179
+ return {
180
+ command: process.execPath,
181
+ args: [path.join(pkgDir, binPath)],
182
+ }
183
+ }
184
+ } catch (_error) {
185
+ // continue searching
186
+ }
187
+ }
188
+
189
+ const localBin = path.resolve(process.cwd(), "node_modules/.bin/vitest")
190
+ if (fs.existsSync(localBin)) {
191
+ return {
192
+ command: localBin,
193
+ args: [],
194
+ }
195
+ }
196
+
197
+ return {
198
+ command: "vitest",
199
+ args: [],
200
+ }
201
+ }
202
+
203
+ async function loadVitestCoverageConfig() {
204
+ const options = await loadCoverageOptions({
205
+ optional: true,
206
+ candidates: VITEST_COVERAGE_CANDIDATES,
207
+ defaultTestResultsDir: "test-results-vitest",
208
+ })
209
+ if (!options) {
210
+ return null
211
+ }
212
+
213
+ const config = createCoverageConfig(options)
214
+
215
+ return {
216
+ config,
217
+ nodeCoverageDir: path.join(config.testResultsRoot, "node-coverage"),
218
+ enabled: config.coverageEnabled,
219
+ }
220
+ }
221
+
222
+ async function cleanCoverageArtifacts(config) {
223
+ await removeCoverageFiles(config)
224
+ await fsPromises.rm(config.coverageReportDir, { recursive: true, force: true })
225
+ await fsPromises.rm(path.join(config.testResultsRoot, "node-coverage"), { recursive: true, force: true })
226
+ }
227
+
228
+ async function convertNodeCoverage(coverage) {
229
+ const { config, nodeCoverageDir } = coverage
230
+
231
+ const entries = await fsPromises.readdir(nodeCoverageDir).catch(() => [])
232
+ const scripts = []
233
+
234
+ for (const entry of entries) {
235
+ if (!entry.endsWith(".json")) {
236
+ continue
237
+ }
238
+
239
+ const fullPath = path.join(nodeCoverageDir, entry)
240
+ const payload = await readJson(fullPath)
241
+ const results = Array.isArray(payload?.result) ? payload.result : []
242
+
243
+ for (const script of results) {
244
+ const normalized = normalizeNodeScriptUrl(script.url, config.workspaceRoot)
245
+ if (!normalized) {
246
+ continue
247
+ }
248
+
249
+ if (!isInsideLib(normalized.absolutePath, config.libRoots)) {
250
+ continue
251
+ }
252
+
253
+ const source = await fsPromises.readFile(normalized.absolutePath, "utf8").catch(() => "")
254
+
255
+ scripts.push({
256
+ absolutePath: normalized.absolutePath,
257
+ relativePath: normalized.relativePath,
258
+ source,
259
+ functions: script.functions ?? [],
260
+ url: script.url,
261
+ })
262
+ }
263
+ }
264
+
265
+ if (scripts.length === 0) {
266
+ return
267
+ }
268
+
269
+ const outDir = path.join(config.testResultsRoot, "vitest")
270
+ await fsPromises.mkdir(outDir, { recursive: true })
271
+ const outputFile = path.join(outDir, config.coverageFileName)
272
+ await fsPromises.writeFile(outputFile, JSON.stringify({ testId: "vitest", scripts }, null, 2), "utf8")
273
+ }
274
+
275
+ async function finalizeCoverage(config) {
276
+ try {
277
+ await generateCoverageReport(config)
278
+ } catch (error) {
279
+ if (error instanceof CoverageThresholdError) {
280
+ console.error(error.message)
281
+ }
282
+ throw error
283
+ }
284
+ }
285
+
286
+ async function readJson(filePath) {
287
+ try {
288
+ const raw = await fsPromises.readFile(filePath, "utf8")
289
+ return JSON.parse(raw)
290
+ } catch {
291
+ return null
292
+ }
293
+ }
294
+
295
+ function normalizeNodeScriptUrl(rawUrl, workspaceRoot) {
296
+ if (!rawUrl || rawUrl.startsWith("node:")) {
297
+ return null
298
+ }
299
+
300
+ let absolutePath = null
301
+
302
+ try {
303
+ if (rawUrl.startsWith("file://")) {
304
+ absolutePath = fileURLToPath(rawUrl)
305
+ }
306
+ } catch (_err) {
307
+ // ignore invalid URLs
308
+ }
309
+
310
+ if (!absolutePath && path.isAbsolute(rawUrl)) {
311
+ absolutePath = rawUrl
312
+ }
313
+
314
+ if (!absolutePath) {
315
+ return null
316
+ }
317
+
318
+ const normalized = path.normalize(absolutePath)
319
+ if (!normalized.startsWith(workspaceRoot)) {
320
+ return null
321
+ }
322
+
323
+ return {
324
+ absolutePath: normalized,
325
+ relativePath: path.relative(workspaceRoot, normalized),
326
+ }
327
+ }
328
+
329
+ function isInsideLib(absolutePath, libRoots) {
330
+ return libRoots.some((libRoot) => {
331
+ const relative = path.relative(libRoot, absolutePath)
332
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
333
+ })
334
+ }
335
+
336
+ function spawnWithLogs({ name, launcher, args, env, successMessage, failureMessage }) {
337
+ return new Promise((resolve, reject) => {
338
+ const stdoutBuffer = []
339
+ const stderrBuffer = []
340
+
341
+ const child = spawn(
342
+ launcher.command,
343
+ [...(launcher.args || []), ...args],
344
+ {
345
+ shell: false,
346
+ env,
347
+ },
348
+ )
349
+
350
+ child.stdout.on("data", (data) => {
351
+ if (!isAider) {
352
+ process.stdout.write(data)
353
+ }
354
+ stdoutBuffer.push(data.toString())
355
+ })
356
+
357
+ child.stderr.on("data", (data) => {
358
+ if (!isAider) {
359
+ process.stderr.write(data)
360
+ }
361
+ stderrBuffer.push(data.toString())
362
+ })
363
+
364
+ child.on("close", (code) => {
365
+ if (code === 0) {
366
+ if (successMessage) {
367
+ console.log(successMessage)
368
+ }
369
+ resolve()
370
+ } else {
371
+ console.error(failureMessage || `${name} failed:`)
372
+
373
+ if (isAider) {
374
+ if (stdoutBuffer.length > 0) {
375
+ console.error(stdoutBuffer.join(""))
376
+ }
377
+
378
+ if (stderrBuffer.length > 0) {
379
+ console.error(stderrBuffer.join(""))
380
+ }
381
+ }
382
+
383
+ reject(new Error(`${name} failed with exit code: ${code}`))
384
+ }
385
+ })
386
+
387
+ child.on("error", (error) => {
388
+ console.error(`Error spawning ${name}:`, error)
389
+ reject(error)
390
+ })
391
+ })
392
+ }
393
+
394
+ function withRegisterShim(baseEnv) {
395
+ const nodeOptions = appendNodeRequire(baseEnv.NODE_OPTIONS, path.join(moduleDir, "register-tty.cjs"))
396
+ return {
397
+ ...baseEnv,
398
+ NODE_OPTIONS: nodeOptions,
399
+ }
400
+ }
401
+
153
402
  function ensureJsxRuntimeShim(projectRoot) {
154
403
  const shimDir = path.join(projectRoot, "node_modules", "playwright")
155
404
  fs.mkdirSync(shimDir, { recursive: true })
@@ -7,7 +7,7 @@ import { pathToFileURL } from "node:url"
7
7
  import { build } from "esbuild"
8
8
 
9
9
 
10
- const COVERAGE_CANDIDATES = [
10
+ const DEFAULT_COVERAGE_CANDIDATES = [
11
11
  "spec/coverage.ts",
12
12
  "spec/coverage.mts",
13
13
  "spec/coverage.cts",
@@ -17,9 +17,9 @@ const COVERAGE_CANDIDATES = [
17
17
  "spec/coverage.json",
18
18
  ]
19
19
 
20
- export async function loadCoverageOptions({ optional = false } = {}) {
20
+ export async function loadCoverageOptions({ optional = false, candidates = DEFAULT_COVERAGE_CANDIDATES, defaultTestResultsDir } = {}) {
21
21
  const projectRoot = process.cwd()
22
- const resolved = await findCoverageFile(projectRoot)
22
+ const resolved = await findCoverageFile(projectRoot, candidates)
23
23
 
24
24
  if (!resolved) {
25
25
  if (optional) {
@@ -35,11 +35,11 @@ export async function loadCoverageOptions({ optional = false } = {}) {
35
35
  throw new Error(`Coverage config at ${resolved} must export an object.`)
36
36
  }
37
37
 
38
- return normalizeOptions(raw, resolved)
38
+ return normalizeOptions(raw, resolved, defaultTestResultsDir)
39
39
  }
40
40
 
41
- async function findCoverageFile(root) {
42
- for (const relative of COVERAGE_CANDIDATES) {
41
+ async function findCoverageFile(root, candidates) {
42
+ for (const relative of candidates) {
43
43
  const candidate = path.resolve(root, relative)
44
44
  try {
45
45
  await fs.access(candidate)
@@ -101,7 +101,7 @@ async function loadModule(url) {
101
101
  return imported
102
102
  }
103
103
 
104
- function normalizeOptions(rawOptions, filePath) {
104
+ function normalizeOptions(rawOptions, filePath, defaultTestResultsDir) {
105
105
  const options = { ...rawOptions }
106
106
  const configDir = path.dirname(filePath)
107
107
 
@@ -116,9 +116,11 @@ function normalizeOptions(rawOptions, filePath) {
116
116
  return {
117
117
  workspaceRoot,
118
118
  libDirs,
119
- testResultsDir: options.testResultsDir ?? "test-results",
119
+ testResultsDir: options.testResultsDir ?? defaultTestResultsDir ?? "test-results",
120
120
  coverageReportSubdir: options.coverageReportSubdir ?? "coverage",
121
121
  coverageFileName: options.coverageFileName ?? "v8-coverage.json",
122
122
  thresholds: options.thresholds ?? {},
123
123
  }
124
124
  }
125
+
126
+ export { DEFAULT_COVERAGE_CANDIDATES }