@7n/test 0.14.5 → 0.15.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/CHANGELOG.md +13 -0
- package/package.json +2 -2
- package/src/coverage/js-collector.mjs +329 -37
- package/src/coverage/storybook-mutation-llm.mjs +180 -0
- package/src/coverage/storybook-mutation.mjs +361 -0
- package/src/coverage/storybook.mjs +75 -0
- package/src/coverage-per-file.mjs +1 -1
- package/src/docs/coverage-fix.md +1 -1
- package/src/docs/coverage-per-file.md +1 -1
- package/src/docs/gen-stories.md +38 -0
- package/src/docs/index.md +1 -1
- package/src/docs/run.md +1 -1
- package/src/gen-stories.mjs +355 -0
- package/src/lib/docs/index.md +7 -8
- package/src/lib/docs/llm.md +1 -1
- package/src/lib/docs/resolve-js-root.md +31 -0
- package/src/run.mjs +105 -52
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.15.0] - 2026-07-18
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- coverage: окремий рядок «Vue (Storybook)» — Storybook-покриття через vitest browser mode (@storybook/addon-vitest), незалежно від JS/mutation-виміру
|
|
8
|
+
- Mutation testing тепер мутує `.vue` SFC (`<script>`/`<script setup>`) поряд зі звичайним JS/TS — Stryker core підтримує це нативно, обмеження лише на browser-mode тести (Storybook). `run` (без `--no-mutation`) генерує Storybook CSF3 `.stories.*` для непокритих Vue-компонентів у Storybook-проєктах замість звичайних unit-тестів (`gen-stories.mjs`), з валідацією через реальний `vitest run --project=storybook`.
|
|
9
|
+
- coverage: mutation testing для Storybook-покритого коду (vitest browser mode) — власний mutate→run→restore executor у --changed-режимі: детерміновані AST-мутанти (rollup parseAst, 5 тірів операторів) по змінених production-файлах і покритих сторі рядках (lcov DA), вбиває/милує реальний browser-mode прогін зі сторі-фільтром і бюджетами (8/файл, 32/прогін); survived мутанти йдуть у наявний ланцюг classify/fix
|
|
10
|
+
- coverage: LLM-джерело мутантів для Storybook mutation executor-а (Mutahunter/Meta-ACH-патерн) — LLM пропонує context-aware bug-like мутанти (off-by-one, підмінені fallback, переплутані аргументи) поверх детермінованих, вбиває/милує так само лише реальний browser-mode прогін; жорстка валідація пропозицій (точний підрядок, покритий рядок, parseAst-синтакс-перевірка), власна стеля 3/файл, graceful degradation без API-ключа, opt-out N_7N_TEST_NO_LLM_MUTANTS=1
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- deps: оновити @7n/llm-lib ^2.0.2 → ^2.7.6 — виправляє помилкову класифікацію unresolved-model викликів (порожній modelSpec) як cloud у chain-телеметрії; тепер фактично резолвлена pi-модель (напр. локальний omlx) прокидається в chain.note() коректно, окремий бакет unknownCalls замість неявного cloud
|
|
15
|
+
|
|
3
16
|
## [0.14.5] - 2026-07-12
|
|
4
17
|
|
|
5
18
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7n/test",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "CLI-утиліта @7n/test",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"7n",
|
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
"test": "vitest run"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
+
"@7n/llm-lib": "^2.7.6",
|
|
42
43
|
"@earendil-works/pi-coding-agent": "^0.80.3",
|
|
43
44
|
"@nitra/check-env": "^4.2.1",
|
|
44
|
-
"@7n/llm-lib": "^2.0.2",
|
|
45
45
|
"@vitest/coverage-v8": "^4.1.9",
|
|
46
46
|
"rollup": "^4.62.2",
|
|
47
47
|
"vitest": "^4.1.9",
|
|
@@ -15,13 +15,24 @@ import { dirname, isAbsolute, join, relative } from 'node:path'
|
|
|
15
15
|
import { resolveAllJsRoots } from '../lib/resolve-js-root.mjs'
|
|
16
16
|
import { addCoverage, addMutation } from './aggregate.mjs'
|
|
17
17
|
import { hasRunnableTests, isBunNativeRoot } from './bun-native.mjs'
|
|
18
|
+
import { STORIES_FILE_RE, hasStories, isStorybookRoot } from './storybook.mjs'
|
|
19
|
+
import { parseLcovCoveredLines, runStorybookMutation } from './storybook-mutation.mjs'
|
|
20
|
+
import { proposeLlmMutants } from './storybook-mutation-llm.mjs'
|
|
18
21
|
|
|
19
22
|
const TEST_BLOCK_START = /^\s*(it|test)\(/
|
|
20
23
|
const FILE_EXTENSION = /\.[^.]+$/
|
|
21
|
-
/**
|
|
22
|
-
|
|
24
|
+
/**
|
|
25
|
+
* JS/TS/Vue-розширення — файли, які мутує Stryker і покриває vitest. `.vue` включено:
|
|
26
|
+
* Stryker core мутує `<script>`/`<script setup>` блок SFC без окремого плагіна (з версії 7+).
|
|
27
|
+
* Мутувати можна лише те, що покрите НЕ-browser-mode тестами (`@vue/test-utils`+happy-dom
|
|
28
|
+
* тощо), НЕ Storybook-сторі (`@storybook/addon-vitest`, browser mode) — детальніше про чому
|
|
29
|
+
* див. коментар над `collectStorybookForRoot` нижче.
|
|
30
|
+
*/
|
|
31
|
+
const JS_FILE = /\.(c|m)?[jt]sx?$|\.vue$/
|
|
23
32
|
/** Тест-файли (`*.test.*` / `*.spec.*`) — НЕ production-код, не йдуть у Stryker `--mutate`. */
|
|
24
33
|
const TEST_FILE = /\.(test|spec)\.[^.]+$/
|
|
34
|
+
/** `.vue`-компоненти + `*.stories.*` — сигнал для Storybook-змінного scope (line coverage). */
|
|
35
|
+
const VUE_OR_STORIES_FILE = /\.vue$|\.stories\.[^.]+$/
|
|
25
36
|
|
|
26
37
|
/**
|
|
27
38
|
* Звужує список змінених файлів (relative до cwd) до тих, що лежать під `jsRoot`,
|
|
@@ -42,6 +53,27 @@ export function scopeToRoot(changedFiles, cwd, jsRoot) {
|
|
|
42
53
|
return out
|
|
43
54
|
}
|
|
44
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Звужує список змінених файлів до тих, що стосуються Storybook-покриття
|
|
58
|
+
* (`.vue`-компоненти + `*.stories.*`) під `jsRoot`, рібейзить відносно `jsRoot`.
|
|
59
|
+
* Окремий від `scopeToRoot`: `.vue`/`*.stories.*` НЕ йдуть у Stryker `--mutate`
|
|
60
|
+
* (JS-мутація для Vue поза скоупом), тож не змішуємо scope-и.
|
|
61
|
+
* @param {string[]} changedFiles relative-до-cwd шляхи змінених файлів
|
|
62
|
+
* @param {string} cwd корінь проєкту
|
|
63
|
+
* @param {string} jsRoot абсолютний шлях workspace-кореня
|
|
64
|
+
* @returns {string[]} `.vue`/`.stories.*`-файли під jsRoot, шляхи relative до jsRoot
|
|
65
|
+
*/
|
|
66
|
+
export function scopeToStorybookRoot(changedFiles, cwd, jsRoot) {
|
|
67
|
+
const out = []
|
|
68
|
+
for (const f of changedFiles) {
|
|
69
|
+
if (!VUE_OR_STORIES_FILE.test(f)) continue
|
|
70
|
+
const rel = relative(jsRoot, join(cwd, f))
|
|
71
|
+
if (rel.startsWith('..') || isAbsolute(rel)) continue
|
|
72
|
+
out.push(rel)
|
|
73
|
+
}
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
45
77
|
const VITEST_HINT =
|
|
46
78
|
'js coverage: vitest відсутній у package.json — додай `vitest`, `@vitest/coverage-v8` та `@stryker-mutator/vitest-runner` у devDependencies (див. test.mdc)'
|
|
47
79
|
|
|
@@ -262,11 +294,18 @@ function resolveLocalStrykerBin() {
|
|
|
262
294
|
}
|
|
263
295
|
|
|
264
296
|
const defaultRunner = {
|
|
265
|
-
runJsCoverage({ cwd, lcovDir, base }) {
|
|
297
|
+
runJsCoverage({ cwd, lcovDir, base, excludeStorybookProject }) {
|
|
266
298
|
// base !== undefined ⇔ --changed-режим: vitest сам рахує зачеплені змінами тести
|
|
267
299
|
// через граф імпортів. `--changed <base>` порівнює base↔робоче дерево (committed і
|
|
268
300
|
// uncommitted разом); `--changed` без аргументу — uncommitted vs HEAD.
|
|
269
301
|
const changedArgs = base === undefined ? [] : base === null ? ['--changed'] : ['--changed', base]
|
|
302
|
+
// excludeStorybookProject: коли root — Storybook-workspace, named vitest-проєкт
|
|
303
|
+
// "storybook" (browser mode, Playwright) типово живе у ТОМУ Ж vitest.config.mjs, що
|
|
304
|
+
// й звичайний JS-suite (canonical setup @storybook/addon-vitest — projects: [...]).
|
|
305
|
+
// Без --project=!storybook цей прогін спробував би виконати і browser-mode тести теж —
|
|
306
|
+
// зайве дублювання з collectStorybookForRoot і ризик втягнути Playwright-залежність
|
|
307
|
+
// у звичайний coverage-прогін.
|
|
308
|
+
const projectArgs = excludeStorybookProject ? ['--project=!storybook'] : []
|
|
270
309
|
const r = spawnSync(
|
|
271
310
|
'bunx',
|
|
272
311
|
[
|
|
@@ -276,6 +315,7 @@ const defaultRunner = {
|
|
|
276
315
|
'--coverage',
|
|
277
316
|
'--coverage.reporter=lcov',
|
|
278
317
|
`--coverage.reportsDirectory=${lcovDir}`,
|
|
318
|
+
...projectArgs,
|
|
279
319
|
...changedArgs
|
|
280
320
|
],
|
|
281
321
|
{ cwd, stdio: 'inherit', env: process.env }
|
|
@@ -301,6 +341,57 @@ const defaultRunner = {
|
|
|
301
341
|
)
|
|
302
342
|
return r.status ?? 1
|
|
303
343
|
},
|
|
344
|
+
runStorybookCoverage({ cwd, lcovDir, base }) {
|
|
345
|
+
// Coverage сторі рахує сам Storybook-vitest-addon (browser mode, Playwright Chromium)
|
|
346
|
+
// через named vitest-проєкт "storybook" (канонічний vitest.config для Vue-проєктів,
|
|
347
|
+
// див. npm/docs) — той самий контракт lcov, що й у звичайного vitest run --coverage.
|
|
348
|
+
const changedArgs = base === undefined ? [] : base === null ? ['--changed'] : ['--changed', base]
|
|
349
|
+
const r = spawnSync(
|
|
350
|
+
'bunx',
|
|
351
|
+
[
|
|
352
|
+
'vitest',
|
|
353
|
+
'run',
|
|
354
|
+
'--project=storybook',
|
|
355
|
+
'--passWithNoTests',
|
|
356
|
+
'--coverage',
|
|
357
|
+
'--coverage.reporter=lcov',
|
|
358
|
+
`--coverage.reportsDirectory=${lcovDir}`,
|
|
359
|
+
...changedArgs
|
|
360
|
+
],
|
|
361
|
+
{ cwd, stdio: 'inherit', env: process.env }
|
|
362
|
+
)
|
|
363
|
+
return r.status ?? 1
|
|
364
|
+
},
|
|
365
|
+
runStorybookMutantTest({ cwd, storyFilter, timeoutMs }) {
|
|
366
|
+
// Один прогін проти мутованого дерева (mutate→run→restore цикл у
|
|
367
|
+
// storybook-mutation.mjs). storyFilter (сторі-файл компонента) звужує suite до
|
|
368
|
+
// релевантних тестів — browser-mode прогін коштує секунди, повний suite на кожен
|
|
369
|
+
// мутант марнотратний. stdio ignore: важливий лише exit code; вивід мутант-прогонів
|
|
370
|
+
// лише шумів би (десятки прогонів поспіль). null (kill за таймаутом) → Timeout.
|
|
371
|
+
const r = spawnSync('bunx', ['vitest', 'run', '--project=storybook', ...(storyFilter ? [storyFilter] : [])], {
|
|
372
|
+
cwd,
|
|
373
|
+
stdio: 'ignore',
|
|
374
|
+
env: process.env,
|
|
375
|
+
timeout: timeoutMs
|
|
376
|
+
})
|
|
377
|
+
if (r.signal) return null // убитий таймаутом
|
|
378
|
+
return r.status ?? 1
|
|
379
|
+
},
|
|
380
|
+
async proposeStorybookLlmMutants({ file, source, coveredLines, cwd }) {
|
|
381
|
+
// Друге джерело мутантів (Mutahunter/ACH-патерн): LLM пропонує bug-like мутанти,
|
|
382
|
+
// судить так само лише реальний прогін. Graceful degradation: будь-яка помилка
|
|
383
|
+
// (нема API-ключа, мережа) → [] з одноразовим попередженням — детерміновані
|
|
384
|
+
// мутанти працюють далі. Opt-out: N_7N_TEST_NO_LLM_MUTANTS=1.
|
|
385
|
+
try {
|
|
386
|
+
return await proposeLlmMutants({ file, source, coveredLines, cwd })
|
|
387
|
+
} catch (error) {
|
|
388
|
+
if (!defaultRunner._llmMutantsWarned) {
|
|
389
|
+
console.error(`⚠ LLM-мутанти недоступні (${String(error.message ?? error).slice(0, 120)}) — лише детерміновані`)
|
|
390
|
+
defaultRunner._llmMutantsWarned = true
|
|
391
|
+
}
|
|
392
|
+
return []
|
|
393
|
+
}
|
|
394
|
+
},
|
|
304
395
|
runStryker({ cwd, mutate }) {
|
|
305
396
|
// Plugin-discovery Stryker (`@stryker-mutator/*`) globиться відносно CORE-install-каталогу
|
|
306
397
|
// (`core/dist/src/di/plugin-loader.js` → `../../../../../@stryker-mutator/*`). Тож core
|
|
@@ -335,6 +426,13 @@ const defaultRunner = {
|
|
|
335
426
|
* `bun test --coverage` (vitest не резолвить модуль `bun`), mutation пропускається
|
|
336
427
|
* з попередженням — Stryker vitest-runner такий код не виконає.
|
|
337
428
|
*
|
|
429
|
+
* `.vue`-мутація: Stryker core мутує `<script>`/`<script setup>` SFC без окремого плагіна.
|
|
430
|
+
* Storybook root (`isStorybookRoot`) → `runJsCoverage` отримує `excludeStorybookProject:
|
|
431
|
+
* true` (`--project=!storybook`), щоб не зачепити browser-mode проєкт (докладніше про стан
|
|
432
|
+
* підтримки browser mode у Stryker — коментар над `collectStorybookForRoot` нижче) —
|
|
433
|
+
* інакше цей самий JS-прогін спробував би й browser-mode тести теж, якщо вони живуть
|
|
434
|
+
* у тому ж vitest.config.mjs.
|
|
435
|
+
*
|
|
338
436
|
* Реальні помилки (vitest/bun exit ≠ 0, відсутній mutation.json попри запуск Stryker)
|
|
339
437
|
* кидаються — у multi-root режимі це не маскує справжній збій.
|
|
340
438
|
* @param {string} jsRoot абсолютний шлях до workspace-кореня
|
|
@@ -345,12 +443,18 @@ const defaultRunner = {
|
|
|
345
443
|
*/
|
|
346
444
|
async function collectOneRoot(jsRoot, cwd, runner, scope = null) {
|
|
347
445
|
const wsRel = relative(cwd, jsRoot)
|
|
348
|
-
// У changed-режимі production-файли для мутації = змінені JS цього root без
|
|
349
|
-
|
|
446
|
+
// У changed-режимі production-файли для мутації = змінені JS/Vue цього root без
|
|
447
|
+
// тест-файлів і без *.stories.* (сторі — не production-код, окремий Storybook-вимір).
|
|
448
|
+
// `.vue` тут ЗАЛИШАЄТЬСЯ — Stryker core мутує <script>/<script setup> SFC (з v7+),
|
|
449
|
+
// допоки покриття дають НЕ-browser-mode тести (@vue/test-utils тощо, не Storybook-сторі).
|
|
450
|
+
const mutateSrc = scope ? scope.files.filter(f => !TEST_FILE.test(f) && !STORIES_FILE_RE.test(f)) : null
|
|
350
451
|
|
|
351
452
|
// Bun-native workspace: coverage через `bun test`, mutation пропускається
|
|
352
453
|
// (Stryker vitest-runner не виконає код з `import ... from 'bun'`).
|
|
353
454
|
const bunNative = await isBunNativeRoot(jsRoot)
|
|
455
|
+
// Storybook root: named vitest-проєкт "storybook" (browser mode) типово ділить один
|
|
456
|
+
// vitest.config.mjs зі звичайним suite — виключаємо його з JS-прогону (див. runJsCoverage).
|
|
457
|
+
const excludeStorybookProject = !bunNative && (await isStorybookRoot(jsRoot))
|
|
354
458
|
if (bunNative && !(await hasRunnableTests(jsRoot))) {
|
|
355
459
|
// `bun test` без тестів завершується помилкою — graceful skip як vitest --passWithNoTests.
|
|
356
460
|
return scope
|
|
@@ -368,7 +472,11 @@ async function collectOneRoot(jsRoot, cwd, runner, scope = null) {
|
|
|
368
472
|
try {
|
|
369
473
|
const code = bunNative
|
|
370
474
|
? await runner.runBunCoverage({ cwd: jsRoot, lcovDir })
|
|
371
|
-
: await runner.runJsCoverage(
|
|
475
|
+
: await runner.runJsCoverage(
|
|
476
|
+
scope
|
|
477
|
+
? { cwd: jsRoot, lcovDir, base: scope.base, excludeStorybookProject }
|
|
478
|
+
: { cwd: jsRoot, lcovDir, excludeStorybookProject }
|
|
479
|
+
)
|
|
372
480
|
if (code !== 0) throw new Error(`JS coverage exit ${code}`)
|
|
373
481
|
const lcovPath = join(lcovDir, 'lcov.info')
|
|
374
482
|
coverage = existsSync(lcovPath)
|
|
@@ -404,10 +512,23 @@ async function collectOneRoot(jsRoot, cwd, runner, scope = null) {
|
|
|
404
512
|
await runner.runStryker(scope ? { cwd: jsRoot, mutate: mutateSrc } : { cwd: jsRoot })
|
|
405
513
|
const mutationPath = join(jsRoot, 'reports', 'stryker', 'mutation.json')
|
|
406
514
|
if (!existsSync(mutationPath)) {
|
|
515
|
+
// Stryker vitest-runner не підтримує сучасний (Playwright-based) vitest browser mode
|
|
516
|
+
// (докладніше — коментар над collectStorybookForRoot): якщо стрикер-фейсінг vitest.config.mjs
|
|
517
|
+
// (на який вказує stryker.config.mjs#vitest.configFile) містить named-проєкт "storybook",
|
|
518
|
+
// Stryker намагається виконати і його — і падає без mutation.json. Виправлення на боці
|
|
519
|
+
// target-проєкту: винести Storybook-проєкт в окремий vitest-конфіг, якого Stryker НЕ бачить
|
|
520
|
+
// (не reused той самий configFile).
|
|
521
|
+
const storybookHint = excludeStorybookProject
|
|
522
|
+
? ' Root має Storybook (.storybook/ + @storybook/addon-vitest) — якщо vitest.config.mjs, ' +
|
|
523
|
+
'на який вказує stryker.config.mjs#vitest.configFile, містить named-проєкт "storybook" ' +
|
|
524
|
+
'(browser mode), Stryker впаде на ньому (browser mode не підтримується vitest-runner) — ' +
|
|
525
|
+
'винеси Storybook-проєкт в окремий vitest-конфіг.'
|
|
526
|
+
: ''
|
|
407
527
|
throw new Error(
|
|
408
528
|
'js coverage: stryker не залишив mutation.json — ' +
|
|
409
529
|
'переконайся що встановлено canonical stryker.config.mjs (vitest-runner, perTest), ' +
|
|
410
|
-
'або налаштуй його
|
|
530
|
+
'або налаштуй його вручну.' +
|
|
531
|
+
storybookHint
|
|
411
532
|
)
|
|
412
533
|
}
|
|
413
534
|
const mutationReport = JSON.parse(await readFile(mutationPath, 'utf8'))
|
|
@@ -430,21 +551,183 @@ async function collectOneRoot(jsRoot, cwd, runner, scope = null) {
|
|
|
430
551
|
}
|
|
431
552
|
|
|
432
553
|
/**
|
|
433
|
-
* Збирає
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
554
|
+
* Збирає Storybook-покриття (Vue/React/... компоненти зі сторі) для **одного** JS-root.
|
|
555
|
+
* Активується лише коли `isStorybookRoot` (тека `.storybook/` + `@storybook/addon-vitest`
|
|
556
|
+
* у deps) і `hasStories` — інакше `null` (root не бере участі у рядку `Vue (Storybook)`).
|
|
557
|
+
*
|
|
558
|
+
* Mutation testing: у **changed-режимі** виконується власним mutate→run→restore
|
|
559
|
+
* executor-ом (`storybook-mutation.mjs`) — детерміновані AST-мутанти по змінених
|
|
560
|
+
* production-файлах, вбиває/милує реальний browser-mode прогін. У **full-режимі**
|
|
561
|
+
* пропускається з попередженням (повний suite × кожен мутант × Chromium — надто
|
|
562
|
+
* дорого; той самий принцип чесного skip, що й bun-native).
|
|
563
|
+
*
|
|
564
|
+
* **Чому не Stryker (перевіряй перед покладанням на це в майбутньому — площина
|
|
565
|
+
* активно змінюється з обох боків):** issue stryker-js#4557 ("[vitest] support browser
|
|
566
|
+
* mode") ЗАКРИТИЙ через PR stryker-js#4628 ще у 2023 (v8.0.0) — але це стосувалось
|
|
567
|
+
* раннього browser mode доби vitest@1.0.0-beta, ДО сучасної provider-based архітектури
|
|
568
|
+
* (`@vitest/browser-playwright` тощо, стабілізованої у Vitest 4, 2025-12). Той старий фікс
|
|
569
|
+
* НЕ покриває сучасний Playwright-based browser mode, яким користується
|
|
570
|
+
* `@storybook/addon-vitest`: чинна документація Stryker vitest-runner прямо каже
|
|
571
|
+
* "Currently, Browser Mode is not supported" — інструментація Stryker передбачає
|
|
572
|
+
* Node.js-виконання, а сучасний browser mode виконує тести у реальному Chromium через
|
|
573
|
+
* Playwright, що структурно несумісно з тим, як Stryker патчить/спостерігає код.
|
|
574
|
+
* Власний executor слідує патерну, підтвердженому дослідженням спільноти
|
|
575
|
+
* (https://alexop.dev/posts/mutation-testing-ai-agents-vitest-browser-mode/, Mutahunter,
|
|
576
|
+
* Meta ACH): детермінована генерація мутантів + реальний прогін як суддя — без LLM
|
|
577
|
+
* у контурі виконання.
|
|
578
|
+
*
|
|
579
|
+
* **Валідована альтернатива (спайк 2026-07):** Stryker **command runner** (не
|
|
580
|
+
* vitest-runner) + browser mode ПРАЦЮЄ — але лише з обов'язковим
|
|
581
|
+
* `define: { 'process.env.__STRYKER_ACTIVE_MUTANT__': JSON.stringify(process.env.__STRYKER_ACTIVE_MUTANT__ ?? '') }`
|
|
582
|
+
* у vite-конфізі browser-проєкту (vitest browser mode ставить define як runtime-глобал
|
|
583
|
+
* у Chromium — саме той шлях, що читає інструментація Stryker). Без define — тихий
|
|
584
|
+
* провал: 0% killed, усі мутанти "виживають" (контрольний експеримент підтвердив).
|
|
585
|
+
* ~1.2s/мутант, sandbox без проблем (symlinked node_modules). Не прийнято зараз:
|
|
586
|
+
* потребує змін конфігів target-проєкту (define + stryker command-config) і має
|
|
587
|
+
* silent-0% failure mode, якщо Storybook-ів config merge загубить define; власний
|
|
588
|
+
* executor мутує файл на диску — активація не залежить від env-пропагації взагалі.
|
|
589
|
+
* Кандидат на майбутнє для full-режиму (richer operator set + incremental mode).
|
|
590
|
+
*
|
|
591
|
+
* Changed-режим: запускається тільки якщо серед змінених файлів root-а є хоча б
|
|
592
|
+
* один `.vue`/`*.stories.*` (`scope.files` — вже звужений через `scopeToStorybookRoot`
|
|
593
|
+
* на боці виклику); інакше `null` (root пропускається повністю для цього виміру).
|
|
594
|
+
* @param {string} jsRoot абсолютний шлях workspace-кореня
|
|
595
|
+
* @param {string} cwd корінь проєкту (для рібейзингу `survived[].file`)
|
|
596
|
+
* @param {{runStorybookCoverage:Function, runStorybookMutantTest?:Function, proposeStorybookLlmMutants?:Function}} runner spawn-ін'єкція
|
|
597
|
+
* @param {{files:string[], base:string|null}|null} [scope] changed-scope (null = full-режим)
|
|
598
|
+
* @returns {Promise<{coverage:object, mutation:{caught:number,total:number}, survived:Array<object>} | null>} результат або null коли root не Storybook/без сторі/без relevant-змін
|
|
599
|
+
*/
|
|
600
|
+
async function collectStorybookForRoot(jsRoot, cwd, runner, scope = null) {
|
|
601
|
+
const wsRel = relative(cwd, jsRoot)
|
|
602
|
+
if (!(await isStorybookRoot(jsRoot))) return null
|
|
603
|
+
if (!(await hasStories(jsRoot))) return null
|
|
604
|
+
|
|
605
|
+
const lcovDir = await mkdtemp(join(tmpdir(), 'sb-cov-'))
|
|
606
|
+
let coverage
|
|
607
|
+
let coveredLines = new Map()
|
|
608
|
+
let baselineMs = 0
|
|
609
|
+
try {
|
|
610
|
+
const startedAt = Date.now()
|
|
611
|
+
const code = await runner.runStorybookCoverage(
|
|
612
|
+
scope ? { cwd: jsRoot, lcovDir, base: scope.base } : { cwd: jsRoot, lcovDir }
|
|
613
|
+
)
|
|
614
|
+
baselineMs = Date.now() - startedAt
|
|
615
|
+
if (code !== 0) {
|
|
616
|
+
throw new Error(
|
|
617
|
+
`Storybook coverage exit ${code} — перевір встановлений Playwright Chromium ` +
|
|
618
|
+
'(`npx playwright install chromium`) і named vitest-проєкт "storybook" (canonical config)'
|
|
619
|
+
)
|
|
620
|
+
}
|
|
621
|
+
const lcovPath = join(lcovDir, 'lcov.info')
|
|
622
|
+
if (existsSync(lcovPath)) {
|
|
623
|
+
const lcovText = await readFile(lcovPath, 'utf8')
|
|
624
|
+
coverage = parseLcov(lcovText)
|
|
625
|
+
coveredLines = parseLcovCoveredLines(lcovText, jsRoot)
|
|
626
|
+
} else {
|
|
627
|
+
coverage = { lines: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 } }
|
|
628
|
+
}
|
|
629
|
+
} finally {
|
|
630
|
+
await rm(lcovDir, { recursive: true, force: true })
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Full-режим: mutation чесно пропускається (див. JSDoc). Guard на runStorybookMutantTest
|
|
634
|
+
// тримає сумісність із інжектованими runner-ами без mutation-підтримки.
|
|
635
|
+
if (!scope || typeof runner.runStorybookMutantTest !== 'function') {
|
|
636
|
+
console.error(
|
|
637
|
+
`⚠ ${wsRel || '.'}: Storybook (Vue) — mutation testing пропущено ` +
|
|
638
|
+
'(у full-режимі надто дорого: повний browser-mode suite на кожен мутант), лише line coverage'
|
|
639
|
+
)
|
|
640
|
+
return { coverage, mutation: { caught: 0, total: 0 }, survived: [] }
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Changed-режим: мутуємо лише змінені production-файли (без сторі/тестів) із покриттям.
|
|
644
|
+
// proposeStorybookLlmMutants (опційний у runner) — друге, LLM-джерело мутантів поверх
|
|
645
|
+
// детермінованих; інжектовані runner-и без нього отримують лише детерміновані.
|
|
646
|
+
const mutationTargets = scope.files.filter(f => !STORIES_FILE_RE.test(f) && !TEST_FILE.test(f))
|
|
647
|
+
const proposeExtra =
|
|
648
|
+
typeof runner.proposeStorybookLlmMutants === 'function'
|
|
649
|
+
? (file, source, lines) => runner.proposeStorybookLlmMutants({ file, source, coveredLines: lines, cwd: jsRoot })
|
|
650
|
+
: null
|
|
651
|
+
const { caught, total, survived } = await runStorybookMutation({
|
|
652
|
+
jsRoot,
|
|
653
|
+
files: mutationTargets,
|
|
654
|
+
coveredLines,
|
|
655
|
+
runMutantTest: args => runner.runStorybookMutantTest(args),
|
|
656
|
+
resolveStoryFilter: file => findStoryFilter(jsRoot, file),
|
|
657
|
+
proposeExtraMutants: proposeExtra,
|
|
658
|
+
// Мутант-прогін звужено сторі-фільтром і без coverage — baseline (повний coverage-прогін)
|
|
659
|
+
// з запасом 3× покриває навіть повний suite; мінімум страхує холодний старт Chromium.
|
|
660
|
+
timeoutMs: Math.max(30_000, 3 * baselineMs)
|
|
661
|
+
})
|
|
662
|
+
if (total > 0) {
|
|
663
|
+
console.log(`✓ ${wsRel || '.'}: Storybook mutation — ${caught}/${total} вбито (changed-scope)`)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return {
|
|
667
|
+
coverage,
|
|
668
|
+
mutation: { caught, total },
|
|
669
|
+
survived: survived.map(group => ({
|
|
670
|
+
...group,
|
|
671
|
+
file: wsRel === '' ? group.file : join(wsRel, group.file)
|
|
672
|
+
}))
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const STORY_EXTENSIONS = ['js', 'mjs', 'ts', 'jsx', 'tsx']
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Шукає сторі-файл компонента поряд із ним (`Card.vue` → `Card.stories.{js,ts,...}`)
|
|
680
|
+
* для звуження мутант-прогону до релевантних тестів.
|
|
681
|
+
* @param {string} jsRoot абсолютний шлях workspace-кореня
|
|
682
|
+
* @param {string} file відносний шлях компонента
|
|
683
|
+
* @returns {string | null} відносний шлях сторі-файлу або null (прогін без фільтра)
|
|
684
|
+
*/
|
|
685
|
+
function findStoryFilter(jsRoot, file) {
|
|
686
|
+
const base = file.replace(FILE_EXTENSION, '')
|
|
687
|
+
for (const ext of STORY_EXTENSIONS) {
|
|
688
|
+
const candidate = `${base}.stories.${ext}`
|
|
689
|
+
if (existsSync(join(jsRoot, candidate))) return candidate
|
|
690
|
+
}
|
|
691
|
+
return null
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Будує підсумковий рядок з масиву per-root результатів через сумування coverage/mutation.
|
|
696
|
+
* @param {string} area назва рядка (`JS`, `Vue (Storybook)`)
|
|
697
|
+
* @param {Array<{coverage:object, mutation:{caught:number,total:number}, survived:Array<object>}>} results per-root результати
|
|
698
|
+
* @returns {{area:string, coverage:object, mutation:{caught:number,total:number}, survived:Array<object>}} агрегований рядок
|
|
699
|
+
*/
|
|
700
|
+
function buildAreaRow(area, results) {
|
|
701
|
+
let coverage = { lines: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 } }
|
|
702
|
+
let mutation = { caught: 0, total: 0 }
|
|
703
|
+
const survived = []
|
|
704
|
+
for (const r of results) {
|
|
705
|
+
coverage = addCoverage(coverage, r.coverage)
|
|
706
|
+
mutation = addMutation(mutation, r.mutation)
|
|
707
|
+
survived.push(...r.survived)
|
|
708
|
+
}
|
|
709
|
+
return { area, coverage, mutation, survived }
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Збирає JS-метрики покриття + мутаційного тестування, і окремо — Storybook-покриття
|
|
714
|
+
* (Vue/React/... компоненти зі сторі, `collectStorybookForRoot`). У monorepo ітерує усі
|
|
715
|
+
* JS-roots з `resolveAllJsRoots()` (включно з glob-патернами `cf/*`), для кожного root-а
|
|
716
|
+
* запускає обидва виміри незалежно й сумує lcov/mutation окремо через `buildAreaRow`.
|
|
717
|
+
* Workspaces без тестів (JS) або без Storybook-конфігурації/сторі пропускаються по
|
|
718
|
+
* кожному виміру окремо (root може дати лише JS-рядок, лише Storybook-рядок, обидва
|
|
719
|
+
* або жодного). Якщо і JS, і Storybook відсутні всюди — повертає `[]`.
|
|
438
720
|
* Шляхи у `survived` рібейзяться відносно `cwd`, щоб `coverage-fix.mjs`
|
|
439
721
|
* знаходив джерела через `join(projectRoot, file)`.
|
|
440
722
|
*
|
|
441
|
-
* Changed-режим (`opts.changedFiles` задано):
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
723
|
+
* Changed-режим (`opts.changedFiles` задано): JS-вимір отримує лише змінені JS-файли
|
|
724
|
+
* root-а (`scopeToRoot`), Storybook-вимір — лише змінені `.vue`/`*.stories.*`
|
|
725
|
+
* (`scopeToStorybookRoot`); кожен вимір пропускається незалежно, якщо relevant-змін
|
|
726
|
+
* нема. Якщо змін нема ніде — повертає `[]` без error-логу (оркестратор трактує
|
|
727
|
+
* порожній changed-scope як pass).
|
|
445
728
|
* @param {string} cwd корінь проєкту
|
|
446
729
|
* @param {{runner?: typeof defaultRunner, changedFiles?: string[], base?: string|null}} [opts] runner-ін'єкція + changed-scope
|
|
447
|
-
* @returns {Promise<Array<{area:string, coverage:object, mutation:{caught:number,total:number}, survived:Array<object>}>>}
|
|
730
|
+
* @returns {Promise<Array<{area:string, coverage:object, mutation:{caught:number,total:number}, survived:Array<object>}>>} рядки `JS`/`Vue (Storybook)` — лише ті, де є дані
|
|
448
731
|
*/
|
|
449
732
|
export async function collect(cwd, opts = {}) {
|
|
450
733
|
const runner = opts.runner ?? defaultRunner
|
|
@@ -452,36 +735,45 @@ export async function collect(cwd, opts = {}) {
|
|
|
452
735
|
const jsRoots = await resolveAllJsRoots(cwd)
|
|
453
736
|
if (jsRoots.length === 0) throw new Error('js coverage: package.json не знайдено')
|
|
454
737
|
|
|
455
|
-
const
|
|
738
|
+
const jsResults = []
|
|
739
|
+
const storybookResults = []
|
|
456
740
|
for (const jsRoot of jsRoots) {
|
|
457
|
-
let scope = null
|
|
458
741
|
if (changed) {
|
|
459
|
-
const
|
|
460
|
-
if (
|
|
461
|
-
|
|
742
|
+
const jsFiles = scopeToRoot(opts.changedFiles, cwd, jsRoot)
|
|
743
|
+
if (jsFiles.length > 0) {
|
|
744
|
+
const scope = { files: jsFiles, base: opts.base ?? null }
|
|
745
|
+
const r = await collectOneRoot(jsRoot, cwd, runner, scope)
|
|
746
|
+
if (r !== null) jsResults.push(r)
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const sbFiles = scopeToStorybookRoot(opts.changedFiles, cwd, jsRoot)
|
|
750
|
+
if (sbFiles.length > 0) {
|
|
751
|
+
const sbScope = { files: sbFiles, base: opts.base ?? null }
|
|
752
|
+
const sb = await collectStorybookForRoot(jsRoot, cwd, runner, sbScope)
|
|
753
|
+
if (sb !== null) storybookResults.push(sb)
|
|
754
|
+
}
|
|
755
|
+
continue
|
|
462
756
|
}
|
|
463
|
-
|
|
464
|
-
|
|
757
|
+
|
|
758
|
+
const r = await collectOneRoot(jsRoot, cwd, runner, null)
|
|
759
|
+
if (r !== null) jsResults.push(r)
|
|
760
|
+
|
|
761
|
+
const sb = await collectStorybookForRoot(jsRoot, cwd, runner, null)
|
|
762
|
+
if (sb !== null) storybookResults.push(sb)
|
|
465
763
|
}
|
|
466
764
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
765
|
+
const rows = []
|
|
766
|
+
if (jsResults.length > 0) {
|
|
767
|
+
rows.push(buildAreaRow('JS', jsResults))
|
|
768
|
+
} else if (!changed) {
|
|
470
769
|
console.error(
|
|
471
770
|
'js coverage: жоден workspace не має тестів ' +
|
|
472
771
|
'(`*.test.{js,mjs}` у `tests/` або поряд із джерелом) — ' +
|
|
473
772
|
'додай тести або запусти `npx @7n/test` для генерації'
|
|
474
773
|
)
|
|
475
|
-
return []
|
|
476
774
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
let mutation = { caught: 0, total: 0 }
|
|
480
|
-
const survived = []
|
|
481
|
-
for (const r of results) {
|
|
482
|
-
coverage = addCoverage(coverage, r.coverage)
|
|
483
|
-
mutation = addMutation(mutation, r.mutation)
|
|
484
|
-
survived.push(...r.survived)
|
|
775
|
+
if (storybookResults.length > 0) {
|
|
776
|
+
rows.push(buildAreaRow('Vue (Storybook)', storybookResults))
|
|
485
777
|
}
|
|
486
|
-
return
|
|
778
|
+
return rows
|
|
487
779
|
}
|