@7n/test 0.14.2 → 0.14.4
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,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.14.4] - 2026-07-11
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- `measureCoveragePerFile`: не додавати `--coverage.exclude=**/*.d.ts` на CLI, коли target-проєкт має власний локальний vitest + `vitest.config.js` — CLI-флаг повністю замінює (не мерджить) масив `test.coverage.exclude` із конфіга проєкту, через що тихо затирались власні винятки (напр. bun-native-тести, що можуть виконуватись лише під `bun test`) і колектор помилково бачив уже покриті файли як 0%, намагаючись згенерувати для них нові тести. У bundled/shim-режимі (без локального vitest) флаг лишається — там немає стороннього конфіга для затирання.
|
|
8
|
+
|
|
9
|
+
## [0.14.3] - 2026-07-11
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- feat(coverage): bun-native workspace підтримка — coverage через `bun test`
|
|
14
|
+
|
|
3
15
|
## [0.14.2] - 2026-07-11
|
|
4
16
|
|
|
5
17
|
### Changed
|
package/package.json
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
//
|
|
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 =
|
|
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) {
|
|
@@ -98,6 +98,22 @@ export async function measureCoveragePerFile(dir) {
|
|
|
98
98
|
const jsonResultsFile = join(lcovDir, 'test-results.json')
|
|
99
99
|
|
|
100
100
|
const { bin, configArgs } = resolveVitestRun(dir)
|
|
101
|
+
// `--coverage.exclude` на CLI ПОВНІСТЮ замінює (не мерджить) масив
|
|
102
|
+
// `test.coverage.exclude` із target-проєкту vitest.config.js — так влаштований
|
|
103
|
+
// vitest CLI-override для array-полів. Коли `configArgs` порожній, resolveVitestRun
|
|
104
|
+
// вже обрав ЛОКАЛЬНИЙ vitest target-проєкту (він сам підхопить свій vitest.config.js) —
|
|
105
|
+
// додавання CLI-флагу тут тихо затирає власні винятки проєкту (напр. тести, що
|
|
106
|
+
// структурно можуть виконуватись лише під `bun test`, а не vitest), через що колектор
|
|
107
|
+
// бачить їх як 0%-покриті й намагається згенерувати для них НОВІ тести, хоча вони вже
|
|
108
|
+
// покриті. У bundled/shim-режимі (`configArgs` непорожній) конфлікту немає — стороннього
|
|
109
|
+
// конфіга для затирання нема, а shim (`ensureVitestShim`) вже сам включає `**/*.d.ts`.
|
|
110
|
+
const coverageExcludeArgs =
|
|
111
|
+
configArgs.length === 0
|
|
112
|
+
? []
|
|
113
|
+
: [
|
|
114
|
+
// vitest 4 прибрав дефолтний exclude **/*.d.ts — без нього v8-remap падає на TS-синтаксисі декларацій
|
|
115
|
+
'--coverage.exclude=**/*.d.ts'
|
|
116
|
+
]
|
|
101
117
|
try {
|
|
102
118
|
spawnSync(
|
|
103
119
|
process.execPath,
|
|
@@ -112,8 +128,7 @@ export async function measureCoveragePerFile(dir) {
|
|
|
112
128
|
'--coverage.reporter=lcov',
|
|
113
129
|
// vitest 4 прибрав coverage.all — явний include, щоб файли без тестів лишались у lcov (0%)
|
|
114
130
|
'--coverage.include=**/*.{js,mjs,ts,vue}',
|
|
115
|
-
|
|
116
|
-
'--coverage.exclude=**/*.d.ts',
|
|
131
|
+
...coverageExcludeArgs,
|
|
117
132
|
`--coverage.reportsDirectory=${lcovDir}`,
|
|
118
133
|
'--reporter=verbose',
|
|
119
134
|
'--reporter=json',
|