@7n/test 0.14.2 → 0.14.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.14.3] - 2026-07-11
4
+
5
+ ### Changed
6
+
7
+ - feat(coverage): bun-native workspace підтримка — coverage через `bun test`
8
+
3
9
  ## [0.14.2] - 2026-07-11
4
10
 
5
11
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/test",
3
- "version": "0.14.2",
3
+ "version": "0.14.3",
4
4
  "description": "CLI-утиліта @7n/test",
5
5
  "keywords": [
6
6
  "7n",
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Детекція bun-native workspace-ів: prod-код імпортує `'bun'` (або `'bun:sqlite'`,
3
+ * `'bun:ffi'` тощо — усе крім `'bun:test'`, який конвертує lint-правило
4
+ * `test/no-bun-test-import`). vitest структурно не виконує такий код: модуль `bun`
5
+ * існує лише всередині Bun runtime (Node ESM/vitest module-runner його не резолвить),
6
+ * тож coverage для таких workspace-ів збирається через `bun test --coverage`
7
+ * (Bun автоматично ремапить `import ... from 'vitest'` у тест-файлах на `bun:test`).
8
+ */
9
+ import { readFile, readdir } from 'node:fs/promises'
10
+ import { join } from 'node:path'
11
+
12
+ /** import/require/dynamic-import специфікатора `bun` або `bun:*` (крім `bun:test`). */
13
+ const BUN_NATIVE_IMPORT_RE = /(?:from|import\s*\(|require\s*\()\s*(['"])bun(?::(?!test)[\w.-]+)?\1/u
14
+
15
+ /** JS/TS prod-розширення, які скануємо на bun-native імпорти. */
16
+ const JS_SOURCE_RE = /\.(c|m)?[jt]sx?$/
17
+ /** Тест-файли — не prod-код: `bun:test`/vitest-імпорти там не сигнал bun-native. */
18
+ const TEST_FILE_RE = /\.(test|spec)\.[^.]+$/
19
+ /** Тест-файли, які запускає `bun test` (для пре-скану «чи є що ганяти»). */
20
+ const RUNNABLE_TEST_RE = /\.test\.(m?js|ts)$/
21
+
22
+ const IGNORE_DIRS = new Set(['node_modules', 'dist', 'build', 'out', '.git', 'coverage', 'reports', 'docs', 'types'])
23
+
24
+ /**
25
+ * Рекурсивний обхід каталогу з відсіюванням службових директорій.
26
+ * @param {string} dir абсолютний шлях
27
+ * @param {(absPath: string) => void} onFile колбек для кожного файлу
28
+ * @returns {Promise<void>}
29
+ */
30
+ async function walk(dir, onFile) {
31
+ let entries
32
+ try {
33
+ entries = await readdir(dir, { withFileTypes: true })
34
+ } catch {
35
+ return
36
+ }
37
+ for (const entry of entries) {
38
+ if (entry.name.startsWith('.')) continue
39
+ const abs = join(dir, entry.name)
40
+ if (entry.isDirectory()) {
41
+ if (!IGNORE_DIRS.has(entry.name)) await walk(abs, onFile)
42
+ } else if (entry.isFile()) {
43
+ onFile(abs)
44
+ }
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Чи workspace bun-native: хоч один prod JS/TS-файл імпортує `bun`/`bun:*` (крім `bun:test`).
50
+ * @param {string} jsRoot абсолютний шлях workspace-кореня
51
+ * @returns {Promise<boolean>} true для bun-native workspace
52
+ */
53
+ export async function isBunNativeRoot(jsRoot) {
54
+ /** @type {string[]} */
55
+ const prodFiles = []
56
+ await walk(jsRoot, abs => {
57
+ if (JS_SOURCE_RE.test(abs) && !TEST_FILE_RE.test(abs)) prodFiles.push(abs)
58
+ })
59
+ for (const abs of prodFiles) {
60
+ let body
61
+ try {
62
+ body = await readFile(abs, 'utf8')
63
+ } catch {
64
+ continue
65
+ }
66
+ if (BUN_NATIVE_IMPORT_RE.test(body)) return true
67
+ }
68
+ return false
69
+ }
70
+
71
+ /**
72
+ * Чи workspace має тест-файли, які запустить `bun test` (`*.test.{js,mjs,ts}`).
73
+ * Пре-скан перед запуском: `bun test` без тестів завершується помилкою, а нам
74
+ * потрібен той самий graceful skip, що дає vitest `--passWithNoTests`.
75
+ * @param {string} jsRoot абсолютний шлях workspace-кореня
76
+ * @returns {Promise<boolean>} true, якщо є хоча б один тест-файл
77
+ */
78
+ export async function hasRunnableTests(jsRoot) {
79
+ let found = false
80
+ await walk(jsRoot, abs => {
81
+ if (RUNNABLE_TEST_RE.test(abs)) found = true
82
+ })
83
+ return found
84
+ }
@@ -171,7 +171,7 @@ export function resolveChangedScope(cwd) {
171
171
 
172
172
  /**
173
173
  * Виконує coverage-pipeline: JS-колектор (detect+collect), агрегація, запис COVERAGE.md.
174
- * @param {{cwd?:string, fix?:boolean, changed?:boolean}} [opts]
174
+ * @param {{cwd?:string, fix?:boolean, changed?:boolean}} [opts] опції прогону
175
175
  * @returns {Promise<number>} exit code (0 OK, 1 помилка)
176
176
  */
177
177
  export async function runCoverageSteps(opts = {}) {
@@ -240,7 +240,7 @@ function lockKey(changed) {
240
240
 
241
241
  /**
242
242
  * CLI entrypoint для `@7n/test coverage [--fix] [--changed]`.
243
- * @param {{cwd?:string, fix?:boolean, changed?:boolean}} [opts]
243
+ * @param {{cwd?:string, fix?:boolean, changed?:boolean}} [opts] опції CLI-виклику
244
244
  * @returns {Promise<number>} exit code
245
245
  */
246
246
  export async function runCoverageCli(opts = {}) {
@@ -14,6 +14,7 @@ import { dirname, isAbsolute, join, relative } from 'node:path'
14
14
 
15
15
  import { resolveAllJsRoots } from '../lib/resolve-js-root.mjs'
16
16
  import { addCoverage, addMutation } from './aggregate.mjs'
17
+ import { hasRunnableTests, isBunNativeRoot } from './bun-native.mjs'
17
18
 
18
19
  const TEST_BLOCK_START = /^\s*(it|test)\(/
19
20
  const FILE_EXTENSION = /\.[^.]+$/
@@ -281,6 +282,17 @@ const defaultRunner = {
281
282
  )
282
283
  return r.status ?? 1
283
284
  },
285
+ runBunCoverage({ cwd, lcovDir }) {
286
+ // Bun-native workspace (prod-код імпортує `bun`/`bun:*`): vitest такий модуль не
287
+ // резолвить, тож coverage ганяємо нативним `bun test`. Bun ремапить
288
+ // `import ... from 'vitest'` у тест-файлах на `bun:test` — тести лишаються canon.
289
+ const r = spawnSync('bun', ['test', '--coverage', '--coverage-reporter=lcov', `--coverage-dir=${lcovDir}`], {
290
+ cwd,
291
+ stdio: 'inherit',
292
+ env: process.env
293
+ })
294
+ return r.status ?? 1
295
+ },
284
296
  runStryker({ cwd, mutate }) {
285
297
  // Plugin-discovery Stryker (`@stryker-mutator/*`) globиться відносно CORE-install-каталогу
286
298
  // (`core/dist/src/di/plugin-loader.js` → `../../../../../@stryker-mutator/*`). Тож core
@@ -311,11 +323,15 @@ const defaultRunner = {
311
323
  * NoCoverage-мутанти (gate впаде, як і має). Якщо змінено лише тест-файли (нема
312
324
  * production-src) — Stryker не запускаємо (мутувати нічого), повертаємо лише coverage.
313
325
  *
314
- * Реальні помилки (vitest exit 0, відсутній mutation.json попри запуск Stryker)
326
+ * Bun-native workspace (prod-код імпортує `bun`/`bun:*`): coverage через
327
+ * `bun test --coverage` (vitest не резолвить модуль `bun`), mutation пропускається
328
+ * з попередженням — Stryker vitest-runner такий код не виконає.
329
+ *
330
+ * Реальні помилки (vitest/bun exit ≠ 0, відсутній mutation.json попри запуск Stryker)
315
331
  * кидаються — у multi-root режимі це не маскує справжній збій.
316
332
  * @param {string} jsRoot абсолютний шлях до workspace-кореня
317
333
  * @param {string} cwd корінь проєкту (для рібейзингу `survived[].file`)
318
- * @param {{runJsCoverage:Function, runStryker:Function}} runner spawn-ін'єкція
334
+ * @param {{runJsCoverage:Function, runStryker:Function, runBunCoverage:Function}} runner spawn-ін'єкція
319
335
  * @param {{files:string[], base:string|null}|null} [scope] changed-scope (null = full-режим)
320
336
  * @returns {Promise<{coverage:object, mutation:{caught:number,total:number}, survived:Array<object>} | null>} результати або null коли full-режим і workspace без тестів
321
337
  */
@@ -324,11 +340,27 @@ async function collectOneRoot(jsRoot, cwd, runner, scope = null) {
324
340
  // У changed-режимі production-файли для мутації = змінені JS цього root без тест-файлів.
325
341
  const mutateSrc = scope ? scope.files.filter(f => !TEST_FILE.test(f)) : null
326
342
 
327
- // 1. Coverage через vitest run --passWithNoTests --coverage (+ --changed у changed-режимі)
343
+ // Bun-native workspace: coverage через `bun test`, mutation пропускається
344
+ // (Stryker vitest-runner не виконає код з `import ... from 'bun'`).
345
+ const bunNative = await isBunNativeRoot(jsRoot)
346
+ if (bunNative && !(await hasRunnableTests(jsRoot))) {
347
+ // `bun test` без тестів завершується помилкою — graceful skip як vitest --passWithNoTests.
348
+ return scope
349
+ ? {
350
+ coverage: { lines: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 } },
351
+ mutation: { caught: 0, total: 0 },
352
+ survived: []
353
+ }
354
+ : null
355
+ }
356
+
357
+ // 1. Coverage: vitest run --passWithNoTests --coverage (+ --changed) або `bun test --coverage`
328
358
  const lcovDir = await mkdtemp(join(tmpdir(), 'js-cov-'))
329
359
  let coverage
330
360
  try {
331
- const code = await runner.runJsCoverage(scope ? { cwd: jsRoot, lcovDir, base: scope.base } : { cwd: jsRoot, lcovDir })
361
+ const code = bunNative
362
+ ? await runner.runBunCoverage({ cwd: jsRoot, lcovDir })
363
+ : await runner.runJsCoverage(scope ? { cwd: jsRoot, lcovDir, base: scope.base } : { cwd: jsRoot, lcovDir })
332
364
  if (code !== 0) throw new Error(`JS coverage exit ${code}`)
333
365
  const lcovPath = join(lcovDir, 'lcov.info')
334
366
  coverage = existsSync(lcovPath)
@@ -338,6 +370,16 @@ async function collectOneRoot(jsRoot, cwd, runner, scope = null) {
338
370
  await rm(lcovDir, { recursive: true, force: true })
339
371
  }
340
372
 
373
+ // Bun-native: mutation testing пропускаємо чесно, з попередженням — Stryker
374
+ // vitest-runner структурно несумісний із bun-native кодом.
375
+ if (bunNative) {
376
+ console.error(
377
+ `⚠ ${wsRel || '.'}: bun-native workspace (import 'bun' у prod-коді) — ` +
378
+ 'mutation testing пропущено (Stryker vitest-runner несумісний), лише line coverage'
379
+ )
380
+ return { coverage, mutation: { caught: 0, total: 0 }, survived: [] }
381
+ }
382
+
341
383
  // Full-режим: порожній lcov ⇔ vitest не знайшов тестів → пропускаємо workspace,
342
384
  // щоб не ганяти Stryker марно. У changed-режимі НЕ пропускаємо (див. JSDoc).
343
385
  if (!scope) {