@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 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.14.5",
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
- /** 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,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
- const mutateSrc = scope ? scope.files.filter(f => !TEST_FILE.test(f)) : null
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(scope ? { cwd: jsRoot, lcovDir, base: scope.base } : { cwd: jsRoot, lcovDir })
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
- * Збирає JS-метрики покриття + мутаційного тестування. У monorepo ітерує усі
434
- * JS-roots з `resolveAllJsRoots()` (включно з glob-патернами `cf/*`), запускає
435
- * vitest+Stryker у кожному та сумує lcov/mutation через `addCoverage`/`addMutation`.
436
- * Workspaces без тестів пропускаються (див. `collectOneRoot`).
437
- * Якщо тестів немає у жодному workspace повертає `[]`.
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` задано): кожен root отримує лише свої змінені
442
- * JS-файли (`scopeToRoot`); roots без змінених JS пропускаються повністю (ні vitest,
443
- * ні Stryker). Якщо змін нема ніде повертає `[]` без error-логу (оркестратор
444
- * трактує порожній changed-scope як pass).
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>}>>} рядок `JS` або `[]` коли тестів/змін нема ніде
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 results = []
803
+ const jsResults = []
804
+ const storybookResults = []
456
805
  for (const jsRoot of jsRoots) {
457
- let scope = null
458
806
  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 }
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
- const r = await collectOneRoot(jsRoot, cwd, runner, scope)
464
- if (r !== null) results.push(r)
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
- if (results.length === 0) {
468
- // Changed-режим: нема змінених JS у жодному root → тихо порожньо (це pass, не помилка).
469
- if (changed) return []
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
- 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)
840
+ if (storybookResults.length > 0) {
841
+ rows.push(buildAreaRow('Vue (Storybook)', storybookResults))
485
842
  }
486
- return [{ area: 'JS', coverage, mutation, survived }]
843
+ return rows
487
844
  }