@7n/test 0.14.5 → 0.16.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 +19 -0
- package/package.json +2 -2
- package/src/coverage/js-collector.mjs +394 -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,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.16.0] - 2026-07-18
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- coverage: full-режим mutation testing для Storybook через Stryker command runner — canonical stryker.storybook.config.mjs (testRunner:'command', jsonReporter → reports/stryker-storybook/mutation.json), спайк-валідований define-трюк для __STRYKER_ACTIVE_MUTANT__ у browser-mode vite-конфізі; без canonical конфіга — чесний skip, як і раніше. Задокументовано контракт у npm/docs/stryker-storybook-config.md
|
|
8
|
+
|
|
9
|
+
## [0.15.0] - 2026-07-18
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- coverage: окремий рядок «Vue (Storybook)» — Storybook-покриття через vitest browser mode (@storybook/addon-vitest), незалежно від JS/mutation-виміру
|
|
14
|
+
- 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`.
|
|
15
|
+
- 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
|
|
16
|
+
- 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
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- deps: оновити @7n/llm-lib ^2.0.2 → ^2.7.6 — виправляє помилкову класифікацію unresolved-model викликів (порожній modelSpec) як cloud у chain-телеметрії; тепер фактично резолвлена pi-модель (напр. локальний omlx) прокидається в chain.note() коректно, окремий бакет unknownCalls замість неявного cloud
|
|
21
|
+
|
|
3
22
|
## [0.14.5] - 2026-07-12
|
|
4
23
|
|
|
5
24
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7n/test",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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,9 +53,35 @@ 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
|
|
|
80
|
+
/** Canonical config-файл для full-режиму Storybook-мутації через Stryker command runner. */
|
|
81
|
+
const STORYBOOK_STRYKER_CONFIG = 'stryker.storybook.config.mjs'
|
|
82
|
+
/** Шлях mutation.json того ж прогону (окремий від `reports/stryker/` JS-виміру). */
|
|
83
|
+
const STORYBOOK_STRYKER_REPORT = join('reports', 'stryker-storybook', 'mutation.json')
|
|
84
|
+
|
|
48
85
|
/**
|
|
49
86
|
* Чи у пакеті встановлено vitest (через dependencies або devDependencies).
|
|
50
87
|
* @param {{dependencies?: Record<string,string>, devDependencies?: Record<string,string>}} pkg package.json
|
|
@@ -262,11 +299,18 @@ function resolveLocalStrykerBin() {
|
|
|
262
299
|
}
|
|
263
300
|
|
|
264
301
|
const defaultRunner = {
|
|
265
|
-
runJsCoverage({ cwd, lcovDir, base }) {
|
|
302
|
+
runJsCoverage({ cwd, lcovDir, base, excludeStorybookProject }) {
|
|
266
303
|
// base !== undefined ⇔ --changed-режим: vitest сам рахує зачеплені змінами тести
|
|
267
304
|
// через граф імпортів. `--changed <base>` порівнює base↔робоче дерево (committed і
|
|
268
305
|
// uncommitted разом); `--changed` без аргументу — uncommitted vs HEAD.
|
|
269
306
|
const changedArgs = base === undefined ? [] : base === null ? ['--changed'] : ['--changed', base]
|
|
307
|
+
// excludeStorybookProject: коли root — Storybook-workspace, named vitest-проєкт
|
|
308
|
+
// "storybook" (browser mode, Playwright) типово живе у ТОМУ Ж vitest.config.mjs, що
|
|
309
|
+
// й звичайний JS-suite (canonical setup @storybook/addon-vitest — projects: [...]).
|
|
310
|
+
// Без --project=!storybook цей прогін спробував би виконати і browser-mode тести теж —
|
|
311
|
+
// зайве дублювання з collectStorybookForRoot і ризик втягнути Playwright-залежність
|
|
312
|
+
// у звичайний coverage-прогін.
|
|
313
|
+
const projectArgs = excludeStorybookProject ? ['--project=!storybook'] : []
|
|
270
314
|
const r = spawnSync(
|
|
271
315
|
'bunx',
|
|
272
316
|
[
|
|
@@ -276,6 +320,7 @@ const defaultRunner = {
|
|
|
276
320
|
'--coverage',
|
|
277
321
|
'--coverage.reporter=lcov',
|
|
278
322
|
`--coverage.reportsDirectory=${lcovDir}`,
|
|
323
|
+
...projectArgs,
|
|
279
324
|
...changedArgs
|
|
280
325
|
],
|
|
281
326
|
{ cwd, stdio: 'inherit', env: process.env }
|
|
@@ -301,6 +346,57 @@ const defaultRunner = {
|
|
|
301
346
|
)
|
|
302
347
|
return r.status ?? 1
|
|
303
348
|
},
|
|
349
|
+
runStorybookCoverage({ cwd, lcovDir, base }) {
|
|
350
|
+
// Coverage сторі рахує сам Storybook-vitest-addon (browser mode, Playwright Chromium)
|
|
351
|
+
// через named vitest-проєкт "storybook" (канонічний vitest.config для Vue-проєктів,
|
|
352
|
+
// див. npm/docs) — той самий контракт lcov, що й у звичайного vitest run --coverage.
|
|
353
|
+
const changedArgs = base === undefined ? [] : base === null ? ['--changed'] : ['--changed', base]
|
|
354
|
+
const r = spawnSync(
|
|
355
|
+
'bunx',
|
|
356
|
+
[
|
|
357
|
+
'vitest',
|
|
358
|
+
'run',
|
|
359
|
+
'--project=storybook',
|
|
360
|
+
'--passWithNoTests',
|
|
361
|
+
'--coverage',
|
|
362
|
+
'--coverage.reporter=lcov',
|
|
363
|
+
`--coverage.reportsDirectory=${lcovDir}`,
|
|
364
|
+
...changedArgs
|
|
365
|
+
],
|
|
366
|
+
{ cwd, stdio: 'inherit', env: process.env }
|
|
367
|
+
)
|
|
368
|
+
return r.status ?? 1
|
|
369
|
+
},
|
|
370
|
+
runStorybookMutantTest({ cwd, storyFilter, timeoutMs }) {
|
|
371
|
+
// Один прогін проти мутованого дерева (mutate→run→restore цикл у
|
|
372
|
+
// storybook-mutation.mjs). storyFilter (сторі-файл компонента) звужує suite до
|
|
373
|
+
// релевантних тестів — browser-mode прогін коштує секунди, повний suite на кожен
|
|
374
|
+
// мутант марнотратний. stdio ignore: важливий лише exit code; вивід мутант-прогонів
|
|
375
|
+
// лише шумів би (десятки прогонів поспіль). null (kill за таймаутом) → Timeout.
|
|
376
|
+
const r = spawnSync('bunx', ['vitest', 'run', '--project=storybook', ...(storyFilter ? [storyFilter] : [])], {
|
|
377
|
+
cwd,
|
|
378
|
+
stdio: 'ignore',
|
|
379
|
+
env: process.env,
|
|
380
|
+
timeout: timeoutMs
|
|
381
|
+
})
|
|
382
|
+
if (r.signal) return null // убитий таймаутом
|
|
383
|
+
return r.status ?? 1
|
|
384
|
+
},
|
|
385
|
+
async proposeStorybookLlmMutants({ file, source, coveredLines, cwd }) {
|
|
386
|
+
// Друге джерело мутантів (Mutahunter/ACH-патерн): LLM пропонує bug-like мутанти,
|
|
387
|
+
// судить так само лише реальний прогін. Graceful degradation: будь-яка помилка
|
|
388
|
+
// (нема API-ключа, мережа) → [] з одноразовим попередженням — детерміновані
|
|
389
|
+
// мутанти працюють далі. Opt-out: N_7N_TEST_NO_LLM_MUTANTS=1.
|
|
390
|
+
try {
|
|
391
|
+
return await proposeLlmMutants({ file, source, coveredLines, cwd })
|
|
392
|
+
} catch (error) {
|
|
393
|
+
if (!defaultRunner._llmMutantsWarned) {
|
|
394
|
+
console.error(`⚠ LLM-мутанти недоступні (${String(error.message ?? error).slice(0, 120)}) — лише детерміновані`)
|
|
395
|
+
defaultRunner._llmMutantsWarned = true
|
|
396
|
+
}
|
|
397
|
+
return []
|
|
398
|
+
}
|
|
399
|
+
},
|
|
304
400
|
runStryker({ cwd, mutate }) {
|
|
305
401
|
// Plugin-discovery Stryker (`@stryker-mutator/*`) globиться відносно CORE-install-каталогу
|
|
306
402
|
// (`core/dist/src/di/plugin-loader.js` → `../../../../../@stryker-mutator/*`). Тож core
|
|
@@ -315,6 +411,21 @@ const defaultRunner = {
|
|
|
315
411
|
? spawnSync(strykerBin, ['run', ...mutateArgs], { cwd, stdio: 'inherit', env: process.env })
|
|
316
412
|
: spawnSync('npx', ['@stryker-mutator/core', 'run', ...mutateArgs], { cwd, stdio: 'inherit', env: process.env })
|
|
317
413
|
return r.status ?? 1
|
|
414
|
+
},
|
|
415
|
+
runStorybookStrykerFull({ cwd }) {
|
|
416
|
+
// Full-режим Storybook-мутація через canonical `stryker.storybook.config.mjs`
|
|
417
|
+
// (окремий Stryker-прогін, testRunner:'command' — vitest-runner browser mode не
|
|
418
|
+
// підтримує, див. коментар над collectStorybookForRoot). Спайк 2026-07 підтвердив:
|
|
419
|
+
// command runner + vitest browser mode ПРАЦЮЄ з `define` для
|
|
420
|
+
// __STRYKER_ACTIVE_MUTANT__ у vite-конфізі browser-проєкту — канонічний контракт
|
|
421
|
+
// конфіга задокументовано в npm/docs/stryker-storybook-config.md. Той самий
|
|
422
|
+
// резолвінг local-bin, що й runStryker (plugin-discovery відносно install-каталогу).
|
|
423
|
+
const strykerBin = resolveLocalStrykerBin()
|
|
424
|
+
const args = ['run', '--configFile', STORYBOOK_STRYKER_CONFIG]
|
|
425
|
+
const r = strykerBin
|
|
426
|
+
? spawnSync(strykerBin, args, { cwd, stdio: 'inherit', env: process.env })
|
|
427
|
+
: spawnSync('npx', ['@stryker-mutator/core', ...args], { cwd, stdio: 'inherit', env: process.env })
|
|
428
|
+
return r.status ?? 1
|
|
318
429
|
}
|
|
319
430
|
}
|
|
320
431
|
|
|
@@ -335,6 +446,13 @@ const defaultRunner = {
|
|
|
335
446
|
* `bun test --coverage` (vitest не резолвить модуль `bun`), mutation пропускається
|
|
336
447
|
* з попередженням — Stryker vitest-runner такий код не виконає.
|
|
337
448
|
*
|
|
449
|
+
* `.vue`-мутація: Stryker core мутує `<script>`/`<script setup>` SFC без окремого плагіна.
|
|
450
|
+
* Storybook root (`isStorybookRoot`) → `runJsCoverage` отримує `excludeStorybookProject:
|
|
451
|
+
* true` (`--project=!storybook`), щоб не зачепити browser-mode проєкт (докладніше про стан
|
|
452
|
+
* підтримки browser mode у Stryker — коментар над `collectStorybookForRoot` нижче) —
|
|
453
|
+
* інакше цей самий JS-прогін спробував би й browser-mode тести теж, якщо вони живуть
|
|
454
|
+
* у тому ж vitest.config.mjs.
|
|
455
|
+
*
|
|
338
456
|
* Реальні помилки (vitest/bun exit ≠ 0, відсутній mutation.json попри запуск Stryker)
|
|
339
457
|
* кидаються — у multi-root режимі це не маскує справжній збій.
|
|
340
458
|
* @param {string} jsRoot абсолютний шлях до workspace-кореня
|
|
@@ -345,12 +463,18 @@ const defaultRunner = {
|
|
|
345
463
|
*/
|
|
346
464
|
async function collectOneRoot(jsRoot, cwd, runner, scope = null) {
|
|
347
465
|
const wsRel = relative(cwd, jsRoot)
|
|
348
|
-
// У changed-режимі production-файли для мутації = змінені JS цього root без
|
|
349
|
-
|
|
466
|
+
// У changed-режимі production-файли для мутації = змінені JS/Vue цього root без
|
|
467
|
+
// тест-файлів і без *.stories.* (сторі — не production-код, окремий Storybook-вимір).
|
|
468
|
+
// `.vue` тут ЗАЛИШАЄТЬСЯ — Stryker core мутує <script>/<script setup> SFC (з v7+),
|
|
469
|
+
// допоки покриття дають НЕ-browser-mode тести (@vue/test-utils тощо, не Storybook-сторі).
|
|
470
|
+
const mutateSrc = scope ? scope.files.filter(f => !TEST_FILE.test(f) && !STORIES_FILE_RE.test(f)) : null
|
|
350
471
|
|
|
351
472
|
// Bun-native workspace: coverage через `bun test`, mutation пропускається
|
|
352
473
|
// (Stryker vitest-runner не виконає код з `import ... from 'bun'`).
|
|
353
474
|
const bunNative = await isBunNativeRoot(jsRoot)
|
|
475
|
+
// Storybook root: named vitest-проєкт "storybook" (browser mode) типово ділить один
|
|
476
|
+
// vitest.config.mjs зі звичайним suite — виключаємо його з JS-прогону (див. runJsCoverage).
|
|
477
|
+
const excludeStorybookProject = !bunNative && (await isStorybookRoot(jsRoot))
|
|
354
478
|
if (bunNative && !(await hasRunnableTests(jsRoot))) {
|
|
355
479
|
// `bun test` без тестів завершується помилкою — graceful skip як vitest --passWithNoTests.
|
|
356
480
|
return scope
|
|
@@ -368,7 +492,11 @@ async function collectOneRoot(jsRoot, cwd, runner, scope = null) {
|
|
|
368
492
|
try {
|
|
369
493
|
const code = bunNative
|
|
370
494
|
? await runner.runBunCoverage({ cwd: jsRoot, lcovDir })
|
|
371
|
-
: await runner.runJsCoverage(
|
|
495
|
+
: await runner.runJsCoverage(
|
|
496
|
+
scope
|
|
497
|
+
? { cwd: jsRoot, lcovDir, base: scope.base, excludeStorybookProject }
|
|
498
|
+
: { cwd: jsRoot, lcovDir, excludeStorybookProject }
|
|
499
|
+
)
|
|
372
500
|
if (code !== 0) throw new Error(`JS coverage exit ${code}`)
|
|
373
501
|
const lcovPath = join(lcovDir, 'lcov.info')
|
|
374
502
|
coverage = existsSync(lcovPath)
|
|
@@ -404,10 +532,23 @@ async function collectOneRoot(jsRoot, cwd, runner, scope = null) {
|
|
|
404
532
|
await runner.runStryker(scope ? { cwd: jsRoot, mutate: mutateSrc } : { cwd: jsRoot })
|
|
405
533
|
const mutationPath = join(jsRoot, 'reports', 'stryker', 'mutation.json')
|
|
406
534
|
if (!existsSync(mutationPath)) {
|
|
535
|
+
// Stryker vitest-runner не підтримує сучасний (Playwright-based) vitest browser mode
|
|
536
|
+
// (докладніше — коментар над collectStorybookForRoot): якщо стрикер-фейсінг vitest.config.mjs
|
|
537
|
+
// (на який вказує stryker.config.mjs#vitest.configFile) містить named-проєкт "storybook",
|
|
538
|
+
// Stryker намагається виконати і його — і падає без mutation.json. Виправлення на боці
|
|
539
|
+
// target-проєкту: винести Storybook-проєкт в окремий vitest-конфіг, якого Stryker НЕ бачить
|
|
540
|
+
// (не reused той самий configFile).
|
|
541
|
+
const storybookHint = excludeStorybookProject
|
|
542
|
+
? ' Root має Storybook (.storybook/ + @storybook/addon-vitest) — якщо vitest.config.mjs, ' +
|
|
543
|
+
'на який вказує stryker.config.mjs#vitest.configFile, містить named-проєкт "storybook" ' +
|
|
544
|
+
'(browser mode), Stryker впаде на ньому (browser mode не підтримується vitest-runner) — ' +
|
|
545
|
+
'винеси Storybook-проєкт в окремий vitest-конфіг.'
|
|
546
|
+
: ''
|
|
407
547
|
throw new Error(
|
|
408
548
|
'js coverage: stryker не залишив mutation.json — ' +
|
|
409
549
|
'переконайся що встановлено canonical stryker.config.mjs (vitest-runner, perTest), ' +
|
|
410
|
-
'або налаштуй його
|
|
550
|
+
'або налаштуй його вручну.' +
|
|
551
|
+
storybookHint
|
|
411
552
|
)
|
|
412
553
|
}
|
|
413
554
|
const mutationReport = JSON.parse(await readFile(mutationPath, 'utf8'))
|
|
@@ -430,21 +571,228 @@ async function collectOneRoot(jsRoot, cwd, runner, scope = null) {
|
|
|
430
571
|
}
|
|
431
572
|
|
|
432
573
|
/**
|
|
433
|
-
* Збирає
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
574
|
+
* Збирає Storybook-покриття (Vue/React/... компоненти зі сторі) для **одного** JS-root.
|
|
575
|
+
* Активується лише коли `isStorybookRoot` (тека `.storybook/` + `@storybook/addon-vitest`
|
|
576
|
+
* у deps) і `hasStories` — інакше `null` (root не бере участі у рядку `Vue (Storybook)`).
|
|
577
|
+
*
|
|
578
|
+
* Mutation testing: у **changed-режимі** виконується власним mutate→run→restore
|
|
579
|
+
* executor-ом (`storybook-mutation.mjs`) — детерміновані AST-мутанти по змінених
|
|
580
|
+
* production-файлах, вбиває/милує реальний browser-mode прогін. У **full-режимі**
|
|
581
|
+
* власний executor надто дорогий (усі production-файли, не лише змінені) — тут шлях
|
|
582
|
+
* лише через canonical Stryker command-runner (`STORYBOOK_STRYKER_CONFIG`, див. нижче
|
|
583
|
+
* і `npm/docs/stryker-storybook-config.md`), якщо target-проєкт його налаштував;
|
|
584
|
+
* інакше чесний skip з попередженням (той самий принцип, що й bun-native).
|
|
585
|
+
*
|
|
586
|
+
* **Чому не Stryker (перевіряй перед покладанням на це в майбутньому — площина
|
|
587
|
+
* активно змінюється з обох боків):** issue stryker-js#4557 ("[vitest] support browser
|
|
588
|
+
* mode") ЗАКРИТИЙ через PR stryker-js#4628 ще у 2023 (v8.0.0) — але це стосувалось
|
|
589
|
+
* раннього browser mode доби vitest@1.0.0-beta, ДО сучасної provider-based архітектури
|
|
590
|
+
* (`@vitest/browser-playwright` тощо, стабілізованої у Vitest 4, 2025-12). Той старий фікс
|
|
591
|
+
* НЕ покриває сучасний Playwright-based browser mode, яким користується
|
|
592
|
+
* `@storybook/addon-vitest`: чинна документація Stryker vitest-runner прямо каже
|
|
593
|
+
* "Currently, Browser Mode is not supported" — інструментація Stryker передбачає
|
|
594
|
+
* Node.js-виконання, а сучасний browser mode виконує тести у реальному Chromium через
|
|
595
|
+
* Playwright, що структурно несумісно з тим, як Stryker патчить/спостерігає код.
|
|
596
|
+
* Власний executor слідує патерну, підтвердженому дослідженням спільноти
|
|
597
|
+
* (https://alexop.dev/posts/mutation-testing-ai-agents-vitest-browser-mode/, Mutahunter,
|
|
598
|
+
* Meta ACH): детермінована генерація мутантів + реальний прогін як суддя — без LLM
|
|
599
|
+
* у контурі виконання.
|
|
600
|
+
*
|
|
601
|
+
* **Валідована альтернатива (спайк 2026-07):** Stryker **command runner** (не
|
|
602
|
+
* vitest-runner) + browser mode ПРАЦЮЄ — але лише з обов'язковим
|
|
603
|
+
* `define: { 'process.env.__STRYKER_ACTIVE_MUTANT__': JSON.stringify(process.env.__STRYKER_ACTIVE_MUTANT__ ?? '') }`
|
|
604
|
+
* у vite-конфізі browser-проєкту (vitest browser mode ставить define як runtime-глобал
|
|
605
|
+
* у Chromium — саме той шлях, що читає інструментація Stryker). Без define — тихий
|
|
606
|
+
* провал: 0% killed, усі мутанти "виживають" (контрольний експеримент підтвердив).
|
|
607
|
+
* ~1.2s/мутант, sandbox без проблем (symlinked node_modules). Не прийнято зараз:
|
|
608
|
+
* потребує змін конфігів target-проєкту (define + stryker command-config) і має
|
|
609
|
+
* silent-0% failure mode, якщо Storybook-ів config merge загубить define; власний
|
|
610
|
+
* executor мутує файл на диску — активація не залежить від env-пропагації взагалі.
|
|
611
|
+
* Тому в **full-режимі** прийнято саме цей шлях (`runStorybookStrykerFull`, опційний
|
|
612
|
+
* у runner-і) — там власний executor вибуває через вартість, а не через технічні
|
|
613
|
+
* обмеження, тож ризик command-runner-а прийнятний. У **changed-режимі** лишається
|
|
614
|
+
* власний executor: дешевший на кількох файлах, і активація не залежить від
|
|
615
|
+
* env-пропагації взагалі.
|
|
616
|
+
*
|
|
617
|
+
* Changed-режим: запускається тільки якщо серед змінених файлів root-а є хоча б
|
|
618
|
+
* один `.vue`/`*.stories.*` (`scope.files` — вже звужений через `scopeToStorybookRoot`
|
|
619
|
+
* на боці виклику); інакше `null` (root пропускається повністю для цього виміру).
|
|
620
|
+
* @param {string} jsRoot абсолютний шлях workspace-кореня
|
|
621
|
+
* @param {string} cwd корінь проєкту (для рібейзингу `survived[].file`)
|
|
622
|
+
* @param {{runStorybookCoverage:Function, runStorybookMutantTest?:Function, proposeStorybookLlmMutants?:Function, runStorybookStrykerFull?:Function}} runner spawn-ін'єкція
|
|
623
|
+
* @param {{files:string[], base:string|null}|null} [scope] changed-scope (null = full-режим)
|
|
624
|
+
* @returns {Promise<{coverage:object, mutation:{caught:number,total:number}, survived:Array<object>} | null>} результат або null коли root не Storybook/без сторі/без relevant-змін
|
|
625
|
+
*/
|
|
626
|
+
async function collectStorybookForRoot(jsRoot, cwd, runner, scope = null) {
|
|
627
|
+
const wsRel = relative(cwd, jsRoot)
|
|
628
|
+
if (!(await isStorybookRoot(jsRoot))) return null
|
|
629
|
+
if (!(await hasStories(jsRoot))) return null
|
|
630
|
+
|
|
631
|
+
const lcovDir = await mkdtemp(join(tmpdir(), 'sb-cov-'))
|
|
632
|
+
let coverage
|
|
633
|
+
let coveredLines = new Map()
|
|
634
|
+
let baselineMs = 0
|
|
635
|
+
try {
|
|
636
|
+
const startedAt = Date.now()
|
|
637
|
+
const code = await runner.runStorybookCoverage(
|
|
638
|
+
scope ? { cwd: jsRoot, lcovDir, base: scope.base } : { cwd: jsRoot, lcovDir }
|
|
639
|
+
)
|
|
640
|
+
baselineMs = Date.now() - startedAt
|
|
641
|
+
if (code !== 0) {
|
|
642
|
+
throw new Error(
|
|
643
|
+
`Storybook coverage exit ${code} — перевір встановлений Playwright Chromium ` +
|
|
644
|
+
'(`npx playwright install chromium`) і named vitest-проєкт "storybook" (canonical config)'
|
|
645
|
+
)
|
|
646
|
+
}
|
|
647
|
+
const lcovPath = join(lcovDir, 'lcov.info')
|
|
648
|
+
if (existsSync(lcovPath)) {
|
|
649
|
+
const lcovText = await readFile(lcovPath, 'utf8')
|
|
650
|
+
coverage = parseLcov(lcovText)
|
|
651
|
+
coveredLines = parseLcovCoveredLines(lcovText, jsRoot)
|
|
652
|
+
} else {
|
|
653
|
+
coverage = { lines: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 } }
|
|
654
|
+
}
|
|
655
|
+
} finally {
|
|
656
|
+
await rm(lcovDir, { recursive: true, force: true })
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Full-режим: власний executor надто дорогий (повний suite × кожен мутант ×
|
|
660
|
+
// Chromium на ВСІХ production-файлах, не лише змінених) — тут шлях лише через
|
|
661
|
+
// canonical Stryker command-runner (STORYBOOK_STRYKER_CONFIG), якщо target-проєкт
|
|
662
|
+
// його налаштував (див. npm/docs/stryker-storybook-config.md). Без canonical
|
|
663
|
+
// конфіга або без runStorybookStrykerFull у runner-і — чесний skip, як і раніше.
|
|
664
|
+
if (!scope) {
|
|
665
|
+
const hasCanonicalConfig = existsSync(join(jsRoot, STORYBOOK_STRYKER_CONFIG))
|
|
666
|
+
if (hasCanonicalConfig && typeof runner.runStorybookStrykerFull === 'function') {
|
|
667
|
+
const code = await runner.runStorybookStrykerFull({ cwd: jsRoot })
|
|
668
|
+
const reportPath = join(jsRoot, STORYBOOK_STRYKER_REPORT)
|
|
669
|
+
if (code !== 0 && !existsSync(reportPath)) {
|
|
670
|
+
throw new Error(
|
|
671
|
+
`Storybook Stryker (command runner) exit ${code} — перевір ${STORYBOOK_STRYKER_CONFIG} ` +
|
|
672
|
+
'(testRunner: "command", commandRunner.command, jsonReporter.fileName) ' +
|
|
673
|
+
'і define __STRYKER_ACTIVE_MUTANT__ у vite-конфізі browser-проєкту'
|
|
674
|
+
)
|
|
675
|
+
}
|
|
676
|
+
if (existsSync(reportPath)) {
|
|
677
|
+
const report = JSON.parse(await readFile(reportPath, 'utf8'))
|
|
678
|
+
const parsed = parseStrykerReport(report, jsRoot)
|
|
679
|
+
console.log(
|
|
680
|
+
`✓ ${wsRel || '.'}: Storybook mutation (Stryker command runner) — ${parsed.caught}/${parsed.total} вбито`
|
|
681
|
+
)
|
|
682
|
+
return {
|
|
683
|
+
coverage,
|
|
684
|
+
mutation: { caught: parsed.caught, total: parsed.total },
|
|
685
|
+
survived: parsed.survived.map(group => ({
|
|
686
|
+
...group,
|
|
687
|
+
file: wsRel === '' ? group.file : join(wsRel, group.file)
|
|
688
|
+
}))
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
console.error(
|
|
693
|
+
`⚠ ${wsRel || '.'}: Storybook (Vue) — mutation testing пропущено ` +
|
|
694
|
+
`(${hasCanonicalConfig ? 'runner без runStorybookStrykerFull' : `нема ${STORYBOOK_STRYKER_CONFIG}`}), лише line coverage`
|
|
695
|
+
)
|
|
696
|
+
return { coverage, mutation: { caught: 0, total: 0 }, survived: [] }
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Changed-режим: guard на runStorybookMutantTest тримає сумісність із
|
|
700
|
+
// інжектованими runner-ами без mutation-підтримки (лише line coverage).
|
|
701
|
+
if (typeof runner.runStorybookMutantTest !== 'function') {
|
|
702
|
+
console.error(
|
|
703
|
+
`⚠ ${wsRel || '.'}: Storybook (Vue) — mutation testing пропущено (runner без runStorybookMutantTest), лише line coverage`
|
|
704
|
+
)
|
|
705
|
+
return { coverage, mutation: { caught: 0, total: 0 }, survived: [] }
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Changed-режим: мутуємо лише змінені production-файли (без сторі/тестів) із покриттям.
|
|
709
|
+
// proposeStorybookLlmMutants (опційний у runner) — друге, LLM-джерело мутантів поверх
|
|
710
|
+
// детермінованих; інжектовані runner-и без нього отримують лише детерміновані.
|
|
711
|
+
const mutationTargets = scope.files.filter(f => !STORIES_FILE_RE.test(f) && !TEST_FILE.test(f))
|
|
712
|
+
const proposeExtra =
|
|
713
|
+
typeof runner.proposeStorybookLlmMutants === 'function'
|
|
714
|
+
? (file, source, lines) => runner.proposeStorybookLlmMutants({ file, source, coveredLines: lines, cwd: jsRoot })
|
|
715
|
+
: null
|
|
716
|
+
const { caught, total, survived } = await runStorybookMutation({
|
|
717
|
+
jsRoot,
|
|
718
|
+
files: mutationTargets,
|
|
719
|
+
coveredLines,
|
|
720
|
+
runMutantTest: args => runner.runStorybookMutantTest(args),
|
|
721
|
+
resolveStoryFilter: file => findStoryFilter(jsRoot, file),
|
|
722
|
+
proposeExtraMutants: proposeExtra,
|
|
723
|
+
// Мутант-прогін звужено сторі-фільтром і без coverage — baseline (повний coverage-прогін)
|
|
724
|
+
// з запасом 3× покриває навіть повний suite; мінімум страхує холодний старт Chromium.
|
|
725
|
+
timeoutMs: Math.max(30_000, 3 * baselineMs)
|
|
726
|
+
})
|
|
727
|
+
if (total > 0) {
|
|
728
|
+
console.log(`✓ ${wsRel || '.'}: Storybook mutation — ${caught}/${total} вбито (changed-scope)`)
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
return {
|
|
732
|
+
coverage,
|
|
733
|
+
mutation: { caught, total },
|
|
734
|
+
survived: survived.map(group => ({
|
|
735
|
+
...group,
|
|
736
|
+
file: wsRel === '' ? group.file : join(wsRel, group.file)
|
|
737
|
+
}))
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const STORY_EXTENSIONS = ['js', 'mjs', 'ts', 'jsx', 'tsx']
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Шукає сторі-файл компонента поряд із ним (`Card.vue` → `Card.stories.{js,ts,...}`)
|
|
745
|
+
* для звуження мутант-прогону до релевантних тестів.
|
|
746
|
+
* @param {string} jsRoot абсолютний шлях workspace-кореня
|
|
747
|
+
* @param {string} file відносний шлях компонента
|
|
748
|
+
* @returns {string | null} відносний шлях сторі-файлу або null (прогін без фільтра)
|
|
749
|
+
*/
|
|
750
|
+
function findStoryFilter(jsRoot, file) {
|
|
751
|
+
const base = file.replace(FILE_EXTENSION, '')
|
|
752
|
+
for (const ext of STORY_EXTENSIONS) {
|
|
753
|
+
const candidate = `${base}.stories.${ext}`
|
|
754
|
+
if (existsSync(join(jsRoot, candidate))) return candidate
|
|
755
|
+
}
|
|
756
|
+
return null
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Будує підсумковий рядок з масиву per-root результатів через сумування coverage/mutation.
|
|
761
|
+
* @param {string} area назва рядка (`JS`, `Vue (Storybook)`)
|
|
762
|
+
* @param {Array<{coverage:object, mutation:{caught:number,total:number}, survived:Array<object>}>} results per-root результати
|
|
763
|
+
* @returns {{area:string, coverage:object, mutation:{caught:number,total:number}, survived:Array<object>}} агрегований рядок
|
|
764
|
+
*/
|
|
765
|
+
function buildAreaRow(area, results) {
|
|
766
|
+
let coverage = { lines: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 } }
|
|
767
|
+
let mutation = { caught: 0, total: 0 }
|
|
768
|
+
const survived = []
|
|
769
|
+
for (const r of results) {
|
|
770
|
+
coverage = addCoverage(coverage, r.coverage)
|
|
771
|
+
mutation = addMutation(mutation, r.mutation)
|
|
772
|
+
survived.push(...r.survived)
|
|
773
|
+
}
|
|
774
|
+
return { area, coverage, mutation, survived }
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Збирає JS-метрики покриття + мутаційного тестування, і окремо — Storybook-покриття
|
|
779
|
+
* (Vue/React/... компоненти зі сторі, `collectStorybookForRoot`). У monorepo ітерує усі
|
|
780
|
+
* JS-roots з `resolveAllJsRoots()` (включно з glob-патернами `cf/*`), для кожного root-а
|
|
781
|
+
* запускає обидва виміри незалежно й сумує lcov/mutation окремо через `buildAreaRow`.
|
|
782
|
+
* Workspaces без тестів (JS) або без Storybook-конфігурації/сторі пропускаються по
|
|
783
|
+
* кожному виміру окремо (root може дати лише JS-рядок, лише Storybook-рядок, обидва
|
|
784
|
+
* або жодного). Якщо і JS, і Storybook відсутні всюди — повертає `[]`.
|
|
438
785
|
* Шляхи у `survived` рібейзяться відносно `cwd`, щоб `coverage-fix.mjs`
|
|
439
786
|
* знаходив джерела через `join(projectRoot, file)`.
|
|
440
787
|
*
|
|
441
|
-
* Changed-режим (`opts.changedFiles` задано):
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
788
|
+
* Changed-режим (`opts.changedFiles` задано): JS-вимір отримує лише змінені JS-файли
|
|
789
|
+
* root-а (`scopeToRoot`), Storybook-вимір — лише змінені `.vue`/`*.stories.*`
|
|
790
|
+
* (`scopeToStorybookRoot`); кожен вимір пропускається незалежно, якщо relevant-змін
|
|
791
|
+
* нема. Якщо змін нема ніде — повертає `[]` без error-логу (оркестратор трактує
|
|
792
|
+
* порожній changed-scope як pass).
|
|
445
793
|
* @param {string} cwd корінь проєкту
|
|
446
794
|
* @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>}>>}
|
|
795
|
+
* @returns {Promise<Array<{area:string, coverage:object, mutation:{caught:number,total:number}, survived:Array<object>}>>} рядки `JS`/`Vue (Storybook)` — лише ті, де є дані
|
|
448
796
|
*/
|
|
449
797
|
export async function collect(cwd, opts = {}) {
|
|
450
798
|
const runner = opts.runner ?? defaultRunner
|
|
@@ -452,36 +800,45 @@ export async function collect(cwd, opts = {}) {
|
|
|
452
800
|
const jsRoots = await resolveAllJsRoots(cwd)
|
|
453
801
|
if (jsRoots.length === 0) throw new Error('js coverage: package.json не знайдено')
|
|
454
802
|
|
|
455
|
-
const
|
|
803
|
+
const jsResults = []
|
|
804
|
+
const storybookResults = []
|
|
456
805
|
for (const jsRoot of jsRoots) {
|
|
457
|
-
let scope = null
|
|
458
806
|
if (changed) {
|
|
459
|
-
const
|
|
460
|
-
if (
|
|
461
|
-
|
|
807
|
+
const jsFiles = scopeToRoot(opts.changedFiles, cwd, jsRoot)
|
|
808
|
+
if (jsFiles.length > 0) {
|
|
809
|
+
const scope = { files: jsFiles, base: opts.base ?? null }
|
|
810
|
+
const r = await collectOneRoot(jsRoot, cwd, runner, scope)
|
|
811
|
+
if (r !== null) jsResults.push(r)
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const sbFiles = scopeToStorybookRoot(opts.changedFiles, cwd, jsRoot)
|
|
815
|
+
if (sbFiles.length > 0) {
|
|
816
|
+
const sbScope = { files: sbFiles, base: opts.base ?? null }
|
|
817
|
+
const sb = await collectStorybookForRoot(jsRoot, cwd, runner, sbScope)
|
|
818
|
+
if (sb !== null) storybookResults.push(sb)
|
|
819
|
+
}
|
|
820
|
+
continue
|
|
462
821
|
}
|
|
463
|
-
|
|
464
|
-
|
|
822
|
+
|
|
823
|
+
const r = await collectOneRoot(jsRoot, cwd, runner, null)
|
|
824
|
+
if (r !== null) jsResults.push(r)
|
|
825
|
+
|
|
826
|
+
const sb = await collectStorybookForRoot(jsRoot, cwd, runner, null)
|
|
827
|
+
if (sb !== null) storybookResults.push(sb)
|
|
465
828
|
}
|
|
466
829
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
830
|
+
const rows = []
|
|
831
|
+
if (jsResults.length > 0) {
|
|
832
|
+
rows.push(buildAreaRow('JS', jsResults))
|
|
833
|
+
} else if (!changed) {
|
|
470
834
|
console.error(
|
|
471
835
|
'js coverage: жоден workspace не має тестів ' +
|
|
472
836
|
'(`*.test.{js,mjs}` у `tests/` або поряд із джерелом) — ' +
|
|
473
837
|
'додай тести або запусти `npx @7n/test` для генерації'
|
|
474
838
|
)
|
|
475
|
-
return []
|
|
476
839
|
}
|
|
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)
|
|
840
|
+
if (storybookResults.length > 0) {
|
|
841
|
+
rows.push(buildAreaRow('Vue (Storybook)', storybookResults))
|
|
485
842
|
}
|
|
486
|
-
return
|
|
843
|
+
return rows
|
|
487
844
|
}
|