@7n/test 0.15.0 → 0.16.1

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.16.1] - 2026-07-18
4
+
5
+ ### Fixed
6
+
7
+ - coverage: три реальні баги, знайдені живою перевіркою на справжньому Storybook 10 + Vue 3 проєкті (storybook init-скаффолд) — (1) --project=!storybook падав з 'No projects matched the filter' для проєктів без окремого JS unit-test vitest-проєкту (типовий default-скаффолд), тепер трактується як passWithNoTests; (2) .vue на Storybook-root виключено з JS-виміру Stryker-мутації — vitest-runner структурно не може прогнати dry-run, коли єдиний vitest-проєкт це browser-mode 'storybook', раніше це валило ВЕСЬ прогін; (3) Stryker CLI configFile — позиційний аргумент, не --configFile. Задокументовано нове невирішене обмеження full-режиму command runner на реальних проєктах (npm/docs/stryker-storybook-config.md) — --changed-executor лишається єдиним підтвердженим робочим шляхом
8
+
9
+ ## [0.16.0] - 2026-07-18
10
+
11
+ ### Added
12
+
13
+ - 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
14
+
3
15
  ## [0.15.0] - 2026-07-18
4
16
 
5
17
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/test",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "CLI-утиліта @7n/test",
5
5
  "keywords": [
6
6
  "7n",
@@ -33,6 +33,7 @@ const JS_FILE = /\.(c|m)?[jt]sx?$|\.vue$/
33
33
  const TEST_FILE = /\.(test|spec)\.[^.]+$/
34
34
  /** `.vue`-компоненти + `*.stories.*` — сигнал для Storybook-змінного scope (line coverage). */
35
35
  const VUE_OR_STORIES_FILE = /\.vue$|\.stories\.[^.]+$/
36
+ const VUE_FILE_RE = /\.vue$/
36
37
 
37
38
  /**
38
39
  * Звужує список змінених файлів (relative до cwd) до тих, що лежать під `jsRoot`,
@@ -77,6 +78,13 @@ export function scopeToStorybookRoot(changedFiles, cwd, jsRoot) {
77
78
  const VITEST_HINT =
78
79
  'js coverage: vitest відсутній у package.json — додай `vitest`, `@vitest/coverage-v8` та `@stryker-mutator/vitest-runner` у devDependencies (див. test.mdc)'
79
80
 
81
+ /** Canonical config-файл для full-режиму Storybook-мутації через Stryker command runner. */
82
+ const STORYBOOK_STRYKER_CONFIG = 'stryker.storybook.config.mjs'
83
+ /** Шлях mutation.json того ж прогону (окремий від `reports/stryker/` JS-виміру). */
84
+ const STORYBOOK_STRYKER_REPORT = join('reports', 'stryker-storybook', 'mutation.json')
85
+ /** vitest hard-fail коли `--project=!storybook` не залишає жодного проєкту (не test-порожньо). */
86
+ const NO_PROJECTS_MATCHED_RE = /No projects matched the filter/
87
+
80
88
  /**
81
89
  * Чи у пакеті встановлено vitest (через dependencies або devDependencies).
82
90
  * @param {{dependencies?: Record<string,string>, devDependencies?: Record<string,string>}} pkg package.json
@@ -306,20 +314,36 @@ const defaultRunner = {
306
314
  // зайве дублювання з collectStorybookForRoot і ризик втягнути Playwright-залежність
307
315
  // у звичайний coverage-прогін.
308
316
  const projectArgs = excludeStorybookProject ? ['--project=!storybook'] : []
309
- const r = spawnSync(
310
- 'bunx',
311
- [
312
- 'vitest',
313
- 'run',
314
- '--passWithNoTests',
315
- '--coverage',
316
- '--coverage.reporter=lcov',
317
- `--coverage.reportsDirectory=${lcovDir}`,
318
- ...projectArgs,
319
- ...changedArgs
320
- ],
321
- { cwd, stdio: 'inherit', env: process.env }
322
- )
317
+ const args = [
318
+ 'vitest',
319
+ 'run',
320
+ '--passWithNoTests',
321
+ '--coverage',
322
+ '--coverage.reporter=lcov',
323
+ `--coverage.reportsDirectory=${lcovDir}`,
324
+ ...projectArgs,
325
+ ...changedArgs
326
+ ]
327
+
328
+ if (!excludeStorybookProject) {
329
+ const r = spawnSync('bunx', args, { cwd, stdio: 'inherit', env: process.env })
330
+ return r.status ?? 1
331
+ }
332
+
333
+ // excludeStorybookProject-гілка захоплює stdout/stderr (не stdio:'inherit'), бо треба
334
+ // програмно розрізнити "--project=!storybook не залишив ЖОДНОГО проєкту" (типовий
335
+ // canonical @storybook/addon-vitest setup БЕЗ окремого unit-test проєкту — Storybook-root
336
+ // з єдиним named-проєктом "storybook") від реальної помилки. vitest у цьому кейсі кидає
337
+ // hard "No projects matched the filter" і завершується exit≠0 ДО будь-якого запуску тестів
338
+ // (`--passWithNoTests` цей кейс не покриває — той стосується тестів усередині вже
339
+ // знайдених проєктів, не відсутності самих проєктів). Підтверджено емпірично на
340
+ // реальному `storybook init` (Storybook 10 + Vue3, 2026-07): весь `test.projects` — лише
341
+ // "storybook", без окремого JS unit-test проєкту.
342
+ const r = spawnSync('bunx', args, { cwd, encoding: 'utf8', env: process.env })
343
+ const out = (r.stdout ?? '') + (r.stderr ?? '')
344
+ process.stdout.write(r.stdout ?? '')
345
+ process.stderr.write(r.stderr ?? '')
346
+ if (r.status !== 0 && NO_PROJECTS_MATCHED_RE.test(out)) return 0
323
347
  return r.status ?? 1
324
348
  },
325
349
  runBunCoverage({ cwd, lcovDir }) {
@@ -406,6 +430,24 @@ const defaultRunner = {
406
430
  ? spawnSync(strykerBin, ['run', ...mutateArgs], { cwd, stdio: 'inherit', env: process.env })
407
431
  : spawnSync('npx', ['@stryker-mutator/core', 'run', ...mutateArgs], { cwd, stdio: 'inherit', env: process.env })
408
432
  return r.status ?? 1
433
+ },
434
+ runStorybookStrykerFull({ cwd }) {
435
+ // Full-режим Storybook-мутація через canonical `stryker.storybook.config.mjs`
436
+ // (окремий Stryker-прогін, testRunner:'command' — vitest-runner browser mode не
437
+ // підтримує, див. коментар над collectStorybookForRoot). Спайк 2026-07 підтвердив:
438
+ // command runner + vitest browser mode ПРАЦЮЄ з `define` для
439
+ // __STRYKER_ACTIVE_MUTANT__ у vite-конфізі browser-проєкту — канонічний контракт
440
+ // конфіга задокументовано в npm/docs/stryker-storybook-config.md. Той самий
441
+ // резолвінг local-bin, що й runStryker (plugin-discovery відносно install-каталогу).
442
+ const strykerBin = resolveLocalStrykerBin()
443
+ // `configFile` — позиційний аргумент Stryker CLI (`stryker run [options] [configFile]`),
444
+ // НЕ `--configFile` — емпірично підтверджено (реальний прогін, 2026-07): `--configFile`
445
+ // дає `error: unknown option '--configFile'`.
446
+ const args = ['run', STORYBOOK_STRYKER_CONFIG]
447
+ const r = strykerBin
448
+ ? spawnSync(strykerBin, args, { cwd, stdio: 'inherit', env: process.env })
449
+ : spawnSync('npx', ['@stryker-mutator/core', ...args], { cwd, stdio: 'inherit', env: process.env })
450
+ return r.status ?? 1
409
451
  }
410
452
  }
411
453
 
@@ -443,18 +485,28 @@ const defaultRunner = {
443
485
  */
444
486
  async function collectOneRoot(jsRoot, cwd, runner, scope = null) {
445
487
  const wsRel = relative(cwd, jsRoot)
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
451
488
 
452
489
  // Bun-native workspace: coverage через `bun test`, mutation пропускається
453
490
  // (Stryker vitest-runner не виконає код з `import ... from 'bun'`).
454
491
  const bunNative = await isBunNativeRoot(jsRoot)
455
492
  // Storybook root: named vitest-проєкт "storybook" (browser mode) типово ділить один
456
493
  // vitest.config.mjs зі звичайним suite — виключаємо його з JS-прогону (див. runJsCoverage).
457
- const excludeStorybookProject = !bunNative && (await isStorybookRoot(jsRoot))
494
+ const isStorybook = !bunNative && (await isStorybookRoot(jsRoot))
495
+ const excludeStorybookProject = isStorybook
496
+
497
+ // У changed-режимі production-файли для мутації = змінені JS/Vue цього root без
498
+ // тест-файлів і без *.stories.* (сторі — не production-код, окремий Storybook-вимір).
499
+ // `.vue` на Storybook-root ТЕЖ виключено: якщо root — Storybook-only (типовий
500
+ // `storybook init`-скаффолд, ЄДИНИЙ vitest-проєкт — "storybook"), Stryker vitest-runner
501
+ // структурно не може прогнати dry-run (немає non-browser проєкту для виконання) —
502
+ // емпірично підтверджено (2026-07, реальний Storybook 10 + Vue3 скаффолд): dry-run
503
+ // валиться, `collectOneRoot` кидає, і це ламає ВЕСЬ прогін (обидва виміри), не лише
504
+ // JS-рядок. `.vue`-мутація на Storybook-root — виключно відповідальність
505
+ // `collectStorybookForRoot` (own executor / command-runner), яка вже покриває SFC
506
+ // повністю; дублювання через JS-вимір там і зайве, і структурно ламке.
507
+ const mutateSrc = scope
508
+ ? scope.files.filter(f => !TEST_FILE.test(f) && !STORIES_FILE_RE.test(f) && !(isStorybook && VUE_FILE_RE.test(f)))
509
+ : null
458
510
  if (bunNative && !(await hasRunnableTests(jsRoot))) {
459
511
  // `bun test` без тестів завершується помилкою — graceful skip як vitest --passWithNoTests.
460
512
  return scope
@@ -558,8 +610,10 @@ async function collectOneRoot(jsRoot, cwd, runner, scope = null) {
558
610
  * Mutation testing: у **changed-режимі** виконується власним mutate→run→restore
559
611
  * executor-ом (`storybook-mutation.mjs`) — детерміновані AST-мутанти по змінених
560
612
  * production-файлах, вбиває/милує реальний browser-mode прогін. У **full-режимі**
561
- * пропускається з попередженням (повний suite × кожен мутант × Chromium — надто
562
- * дорого; той самий принцип чесного skip, що й bun-native).
613
+ * власний executor надто дорогий (усі production-файли, не лише змінені) тут шлях
614
+ * лише через canonical Stryker command-runner (`STORYBOOK_STRYKER_CONFIG`, див. нижче
615
+ * і `npm/docs/stryker-storybook-config.md`), якщо target-проєкт його налаштував;
616
+ * інакше чесний skip з попередженням (той самий принцип, що й bun-native).
563
617
  *
564
618
  * **Чому не Stryker (перевіряй перед покладанням на це в майбутньому — площина
565
619
  * активно змінюється з обох боків):** issue stryker-js#4557 ("[vitest] support browser
@@ -576,24 +630,29 @@ async function collectOneRoot(jsRoot, cwd, runner, scope = null) {
576
630
  * Meta ACH): детермінована генерація мутантів + реальний прогін як суддя — без LLM
577
631
  * у контурі виконання.
578
632
  *
579
- * **Валідована альтернатива (спайк 2026-07):** Stryker **command runner** (не
580
- * vitest-runner) + browser mode ПРАЦЮЄ але лише з обов'язковим
633
+ * **Full-режим Stryker command runner (`runStorybookStrykerFull`, опційний у
634
+ * runner), АЛЕ з відомим невирішеним обмеженням (перевіряй перед покладанням
635
+ * секція «Обмеження» в `npm/docs/stryker-storybook-config.md`):**
636
+ * Spike на синтетичному JS-репо (2026-07-17) підтвердив саму МЕХАНІКУ — command
637
+ * runner + browser mode ПРАЦЮЄ з обов'язковим
581
638
  * `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).
639
+ * у vite-конфізі (без define тихий провал, 0% killed). Але живий прогін на
640
+ * РЕАЛЬНОМУ `storybook init`-скаффолді (2026-07-18) показав: dry-run падає з
641
+ * `Failed to fetch dynamically imported module` для `*.stories.js`, і ЖОДНА з
642
+ * перевірених гіпотез (symlink sandbox, `inPlace: true`, vue-docgen-plugin,
643
+ * concurrency) не root-cause. Той самий `vitest run --project=storybook`
644
+ * бездоганно працює через ЗВИЧАЙНИЙ `spawnSync` (наш `--changed`-executor)
645
+ * проблема специфічна саме до того, як Stryker spawn-ить/оточує процес.
646
+ * Висновок: full-режим лишається задокументованим напрямком, не підтвердженим
647
+ * робочим рішенням; `--changed`-executor (нижче) — єдиний перевірений на
648
+ * реальному проєкті шлях отримати справжній mutation score для Storybook-коду.
590
649
  *
591
650
  * Changed-режим: запускається тільки якщо серед змінених файлів root-а є хоча б
592
651
  * один `.vue`/`*.stories.*` (`scope.files` — вже звужений через `scopeToStorybookRoot`
593
652
  * на боці виклику); інакше `null` (root пропускається повністю для цього виміру).
594
653
  * @param {string} jsRoot абсолютний шлях workspace-кореня
595
654
  * @param {string} cwd корінь проєкту (для рібейзингу `survived[].file`)
596
- * @param {{runStorybookCoverage:Function, runStorybookMutantTest?:Function, proposeStorybookLlmMutants?:Function}} runner spawn-ін'єкція
655
+ * @param {{runStorybookCoverage:Function, runStorybookMutantTest?:Function, proposeStorybookLlmMutants?:Function, runStorybookStrykerFull?:Function}} runner spawn-ін'єкція
597
656
  * @param {{files:string[], base:string|null}|null} [scope] changed-scope (null = full-режим)
598
657
  * @returns {Promise<{coverage:object, mutation:{caught:number,total:number}, survived:Array<object>} | null>} результат або null коли root не Storybook/без сторі/без relevant-змін
599
658
  */
@@ -630,12 +689,51 @@ async function collectStorybookForRoot(jsRoot, cwd, runner, scope = null) {
630
689
  await rm(lcovDir, { recursive: true, force: true })
631
690
  }
632
691
 
633
- // Full-режим: mutation чесно пропускається (див. JSDoc). Guard на runStorybookMutantTest
634
- // тримає сумісність із інжектованими runner-ами без mutation-підтримки.
635
- if (!scope || typeof runner.runStorybookMutantTest !== 'function') {
692
+ // Full-режим: власний executor надто дорогий (повний suite × кожен мутант ×
693
+ // Chromium на ВСІХ production-файлах, не лише змінених) — тут шлях лише через
694
+ // canonical Stryker command-runner (STORYBOOK_STRYKER_CONFIG), якщо target-проєкт
695
+ // його налаштував (див. npm/docs/stryker-storybook-config.md). Без canonical
696
+ // конфіга або без runStorybookStrykerFull у runner-і — чесний skip, як і раніше.
697
+ if (!scope) {
698
+ const hasCanonicalConfig = existsSync(join(jsRoot, STORYBOOK_STRYKER_CONFIG))
699
+ if (hasCanonicalConfig && typeof runner.runStorybookStrykerFull === 'function') {
700
+ const code = await runner.runStorybookStrykerFull({ cwd: jsRoot })
701
+ const reportPath = join(jsRoot, STORYBOOK_STRYKER_REPORT)
702
+ if (code !== 0 && !existsSync(reportPath)) {
703
+ throw new Error(
704
+ `Storybook Stryker (command runner) exit ${code} — перевір ${STORYBOOK_STRYKER_CONFIG} ` +
705
+ '(testRunner: "command", commandRunner.command, jsonReporter.fileName) ' +
706
+ 'і define __STRYKER_ACTIVE_MUTANT__ у vite-конфізі browser-проєкту'
707
+ )
708
+ }
709
+ if (existsSync(reportPath)) {
710
+ const report = JSON.parse(await readFile(reportPath, 'utf8'))
711
+ const parsed = parseStrykerReport(report, jsRoot)
712
+ console.log(
713
+ `✓ ${wsRel || '.'}: Storybook mutation (Stryker command runner) — ${parsed.caught}/${parsed.total} вбито`
714
+ )
715
+ return {
716
+ coverage,
717
+ mutation: { caught: parsed.caught, total: parsed.total },
718
+ survived: parsed.survived.map(group => ({
719
+ ...group,
720
+ file: wsRel === '' ? group.file : join(wsRel, group.file)
721
+ }))
722
+ }
723
+ }
724
+ }
636
725
  console.error(
637
726
  `⚠ ${wsRel || '.'}: Storybook (Vue) — mutation testing пропущено ` +
638
- '(у full-режимі надто дорого: повний browser-mode suite на кожен мутант), лише line coverage'
727
+ `(${hasCanonicalConfig ? 'runner без runStorybookStrykerFull' : `нема ${STORYBOOK_STRYKER_CONFIG}`}), лише line coverage`
728
+ )
729
+ return { coverage, mutation: { caught: 0, total: 0 }, survived: [] }
730
+ }
731
+
732
+ // Changed-режим: guard на runStorybookMutantTest тримає сумісність із
733
+ // інжектованими runner-ами без mutation-підтримки (лише line coverage).
734
+ if (typeof runner.runStorybookMutantTest !== 'function') {
735
+ console.error(
736
+ `⚠ ${wsRel || '.'}: Storybook (Vue) — mutation testing пропущено (runner без runStorybookMutantTest), лише line coverage`
639
737
  )
640
738
  return { coverage, mutation: { caught: 0, total: 0 }, survived: [] }
641
739
  }