@7n/test 0.14.4 → 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 CHANGED
@@ -1,5 +1,24 @@
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
+
16
+ ## [0.14.5] - 2026-07-12
17
+
18
+ ### Fixed
19
+
20
+ - runBunCoverage: --parallel для ізоляції test-файлів
21
+
3
22
  ## [0.14.4] - 2026-07-11
4
23
 
5
24
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/test",
3
- "version": "0.14.4",
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
- /** JS/TS-розширення — файли, які мутує Stryker і покриває vitest. */
22
- const JS_FILE = /\.(c|m)?[jt]sx?$/
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 }
@@ -286,13 +326,72 @@ const defaultRunner = {
286
326
  // Bun-native workspace (prod-код імпортує `bun`/`bun:*`): vitest такий модуль не
287
327
  // резолвить, тож coverage ганяємо нативним `bun test`. Bun ремапить
288
328
  // `import ... from 'vitest'` у тест-файлах на `bun:test` — тести лишаються canon.
289
- const r = spawnSync('bun', ['test', '--coverage', '--coverage-reporter=lcov', `--coverage-dir=${lcovDir}`], {
329
+ // `--parallel` форкає worker-процес на тестовий файл (ізоляція module-registry):
330
+ // без нього всі файли ділять один process, і leftover mock-стан
331
+ // (`mockResolvedValueOnce`, module-level кеш) з одного файлу протікає в наступний,
332
+ // даючи фантомні падіння. lcov з `--coverage-dir` агрегується коректно з усіх worker-ів.
333
+ const r = spawnSync(
334
+ 'bun',
335
+ ['test', '--coverage', '--coverage-reporter=lcov', `--coverage-dir=${lcovDir}`, '--parallel'],
336
+ {
337
+ cwd,
338
+ stdio: 'inherit',
339
+ env: process.env
340
+ }
341
+ )
342
+ return r.status ?? 1
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] : [])], {
290
372
  cwd,
291
- stdio: 'inherit',
292
- env: process.env
373
+ stdio: 'ignore',
374
+ env: process.env,
375
+ timeout: timeoutMs
293
376
  })
377
+ if (r.signal) return null // убитий таймаутом
294
378
  return r.status ?? 1
295
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
+ },
296
395
  runStryker({ cwd, mutate }) {
297
396
  // Plugin-discovery Stryker (`@stryker-mutator/*`) globиться відносно CORE-install-каталогу
298
397
  // (`core/dist/src/di/plugin-loader.js` → `../../../../../@stryker-mutator/*`). Тож core
@@ -327,6 +426,13 @@ const defaultRunner = {
327
426
  * `bun test --coverage` (vitest не резолвить модуль `bun`), mutation пропускається
328
427
  * з попередженням — Stryker vitest-runner такий код не виконає.
329
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
+ *
330
436
  * Реальні помилки (vitest/bun exit ≠ 0, відсутній mutation.json попри запуск Stryker)
331
437
  * кидаються — у multi-root режимі це не маскує справжній збій.
332
438
  * @param {string} jsRoot абсолютний шлях до workspace-кореня
@@ -337,12 +443,18 @@ const defaultRunner = {
337
443
  */
338
444
  async function collectOneRoot(jsRoot, cwd, runner, scope = null) {
339
445
  const wsRel = relative(cwd, jsRoot)
340
- // У changed-режимі production-файли для мутації = змінені JS цього root без тест-файлів.
341
- const mutateSrc = scope ? scope.files.filter(f => !TEST_FILE.test(f)) : null
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
342
451
 
343
452
  // Bun-native workspace: coverage через `bun test`, mutation пропускається
344
453
  // (Stryker vitest-runner не виконає код з `import ... from 'bun'`).
345
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))
346
458
  if (bunNative && !(await hasRunnableTests(jsRoot))) {
347
459
  // `bun test` без тестів завершується помилкою — graceful skip як vitest --passWithNoTests.
348
460
  return scope
@@ -360,7 +472,11 @@ async function collectOneRoot(jsRoot, cwd, runner, scope = null) {
360
472
  try {
361
473
  const code = bunNative
362
474
  ? await runner.runBunCoverage({ cwd: jsRoot, lcovDir })
363
- : await runner.runJsCoverage(scope ? { cwd: jsRoot, lcovDir, base: scope.base } : { cwd: jsRoot, lcovDir })
475
+ : await runner.runJsCoverage(
476
+ scope
477
+ ? { cwd: jsRoot, lcovDir, base: scope.base, excludeStorybookProject }
478
+ : { cwd: jsRoot, lcovDir, excludeStorybookProject }
479
+ )
364
480
  if (code !== 0) throw new Error(`JS coverage exit ${code}`)
365
481
  const lcovPath = join(lcovDir, 'lcov.info')
366
482
  coverage = existsSync(lcovPath)
@@ -396,10 +512,23 @@ async function collectOneRoot(jsRoot, cwd, runner, scope = null) {
396
512
  await runner.runStryker(scope ? { cwd: jsRoot, mutate: mutateSrc } : { cwd: jsRoot })
397
513
  const mutationPath = join(jsRoot, 'reports', 'stryker', 'mutation.json')
398
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
+ : ''
399
527
  throw new Error(
400
528
  'js coverage: stryker не залишив mutation.json — ' +
401
529
  'переконайся що встановлено canonical stryker.config.mjs (vitest-runner, perTest), ' +
402
- 'або налаштуй його вручну'
530
+ 'або налаштуй його вручну.' +
531
+ storybookHint
403
532
  )
404
533
  }
405
534
  const mutationReport = JSON.parse(await readFile(mutationPath, 'utf8'))
@@ -422,21 +551,183 @@ async function collectOneRoot(jsRoot, cwd, runner, scope = null) {
422
551
  }
423
552
 
424
553
  /**
425
- * Збирає JS-метрики покриття + мутаційного тестування. У monorepo ітерує усі
426
- * JS-roots з `resolveAllJsRoots()` (включно з glob-патернами `cf/*`), запускає
427
- * vitest+Stryker у кожному та сумує lcov/mutation через `addCoverage`/`addMutation`.
428
- * Workspaces без тестів пропускаються (див. `collectOneRoot`).
429
- * Якщо тестів немає у жодному workspace повертає `[]`.
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 відсутні всюди — повертає `[]`.
430
720
  * Шляхи у `survived` рібейзяться відносно `cwd`, щоб `coverage-fix.mjs`
431
721
  * знаходив джерела через `join(projectRoot, file)`.
432
722
  *
433
- * Changed-режим (`opts.changedFiles` задано): кожен root отримує лише свої змінені
434
- * JS-файли (`scopeToRoot`); roots без змінених JS пропускаються повністю (ні vitest,
435
- * ні Stryker). Якщо змін нема ніде повертає `[]` без error-логу (оркестратор
436
- * трактує порожній changed-scope як pass).
723
+ * Changed-режим (`opts.changedFiles` задано): JS-вимір отримує лише змінені JS-файли
724
+ * root-а (`scopeToRoot`), Storybook-вимір лише змінені `.vue`/`*.stories.*`
725
+ * (`scopeToStorybookRoot`); кожен вимір пропускається незалежно, якщо relevant-змін
726
+ * нема. Якщо змін нема ніде — повертає `[]` без error-логу (оркестратор трактує
727
+ * порожній changed-scope як pass).
437
728
  * @param {string} cwd корінь проєкту
438
729
  * @param {{runner?: typeof defaultRunner, changedFiles?: string[], base?: string|null}} [opts] runner-ін'єкція + changed-scope
439
- * @returns {Promise<Array<{area:string, coverage:object, mutation:{caught:number,total:number}, survived:Array<object>}>>} рядок `JS` або `[]` коли тестів/змін нема ніде
730
+ * @returns {Promise<Array<{area:string, coverage:object, mutation:{caught:number,total:number}, survived:Array<object>}>>} рядки `JS`/`Vue (Storybook)` лише ті, де є дані
440
731
  */
441
732
  export async function collect(cwd, opts = {}) {
442
733
  const runner = opts.runner ?? defaultRunner
@@ -444,36 +735,45 @@ export async function collect(cwd, opts = {}) {
444
735
  const jsRoots = await resolveAllJsRoots(cwd)
445
736
  if (jsRoots.length === 0) throw new Error('js coverage: package.json не знайдено')
446
737
 
447
- const results = []
738
+ const jsResults = []
739
+ const storybookResults = []
448
740
  for (const jsRoot of jsRoots) {
449
- let scope = null
450
741
  if (changed) {
451
- const files = scopeToRoot(opts.changedFiles, cwd, jsRoot)
452
- if (files.length === 0) continue // root без змінених JS — пропускаємо
453
- scope = { files, base: opts.base ?? null }
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
454
756
  }
455
- const r = await collectOneRoot(jsRoot, cwd, runner, scope)
456
- if (r !== null) results.push(r)
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)
457
763
  }
458
764
 
459
- if (results.length === 0) {
460
- // Changed-режим: нема змінених JS у жодному root → тихо порожньо (це pass, не помилка).
461
- if (changed) return []
765
+ const rows = []
766
+ if (jsResults.length > 0) {
767
+ rows.push(buildAreaRow('JS', jsResults))
768
+ } else if (!changed) {
462
769
  console.error(
463
770
  'js coverage: жоден workspace не має тестів ' +
464
771
  '(`*.test.{js,mjs}` у `tests/` або поряд із джерелом) — ' +
465
772
  'додай тести або запусти `npx @7n/test` для генерації'
466
773
  )
467
- return []
468
774
  }
469
-
470
- let coverage = { lines: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 } }
471
- let mutation = { caught: 0, total: 0 }
472
- const survived = []
473
- for (const r of results) {
474
- coverage = addCoverage(coverage, r.coverage)
475
- mutation = addMutation(mutation, r.mutation)
476
- survived.push(...r.survived)
775
+ if (storybookResults.length > 0) {
776
+ rows.push(buildAreaRow('Vue (Storybook)', storybookResults))
477
777
  }
478
- return [{ area: 'JS', coverage, mutation, survived }]
778
+ return rows
479
779
  }