@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 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.14.5",
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 }
@@ -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
- 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
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(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
+ )
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
- * Збирає JS-метрики покриття + мутаційного тестування. У monorepo ітерує усі
434
- * JS-roots з `resolveAllJsRoots()` (включно з glob-патернами `cf/*`), запускає
435
- * vitest+Stryker у кожному та сумує lcov/mutation через `addCoverage`/`addMutation`.
436
- * Workspaces без тестів пропускаються (див. `collectOneRoot`).
437
- * Якщо тестів немає у жодному 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 відсутні всюди — повертає `[]`.
438
720
  * Шляхи у `survived` рібейзяться відносно `cwd`, щоб `coverage-fix.mjs`
439
721
  * знаходив джерела через `join(projectRoot, file)`.
440
722
  *
441
- * Changed-режим (`opts.changedFiles` задано): кожен root отримує лише свої змінені
442
- * JS-файли (`scopeToRoot`); roots без змінених JS пропускаються повністю (ні vitest,
443
- * ні Stryker). Якщо змін нема ніде повертає `[]` без error-логу (оркестратор
444
- * трактує порожній changed-scope як pass).
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>}>>} рядок `JS` або `[]` коли тестів/змін нема ніде
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 results = []
738
+ const jsResults = []
739
+ const storybookResults = []
456
740
  for (const jsRoot of jsRoots) {
457
- let scope = null
458
741
  if (changed) {
459
- const files = scopeToRoot(opts.changedFiles, cwd, jsRoot)
460
- if (files.length === 0) continue // root без змінених JS — пропускаємо
461
- 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
462
756
  }
463
- const r = await collectOneRoot(jsRoot, cwd, runner, scope)
464
- 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)
465
763
  }
466
764
 
467
- if (results.length === 0) {
468
- // Changed-режим: нема змінених JS у жодному root → тихо порожньо (це pass, не помилка).
469
- if (changed) return []
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
- let coverage = { lines: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 } }
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 [{ area: 'JS', coverage, mutation, survived }]
778
+ return rows
487
779
  }