diff --git a/plugins/lang-js/.changes/260721-1302.md b/plugins/lang-js/.changes/260721-1302.md new file mode 100644 index 000000000..0cbcbe861 --- /dev/null +++ b/plugins/lang-js/.changes/260721-1302.md @@ -0,0 +1,5 @@ +--- +bump: minor +section: Added +--- +storybook: новий concern `ci` (ADR Кластер 5, CI-частина) — канонічний composite action `setup-playwright-chromium` (кеш Playwright-браузерів, лише chromium) і `.github/workflows/lint-storybook.yml` (швидкий PR-прогін `vitest --project=storybook`), гейтований `requires.capability: ci:github` diff --git a/plugins/lang-js/.changes/260721-1324.md b/plugins/lang-js/.changes/260721-1324.md new file mode 100644 index 000000000..766e72601 --- /dev/null +++ b/plugins/lang-js/.changes/260721-1324.md @@ -0,0 +1,5 @@ +--- +bump: patch +section: Fixed +--- +storybook: viteFinal-фільтр стійкий до VueMacros-стека (Promise/масив-резолв, сімейний фільтр vite:*/vue-macros), vitest@^4 provider-factory (@vitest/browser-playwright) замість застарілого рядка `'playwright'`, flat-root layout у detectStoriesGlob (components без src/), точковий alias-мок одного модуля в mocking.mdc, і STORIES_RE false positive на `storybookTest({ configDir })` без явного include — усе за результатами пілота adopt-діагностики на nitra/components. Заодно governance package_json.rego: allowlist доповнено `@vitest/browser-playwright`. diff --git a/plugins/lang-js/rules/bun/package_json/package_json.mdc b/plugins/lang-js/rules/bun/package_json/package_json.mdc index fed144161..a76addd1a 100644 --- a/plugins/lang-js/rules/bun/package_json/package_json.mdc +++ b/plugins/lang-js/rules/bun/package_json/package_json.mdc @@ -9,8 +9,8 @@ Rego-пакет: `bun.package_json` Gate виносить два класи deny: 1. **Заборонені top-level поля** — будь-яке поле з `package.json.deny.json` присутнє у файлі (навіть із порожнім значенням `{}`). -2. **devDependencies не з білого списку** — дозволені лише `@nitra/*`/`@7n/*` та root-only тестові peer/tools (`vitest`, `@vitest/coverage-v8`, `@vitest/browser`, `@stryker-mutator/vitest-runner`, `@stryker-mutator/core`, `@playwright/test`, `playwright`, `@storybook/addon-vitest`, `@7n/test`). Будь-який інший пакет → deny. CLI-тули, які `n-rules lint` спавнить через `bunx` (oxlint, jscpd, v8r, github-actionlint тощо), у root не пінуються — вони приїжджають як `dependencies` пакета `@7n/rules` (npm-module.mdc). +2. **devDependencies не з білого списку** — дозволені лише `@nitra/*`/`@7n/*` та root-only тестові peer/tools (`vitest`, `@vitest/coverage-v8`, `@vitest/browser`, `@vitest/browser-playwright`, `@stryker-mutator/vitest-runner`, `@stryker-mutator/core`, `@playwright/test`, `playwright`, `@storybook/addon-vitest`, `@7n/test`). Будь-який інший пакет → deny. CLI-тули, які `n-rules lint` спавнить через `bunx` (oxlint, jscpd, v8r, github-actionlint тощо), у root не пінуються — вони приїжджають як `dependencies` пакета `@7n/rules` (npm-module.mdc). -`@vitest/browser`/`playwright`/`@storybook/addon-vitest` — додані для named vitest project "storybook" (канон Storybook, кластер 5: browser-mode, лише chromium) — той самий root vitest.config, що й `unit`-проект; `@storybook/addon-vitest` постачає `storybookTest`-плагін для цього vitest-конфіга (канонічний template правила `storybook`). **Межа з `npm-module.mdc`**: Storybook-специфічні identity-пакети (`storybook`, `@storybook/vue3-vite`, `@storybook/vue3`, `msw`, `msw-storybook-addon`) у цей allowlist **не** додаються — вони живуть у `npm/package.json` консюмер-пакета (канон Storybook, кластер 7 Governance), бо `isStorybookRoot()` у `@7n/test` детектує Storybook-скоуп саме за тим файлом, не кореневим. `@storybook/addon-vitest` — виняток із цієї межі: він test-tooling (vitest-плагін), а не identity-маркер, тож root, як і решта vitest-peer'ів. +`@vitest/browser`/`@vitest/browser-playwright`/`playwright`/`@storybook/addon-vitest` — додані для named vitest project "storybook" (канон Storybook, кластер 5: browser-mode, лише chromium) — той самий root vitest.config, що й `unit`-проект; `@storybook/addon-vitest` постачає `storybookTest`-плагін для цього vitest-конфіга (канонічний template правила `storybook`). `@vitest/browser-playwright` — окремий npm-пакет: vitest@^4 прибрав рядкове API browser-провайдера (`provider: 'playwright'`), потрібна factory `import { playwright } from '@vitest/browser-playwright'` → `provider: playwright()` (звірено з реальним `bun.lock` пілотного консюмера — усі три `@vitest/*`/`playwright`-пакети встановлені поруч). **Межа з `npm-module.mdc`**: Storybook-специфічні identity-пакети (`storybook`, `@storybook/vue3-vite`, `@storybook/vue3`, `msw`, `msw-storybook-addon`) у цей allowlist **не** додаються — вони живуть у `npm/package.json` консюмер-пакета (канон Storybook, кластер 7 Governance), бо `isStorybookRoot()` у `@7n/test` детектує Storybook-скоуп саме за тим файлом, не кореневим. `@storybook/addon-vitest` — виняток із цієї межі: він test-tooling (vitest-плагін), а не identity-маркер, тож root, як і решта vitest-peer'ів. Перевірки, що потребують FS або cross-file контексту (наприклад наявність `yarn.lock`), лишаються у JS-шарі. diff --git a/plugins/lang-js/rules/bun/package_json/package_json.rego b/plugins/lang-js/rules/bun/package_json/package_json.rego index 3d54447cd..4e309efcf 100644 --- a/plugins/lang-js/rules/bun/package_json/package_json.rego +++ b/plugins/lang-js/rules/bun/package_json/package_json.rego @@ -8,10 +8,11 @@ # - `devDependencies` лише `@nitra/*` + root-only тестові peer/tools для `@7n/test coverage` # (правило `test` enabled завжди — див. `test/auto.md`; published workspace-и не мають # `devDependencies` за `npm-module.mdc`) -# - `@vitest/browser`/`playwright`/`@storybook/addon-vitest` (browser-mode provider + -# `storybookTest`-плагін для named vitest project "storybook", лише chromium — канон -# Storybook кластер 5) теж root-only test peers: той самий vitest.config, що й -# `unit`-проект, живе в корені монорепо-споживача. Storybook-специфічні +# - `@vitest/browser`/`@vitest/browser-playwright`/`playwright`/`@storybook/addon-vitest` +# (browser-mode provider factory + `storybookTest`-плагін для named vitest project +# "storybook", лише chromium — канон Storybook кластер 5) теж root-only test peers: +# той самий vitest.config, що й `unit`-проект, живе в корені монорепо-споживача. +# Storybook-специфічні # identity-пакети (`storybook`, `@storybook/vue3*`, `msw*`) НЕ сюди — вони живуть у # `npm/package.json` (канон Storybook кластер 7, `npm-module.mdc`), бо # `isStorybookRoot()` @7n/test читає саме той файл, не кореневий package.json. @@ -59,11 +60,15 @@ deny contains msg if { # @stryker-mutator/core — обов'язковий exact-pin peer vitest-runner@9+ (раніше тягнувся транзитивно) # @7n/test — оркестратор `coverage` (npx @7n/test coverage); devDependency, щоб npx резолвив # локально без мережевого fetch щоразу. -# @vitest/browser + playwright — провайдер browser-mode для named vitest project -# "storybook" (канон Storybook кластер 5: лише chromium, PR — швидкий -# --project=storybook). `playwright` (не `@playwright/test`) — сирий driver, який -# @vitest/browser використовує як provider; `@playwright/test` лишається окремо для -# змістовних E2E-сценаріїв (n-vue.mdc). +# @vitest/browser + @vitest/browser-playwright + playwright — провайдер browser-mode для +# named vitest project "storybook" (канон Storybook кластер 5: лише chromium, PR — +# швидкий --project=storybook). vitest@^4 прибрав рядкове API провайдера +# (`provider: 'playwright'`) — `@vitest/browser-playwright` постачає factory +# `import { playwright } from '@vitest/browser-playwright'` → `provider: playwright()`, +# окремий npm-пакет, не входить у `@vitest/browser` (звірено з реальним bun.lock пілота — +# усі три пакети встановлені поруч). `playwright` (не `@playwright/test`) — сирий driver, +# який `@vitest/browser-playwright` використовує як provider; `@playwright/test` лишається +# окремо для змістовних E2E-сценаріїв (n-vue.mdc). # @storybook/addon-vitest — постачає `storybookTest` для vitest-плагіна в канонічному # vitest.config named-проекту "storybook" (той самий канон Storybook кластер 5); версія # з лінійки Storybook 9.x (узгоджена з `storybook`@9.1.10, запіненим у @@ -73,6 +78,7 @@ allowed_root_test_deps := { "vitest", "@vitest/coverage-v8", "@vitest/browser", + "@vitest/browser-playwright", "@stryker-mutator/vitest-runner", "@stryker-mutator/core", "@playwright/test", diff --git a/plugins/lang-js/rules/bun/package_json/package_json_test.rego b/plugins/lang-js/rules/bun/package_json/package_json_test.rego index a81222ff0..e845e44d5 100644 --- a/plugins/lang-js/rules/bun/package_json/package_json_test.rego +++ b/plugins/lang-js/rules/bun/package_json/package_json_test.rego @@ -69,7 +69,8 @@ test_allow_7n_test_coverage_orchestrator if { count(package_json.deny) == 0 with input as pkg with data.template as template_data } -# ── allow: @vitest/browser + playwright — provider для named vitest project "storybook" ── +# ── allow: @vitest/browser + @vitest/browser-playwright + playwright — provider factory +# (vitest@^4) для named vitest project "storybook" ── test_allow_vitest_browser_and_playwright_provider if { pkg := json.patch(valid_pkg, [{ @@ -80,6 +81,20 @@ test_allow_vitest_browser_and_playwright_provider if { count(package_json.deny) == 0 with input as pkg with data.template as template_data } +test_allow_vitest_browser_playwright_provider_package if { + pkg := json.patch(valid_pkg, [{ + "op": "replace", + "path": "/devDependencies", + "value": { + "@nitra/eslint-config": "^3.9.2", + "@vitest/browser": "^4.1.10", + "@vitest/browser-playwright": "^4.1.10", + "playwright": "^1.61.1", + }, + }]) + count(package_json.deny) == 0 with input as pkg with data.template as template_data +} + test_allow_storybook_addon_vitest_root_tooling if { pkg := json.patch(valid_pkg, [{ "op": "replace", diff --git a/plugins/lang-js/rules/storybook/adopt/docs/main.md b/plugins/lang-js/rules/storybook/adopt/docs/main.md index f8400ec62..fa828bfe5 100644 --- a/plugins/lang-js/rules/storybook/adopt/docs/main.md +++ b/plugins/lang-js/rules/storybook/adopt/docs/main.md @@ -3,9 +3,8 @@ type: JS Module title: main.mjs resource: plugins/lang-js/rules/storybook/adopt/main.mjs docgen: - crc: 640409c7 + crc: c8e6e421 model: openai-codex/gpt-5.4-mini - tier: cloud-min score: 90 issues: anchor-miss:(mocking.mdc),anchor-miss:(vitest-config.mdc),judge:inaccurate:0.99 judgeModel: openai-codex/gpt-5.4-mini diff --git a/plugins/lang-js/rules/storybook/adopt/main.mjs b/plugins/lang-js/rules/storybook/adopt/main.mjs index 26143cf57..36b95dd34 100644 --- a/plugins/lang-js/rules/storybook/adopt/main.mjs +++ b/plugins/lang-js/rules/storybook/adopt/main.mjs @@ -29,9 +29,10 @@ import { classifyProjects, findProperty, findTestObject, + hasStoriesMarker, parseModule, + PROVIDER_FACTORY_RE, resolveVitestConfigPath, - STORIES_RE, strykerConfigPathFor } from '../vitest-config/main.mjs' @@ -167,7 +168,9 @@ function diagnoseVitestProjectsSection(entry) { const missingHints = [] if (!CHROMIUM_RE.test(storybookSlice)) missingHints.push('chromium-інстанс') if (!BROWSER_KEY_RE.test(storybookSlice)) missingHints.push('browser-mode') - if (!STORIES_RE.test(storybookSlice)) missingHints.push('stories-glob') + if (!hasStoriesMarker(storybookSlice)) missingHints.push('stories-джерело (include або storybookTest({ configDir }))') + if (!PROVIDER_FACTORY_RE.test(storybookSlice)) + missingHints.push('provider-factory (playwright() з @vitest/browser-playwright)') if (missingHints.length > 0) { return { name, file, status: STATUS.DIFFER, detail: `storybook-project без: ${missingHints.join(', ')}` } } diff --git a/plugins/lang-js/rules/storybook/adopt/tests/adopt.test.mjs b/plugins/lang-js/rules/storybook/adopt/tests/adopt.test.mjs index 6e4f6d016..6c832ad8c 100644 --- a/plugins/lang-js/rules/storybook/adopt/tests/adopt.test.mjs +++ b/plugins/lang-js/rules/storybook/adopt/tests/adopt.test.mjs @@ -75,7 +75,7 @@ async function writeCanonicalStorybookSetup(root, rootDir) { ' test: {', " name: 'storybook',", " include: ['src/components/**/*.stories.@(js|ts)'],", - " browser: { enabled: true, headless: true, provider: 'playwright', instances: [{ browser: 'chromium' }] }", + " browser: { enabled: true, headless: true, provider: playwright(), instances: [{ browser: 'chromium' }] }", ' }', ' }', ' ]', diff --git a/plugins/lang-js/rules/storybook/ci/concern.json b/plugins/lang-js/rules/storybook/ci/concern.json new file mode 100644 index 000000000..dd2d43669 --- /dev/null +++ b/plugins/lang-js/rules/storybook/ci/concern.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://unpkg.com/@7n/rules/schemas/concern.json", + "fixability": "config", + "requires": { "capability": "ci:github" }, + "lint": { + "scope": "full", + "glob": [ + "package.json", + "**/*.vue", + ".storybook/**", + ".github/actions/setup-playwright-chromium/action.yml", + ".github/workflows/lint-storybook.yml" + ] + } +} diff --git a/plugins/lang-js/rules/storybook/ci/docs/fix-ci.md b/plugins/lang-js/rules/storybook/ci/docs/fix-ci.md new file mode 100644 index 000000000..f44790249 --- /dev/null +++ b/plugins/lang-js/rules/storybook/ci/docs/fix-ci.md @@ -0,0 +1,39 @@ +--- +type: JS Module +title: fix-ci.mjs +resource: plugins/lang-js/rules/storybook/ci/fix-ci.mjs +docgen: + crc: 877b9feb + model: openai-codex/gpt-5.4-mini + tier: cloud-min + score: 90 + judgeModel: openai-codex/gpt-5.4-mini +--- + +## Огляд + +Порожній шаблон `storybook/ci`, з якого відтворюються репо-рівневі CI-артефакти: `setup-playwright-chromium` як verbatim-копія composite action для Playwright Chromium і `.github/workflows/lint-storybook.yml` із матрицею `strategy.matrix.package`, згенерованою з фактичного списку пакетів через `collectInScopeVuePackages`. Публічні anchors: `TEMPLATE_DIR`, `renderPlaywrightAction`, `renderPackageDirsYaml`, `renderStorybookWorkflow`, `patterns`. + +## Поведінка + +- **TEMPLATE_DIR** — вказує на каталог `template/` цього concern-а як джерело канонічних CI-шаблонів. +- **renderPlaywrightAction** — читає канонічний composite action `setup-playwright-chromium` із шаблону без змін. +- **renderPackageDirsYaml** — перетворює список коренів пакетів у YAML-рядки для матриці Storybook CI. +- **renderStorybookWorkflow** — збирає канонічний `.github/workflows/lint-storybook.yml`, підставляючи в нього матрицю пакетів зі скоупу репозиторію. +- **patterns** — описує T0-правила автофіксу для відновлення відсутніх CI-файлів цього concern-а. + +## Публічний API + +- TEMPLATE_DIR — Каталог `template/` цього concern-а. +- renderPlaywrightAction — Вміст канонічного composite action `setup-playwright-chromium` — verbatim з template. +- renderPackageDirsYaml — Рендерить YAML-фрагмент `matrix.package` списку — по одному `- ` на рядок, з тим +самим відступом, що й токен-рядок у шаблоні (10 пробілів — рівень елемента списку під +`strategy.matrix.package:`). `rootDir === '.'` (корінь монорепо) лишається `.` — валідний +`working-directory` для GitHub Actions. +- renderStorybookWorkflow — Вміст канонічного `.github/workflows/lint-storybook.yml` — template з підставленою +матрицею пакетів у скоупі. +- patterns — повертає набір шаблонів, за якими далі шукають або зіставляють потрібні значення. + +## Гарантії поведінки + +- (специфічних машинно-виведених гарантій немає) diff --git a/plugins/lang-js/rules/storybook/ci/docs/index.md b/plugins/lang-js/rules/storybook/ci/docs/index.md new file mode 100644 index 000000000..9b3dc4bf4 --- /dev/null +++ b/plugins/lang-js/rules/storybook/ci/docs/index.md @@ -0,0 +1,10 @@ +--- +type: Directory Index +title: plugins/lang-js/rules/storybook/ci +resource: plugins/lang-js/rules/storybook/ci/ +--- + +| Файл | Тип | +| ----------------------- | --------- | +| [fix-ci.mjs](fix-ci.md) | JS Module | +| [main.mjs](main.md) | JS Module | diff --git a/plugins/lang-js/rules/storybook/ci/docs/main.md b/plugins/lang-js/rules/storybook/ci/docs/main.md new file mode 100644 index 000000000..6e9868873 --- /dev/null +++ b/plugins/lang-js/rules/storybook/ci/docs/main.md @@ -0,0 +1,50 @@ +--- +type: JS Module +title: main.mjs +resource: plugins/lang-js/rules/storybook/ci/main.mjs +docgen: + crc: 65df5791 + model: openai-codex/gpt-5.4-mini + tier: cloud-min + score: 85 + issues: internal-name:collectInScopeVuePackages,anchor-miss:(storybook.mdc),judge:inaccurate:0.96 + judgeModel: openai-codex/gpt-5.4-mini +--- + +## Огляд + +Файл фіксує канонічні репо-шляхи й маркери для storybook CI, щоб у Vue component library пакетах у scope швидко знаходити правильні `.github/actions/setup-playwright-chromium/action.yml` і `.github/workflows/lint-storybook.yml` та зіставляти їх із повідомленнями в `storybook.mdc`. Експортовані константи-рядки: PLAYWRIGHT_ACTION_REL=".github/actions/setup-playwright-chromium/action.yml" — канонічний шлях до Playwright action; STORYBOOK_WORKFLOW_REL=".github/workflows/lint-storybook.yml" — канонічний шлях до workflow linting. Маркери повідомлень: PLAYWRIGHT_ACTION_MARKERS і STORYBOOK_WORKFLOW_MARKERS, які прив’язують перевірки до повідомлень у `storybook.mdc`. Це потрібно, щоб підтримувати єдиний storybook-канон без зайвого обходу `.github` і `.git`, із кешуванням результату в межах одного прогону. + +## Поведінка + +- PLAYWRIGHT_ACTION_REL — задає репо-рівневий шлях до канонічного composite action `.github/actions/setup-playwright-chromium/action.yml`, який перевіряється як частина storybook CI. +- STORYBOOK_WORKFLOW_REL — задає репо-рівневий шлях до канонічного workflow `.github/workflows/lint-storybook.yml`, що відповідає за швидкий storybook-лінт. +- PLAYWRIGHT_ACTION_MARKERS — описує канонічні ознаки composite action для storybook CI, щоб гарантувати кеш Playwright і встановлення лише chromium; у повідомленнях використовується прив’язка до `storybook.mdc`. +- STORYBOOK_WORKFLOW_MARKERS — описує канонічні ознаки workflow для storybook CI, щоб гарантувати запуск швидкого `vitest --project=storybook`; у повідомленнях використовується прив’язка до `storybook.mdc`. +- lint — перевіряє наявність storybook CI-канону лише для репозиторіїв із Vue component library пакетами в scope; якщо таких пакетів немає, завершується без зауважень. Свідомо не зачіпає `.github` і `.git` як окремі області обходу; кешує результат у межах одного прогону. + +## Публічний API + +- PLAYWRIGHT_ACTION_REL — Repo-relative шлях канонічного composite action (не per-package — один на репозиторій). +- STORYBOOK_WORKFLOW_REL — Repo-relative шлях канонічного workflow, що запускає `vitest --project=storybook`. +- PLAYWRIGHT_ACTION_MARKERS — Маркери канону composite action `setup-playwright-chromium` (ADR Кластер 5): кеш +`ms-playwright` через `actions/cache`, ключ від версії playwright, install лише chromium. +Текстовий пошук — той самий підхід, що й `MAIN_JS_MARKERS`/`PREVIEW_JS_MARKERS` у `scaffold`. +- STORYBOOK_WORKFLOW_MARKERS — Маркери канону `.github/workflows/lint-storybook.yml`: композитний Playwright-кеш ПІСЛЯ +setup-bun-deps, і швидкий `vitest --project=storybook` (ADR Кластер 5 — nightly-only +`@7n/test coverage`/mutation-testing на PR не запускається, лише цей швидкий шлях). +- lint — Detector concern-а `storybook/ci` (ADR Кластер 5, CI-частина): для репозиторіїв з бодай +одним Vue component library пакетом у скоупі Storybook (`collectInScopeVuePackages`) — +канонічний composite action `setup-playwright-chromium` (кеш Playwright-браузерів, лише +chromium) і канонічний `.github/workflows/lint-storybook.yml`, що запускає швидкий +`vitest --project=storybook` на PR. Гейтований `requires.capability: ci:github` — спить +у репозиторіях без плагіна `@7n/rules-ci-github` (немає `.github/workflows`). + +Nightly-only `@7n/test coverage` (mutation testing) — поза обсягом цього concern-а: ADR +Кластер 5 явно розділяє швидкий PR-шлях (цей concern) і nightly mutation-прогін, який +лишається окремою інфраструктурою `test/stryker_config`. + +## Гарантії поведінки + +- Кешує результати в межах одного прогону. +- Свідомо пропускає шляхи: `.github`, `.git`. diff --git a/plugins/lang-js/rules/storybook/ci/fix-ci.mjs b/plugins/lang-js/rules/storybook/ci/fix-ci.mjs new file mode 100644 index 000000000..a2c329e79 --- /dev/null +++ b/plugins/lang-js/rules/storybook/ci/fix-ci.mjs @@ -0,0 +1,105 @@ +/** + * T0-autofix для concern-а `storybook/ci` (ADR Кластер 5, CI-частина): відтворює канонічний + * composite action `setup-playwright-chromium` (кеш Playwright-браузерів, лише chromium) і + * канонічний `.github/workflows/lint-storybook.yml` з `template/` цього concern-а. + * + * Обидва файли — репо-рівневі (не per-package): composite action — verbatim-копія; workflow — + * матриця `strategy.matrix.package` генерується з фактичного списку пакетів у скоупі + * (`collectInScopeVuePackages`), щоб CI реально покривав усі Storybook-пакети репозиторію. + */ +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { readFile } from 'node:fs/promises' + +import { collectInScopeVuePackages } from '../scope/main.mjs' + +/** Каталог `template/` цього concern-а. */ +export const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'template') + +/** Токен у `lint-storybook.yml.snippet.yml`, що заміщається списком пакетів матриці. */ +const PACKAGE_DIRS_TOKEN = '__STORYBOOK_CI_PACKAGE_DIRS__' + +/** + * Вміст канонічного composite action `setup-playwright-chromium` — verbatim з template. + * @param {string} [templateDir] каталог template/ (за замовчуванням — цього concern-а) + * @returns {Promise} вміст `action.yml` + */ +export function renderPlaywrightAction(templateDir = TEMPLATE_DIR) { + return readFile(join(templateDir, 'setup-playwright-chromium.action.yml'), 'utf8') +} + +/** + * Рендерить YAML-фрагмент `matrix.package` списку — по одному `- ` на рядок, з тим + * самим відступом, що й токен-рядок у шаблоні (10 пробілів — рівень елемента списку під + * `strategy.matrix.package:`). `rootDir === '.'` (корінь монорепо) лишається `.` — валідний + * `working-directory` для GitHub Actions. + * @param {string[]} rootDirs відносні (posix) корені пакетів у скоупі + * @returns {string} YAML-рядки (без завершального переносу) + */ +export function renderPackageDirsYaml(rootDirs) { + return rootDirs.map(dir => ` - ${dir}`).join('\n') +} + +/** + * Вміст канонічного `.github/workflows/lint-storybook.yml` — template з підставленою + * матрицею пакетів у скоупі. + * @param {string[]} rootDirs відносні корені пакетів у скоупі (`collectInScopeVuePackages`) + * @param {string} [templateDir] каталог template/ (за замовчуванням — цього concern-а) + * @returns {Promise} вміст `lint-storybook.yml` + */ +export async function renderStorybookWorkflow(rootDirs, templateDir = TEMPLATE_DIR) { + const raw = await readFile(join(templateDir, 'lint-storybook.yml.snippet.yml'), 'utf8') + const tokenLineRe = new RegExp(`^[ \\t]*${PACKAGE_DIRS_TOKEN}[ \\t]*$`, 'mu') + // Replacer-функція (не рядок) — уникає unicorn/no-unsafe-string-replacement: + // рядок-заміна мав би трактувати `$`-послідовності як спецсимволи `String#replace`. + return raw.replace(tokenLineRe, () => renderPackageDirsYaml(rootDirs)) +} + +/** + * Записує файл, створюючи батьківські каталоги й реєструючи запис для rollback (той самий + * патерн, що й `writeScaffoldFile` у `scaffold/fix-scaffold.mjs`). + * @param {import('@7n/rules/scripts/lib/lint-surface/types.mjs').FixContext} ctx fix-контекст рунга + * @param {string} absPath абсолютний шлях цільового файлу + * @param {string} content вміст файлу + * @returns {void} + */ +function writeCiFile(ctx, absPath, content) { + mkdirSync(dirname(absPath), { recursive: true }) + ctx.recordWrite?.(absPath) + writeFileSync(absPath, content, 'utf8') +} + +/** @type {import('@7n/rules/scripts/lib/lint-surface/types.mjs').T0Pattern[]} */ +export const patterns = [ + { + id: 'storybook-ci-playwright-action', + test: violations => violations.some(v => v.reason === 'missing-playwright-action' && v.file), + apply: async (violations, ctx) => { + const target = violations.find(v => v.reason === 'missing-playwright-action' && v.file) + if (!target || !ctx.concernDir) return { touchedFiles: [] } + + const content = await renderPlaywrightAction(join(ctx.concernDir, 'template')) + const abs = join(ctx.cwd, target.file) + writeCiFile(ctx, abs, content) + return { touchedFiles: [abs], message: `${target.file}: створено composite action setup-playwright-chromium` } + } + }, + { + id: 'storybook-ci-workflow', + test: violations => violations.some(v => v.reason === 'missing-storybook-workflow' && v.file), + apply: async (violations, ctx) => { + const target = violations.find(v => v.reason === 'missing-storybook-workflow' && v.file) + if (!target || !ctx.concernDir) return { touchedFiles: [] } + + const pkgs = await collectInScopeVuePackages(ctx.cwd) + const rootDirs = pkgs.map(p => p.rootDir) + if (rootDirs.length === 0) return { touchedFiles: [] } + + const content = await renderStorybookWorkflow(rootDirs, join(ctx.concernDir, 'template')) + const abs = join(ctx.cwd, target.file) + writeCiFile(ctx, abs, content) + return { touchedFiles: [abs], message: `${target.file}: створено (${rootDirs.length} пакет(ів) у матриці)` } + } + } +] diff --git a/plugins/lang-js/rules/storybook/ci/main.mjs b/plugins/lang-js/rules/storybook/ci/main.mjs new file mode 100644 index 000000000..cdce4a6c9 --- /dev/null +++ b/plugins/lang-js/rules/storybook/ci/main.mjs @@ -0,0 +1,112 @@ +/** @see ./docs/main.md */ +import { existsSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { createViolationReporter } from '@7n/rules/scripts/lib/lint-surface/violation-reporter.mjs' +import { collectInScopeVuePackages } from '../scope/main.mjs' +import { missingMarkers } from '../scaffold/main.mjs' + +/** Repo-relative шлях канонічного composite action (не per-package — один на репозиторій). */ +export const PLAYWRIGHT_ACTION_REL = '.github/actions/setup-playwright-chromium/action.yml' + +/** Repo-relative шлях канонічного workflow, що запускає `vitest --project=storybook`. */ +export const STORYBOOK_WORKFLOW_REL = '.github/workflows/lint-storybook.yml' + +/** + * Маркери канону composite action `setup-playwright-chromium` (ADR Кластер 5): кеш + * `ms-playwright` через `actions/cache`, ключ від версії playwright, install лише chromium. + * Текстовий пошук — той самий підхід, що й `MAIN_JS_MARKERS`/`PREVIEW_JS_MARKERS` у `scaffold`. + */ +export const PLAYWRIGHT_ACTION_MARKERS = [ + { token: 'ms-playwright', hint: 'кеш каталогу ms-playwright' }, + { token: 'actions/cache@', hint: 'actions/cache для Playwright-браузерів' }, + { token: 'playwright install chromium', hint: 'install лише chromium (не всі браузери)' } +] + +/** + * Маркери канону `.github/workflows/lint-storybook.yml`: композитний Playwright-кеш ПІСЛЯ + * setup-bun-deps, і швидкий `vitest --project=storybook` (ADR Кластер 5 — nightly-only + * `@7n/test coverage`/mutation-testing на PR не запускається, лише цей швидкий шлях). + */ +export const STORYBOOK_WORKFLOW_MARKERS = [ + { token: './.github/actions/setup-bun-deps', hint: 'setup-bun-deps перед Playwright-кроком' }, + { token: './.github/actions/setup-playwright-chromium', hint: 'композитний Playwright-кеш' }, + { token: '--project=storybook', hint: 'швидкий прогін лише storybook-проєкту (не повний coverage)' } +] + +/** + * Перевіряє один репо-рівневий канонічний файл (composite action чи workflow): відсутність → + * `missingReason`-порушення з посиланням на `npx \@7n/rules fix storybook`; присутність без + * якогось маркера → `markerReason`-порушення на конкретний маркер. Той самий патерн, що й + * `checkCanonFile` у `scaffold/main.mjs`, але без per-package `rootDir` — файл один на репо. + * @param {string} cwd абсолютний корінь репозиторію + * @param {string} relFile posix-relative шлях файлу від кореня репозиторію + * @param {{ token: string, hint: string }[]} markers канонічні маркери файлу + * @param {string} missingReason reason для порушення "файл відсутній" + * @param {string} markerReason reason для порушення "маркер відсутній" + * @param {ReturnType} reporter reporter поточного лінту + * @returns {Promise} + */ +async function checkRepoCanonFile(cwd, relFile, markers, missingReason, markerReason, reporter) { + const abs = join(cwd, relFile) + if (existsSync(abs)) { + const content = await readFile(abs, 'utf8') + for (const m of missingMarkers(content, markers)) { + reporter.fail(`${relFile} не відповідає канону — бракує: ${m.hint} (storybook.mdc, ADR Кластер 5)`, { + reason: markerReason, + file: relFile + }) + } + return + } + reporter.fail( + `Відсутній ${relFile} — канонічний Playwright-кеш для vitest storybook-проєкту: npx @7n/rules fix storybook (storybook.mdc, ADR Кластер 5)`, + { reason: missingReason, file: relFile } + ) +} + +/** + * Detector concern-а `storybook/ci` (ADR Кластер 5, CI-частина): для репозиторіїв з бодай + * одним Vue component library пакетом у скоупі Storybook (`collectInScopeVuePackages`) — + * канонічний composite action `setup-playwright-chromium` (кеш Playwright-браузерів, лише + * chromium) і канонічний `.github/workflows/lint-storybook.yml`, що запускає швидкий + * `vitest --project=storybook` на PR. Гейтований `requires.capability: ci:github` — спить + * у репозиторіях без плагіна `@7n/rules-ci-github` (немає `.github/workflows`). + * + * Nightly-only `@7n/test coverage` (mutation testing) — поза обсягом цього concern-а: ADR + * Кластер 5 явно розділяє швидкий PR-шлях (цей concern) і nightly mutation-прогін, який + * лишається окремою інфраструктурою `test/stryker_config`. + * @param {import('@7n/rules/scripts/lib/lint-surface/types.mjs').LintContext} ctx контекст лінту + * @returns {Promise} результат лінту + */ +export async function lint(ctx) { + const reporter = createViolationReporter(ctx) + const cwd = ctx.cwd + + const pkgs = await collectInScopeVuePackages(cwd) + if (pkgs.length === 0) { + reporter.pass('storybook/ci: немає Vue component library пакетів у скоупі (storybook.mdc)') + return reporter.result() + } + + await checkRepoCanonFile( + cwd, + PLAYWRIGHT_ACTION_REL, + PLAYWRIGHT_ACTION_MARKERS, + 'missing-playwright-action', + 'playwright-action-marker-missing', + reporter + ) + + await checkRepoCanonFile( + cwd, + STORYBOOK_WORKFLOW_REL, + STORYBOOK_WORKFLOW_MARKERS, + 'missing-storybook-workflow', + 'storybook-workflow-marker-missing', + reporter + ) + + return reporter.result() +} diff --git a/plugins/lang-js/rules/storybook/ci/template/lint-storybook.yml.snippet.yml b/plugins/lang-js/rules/storybook/ci/template/lint-storybook.yml.snippet.yml new file mode 100644 index 000000000..6aa010285 --- /dev/null +++ b/plugins/lang-js/rules/storybook/ci/template/lint-storybook.yml.snippet.yml @@ -0,0 +1,41 @@ +name: Lint Storybook + +on: + push: + branches: + - dev + - main + paths: + - '**/*.stories.@(js|ts)' + - '.storybook/**' + pull_request: + branches: + - dev + - main + +concurrency: + group: ${{ github.ref }}-${{ github.workflow }} + cancel-in-progress: true + +jobs: + storybook-test: + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + package: + __STORYBOOK_CI_PACKAGE_DIRS__ + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: ./.github/actions/setup-bun-deps + + - uses: ./.github/actions/setup-playwright-chromium + + - name: Storybook vitest project (${{ matrix.package }}) + working-directory: ${{ matrix.package }} + run: bunx vitest run --project=storybook diff --git a/plugins/lang-js/rules/storybook/ci/template/setup-playwright-chromium.action.yml b/plugins/lang-js/rules/storybook/ci/template/setup-playwright-chromium.action.yml new file mode 100644 index 000000000..f063b516d --- /dev/null +++ b/plugins/lang-js/rules/storybook/ci/template/setup-playwright-chromium.action.yml @@ -0,0 +1,34 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-action.json + +name: Setup Playwright Chromium +description: >- + Кеш Playwright-браузерів (лише chromium, storybook.mdc ADR Кластер 5) для vitest + browser-mode storybook-проєкту. Викликати ПІСЛЯ ./.github/actions/setup-bun-deps + (потрібен node_modules/bun.lock для визначення версії). + +runs: + using: composite + steps: + - name: Resolve Playwright version (bun.lock) + id: playwright-version + shell: bash + run: | + VERSION=$(grep -oE '"playwright(-core)?@[0-9]+\.[0-9]+\.[0-9]+"' bun.lock | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true) + if [ -z "$VERSION" ]; then + VERSION=$(node -p "require('playwright-core/package.json').version" 2>/dev/null || echo unknown) + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browsers (chromium) + id: playwright-cache + uses: actions/cache@v5 + with: + path: | + ~/.cache/ms-playwright + ~/Library/Caches/ms-playwright + key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium + + - name: Install Playwright chromium (лише chromium, storybook.mdc) + if: steps.playwright-cache.outputs.cache-hit != 'true' + shell: bash + run: bunx playwright install chromium --with-deps diff --git a/plugins/lang-js/rules/storybook/ci/tests/ci.test.mjs b/plugins/lang-js/rules/storybook/ci/tests/ci.test.mjs new file mode 100644 index 000000000..c54d39eb4 --- /dev/null +++ b/plugins/lang-js/rules/storybook/ci/tests/ci.test.mjs @@ -0,0 +1,199 @@ +/** + * Тести concern-а `storybook/ci` (storybook.mdc, ADR Кластер 5 — CI-частина): виявлення + * відсутнього/неповного composite action `setup-playwright-chromium` і відсутнього/неповного + * `.github/workflows/lint-storybook.yml`, а також T0-autofix (`fix-ci.mjs`), що відтворює + * обидва файли з `template/`. Фікстури — динамічні тимчасові дерева (mkdtemp), не статичні + * файли в репо (щоб авто-fix лінтера цього репозиторію не переписав "погані"/неповні зразки). + */ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, test } from 'vitest' + +import { lint, PLAYWRIGHT_ACTION_REL, STORYBOOK_WORKFLOW_REL } from '../main.mjs' +import { patterns, renderPackageDirsYaml, renderPlaywrightAction, renderStorybookWorkflow } from '../fix-ci.mjs' + +const CONCERN_DIR = join(import.meta.dirname, '..') + +/** + * @param {string} root абсолютний шлях + * @param {string} rel відносний шлях файлу + * @param {string} content вміст + */ +async function writeFileDeep(root, rel, content) { + const abs = join(root, rel) + await mkdir(dirname(abs), { recursive: true }) + await writeFile(abs, content, 'utf8') +} + +/** + * Створює мінімальний Vue-пакет-бібліотеку у скоупі (peerDependencies.vue, ≥3 .vue, + * vite.config.js) — той самий фікстур-набір, що й у scaffold/vitest-config/hygiene тестах. + * @param {string} root корінь монорепо + * @param {string} rootDir відносний корінь пакета + */ +async function writeVueLibraryPkg(root, rootDir) { + const pkg = { name: `pkg-${rootDir}`, peerDependencies: { vue: '^3.6.0' } } + await writeFileDeep(root, join(rootDir, 'package.json'), JSON.stringify(pkg, null, 2)) + await writeFileDeep(root, join(rootDir, 'vite.config.js'), 'export default {}\n') + for (let i = 0; i < 3; i++) { + await writeFileDeep(root, join(rootDir, `src/components/Comp${i}.vue`), '\n') + } +} + +describe('lint: перевірка storybook/ci', () => { + let root + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'storybook-ci-')) + await writeFileDeep(root, 'package.json', JSON.stringify({ name: 'root', workspaces: ['packages/*'] }, null, 2)) + }) + + afterEach(async () => { + await rm(root, { recursive: true, force: true }) + }) + + test('немає пакетів у скоупі — без порушень', async () => { + const result = await lint({ cwd: root, ruleId: 'storybook', concernId: 'storybook/ci' }) + expect(result.violations).toEqual([]) + }) + + test('пакет у скоупі, без composite action і без workflow — 2 порушення missing', async () => { + await writeVueLibraryPkg(root, 'packages/ui') + const result = await lint({ cwd: root, ruleId: 'storybook', concernId: 'storybook/ci' }) + const reasons = result.violations.map(v => v.reason).toSorted() + expect(reasons).toEqual(['missing-playwright-action', 'missing-storybook-workflow']) + expect(result.violations.find(v => v.reason === 'missing-playwright-action').file).toBe(PLAYWRIGHT_ACTION_REL) + expect(result.violations.find(v => v.reason === 'missing-storybook-workflow').file).toBe(STORYBOOK_WORKFLOW_REL) + }) + + test('composite action без канонічних маркерів — marker-порушення, не missing', async () => { + await writeVueLibraryPkg(root, 'packages/ui') + await writeFileDeep(root, PLAYWRIGHT_ACTION_REL, 'name: Setup Playwright Chromium\nruns:\n using: composite\n') + const rootDirs = ['packages/ui'] + await writeFileDeep( + root, + STORYBOOK_WORKFLOW_REL, + await renderStorybookWorkflow(rootDirs, join(CONCERN_DIR, 'template')) + ) + + const result = await lint({ cwd: root, ruleId: 'storybook', concernId: 'storybook/ci' }) + const reasons = result.violations.map(v => v.reason) + expect(reasons.length).toBeGreaterThan(0) + expect(reasons.every(r => r === 'playwright-action-marker-missing')).toBe(true) + }) + + test('workflow без канонічних маркерів — marker-порушення, не missing', async () => { + await writeVueLibraryPkg(root, 'packages/ui') + await writeFileDeep(root, PLAYWRIGHT_ACTION_REL, await renderPlaywrightAction(join(CONCERN_DIR, 'template'))) + await writeFileDeep( + root, + STORYBOOK_WORKFLOW_REL, + 'name: Lint Storybook\non: push\njobs:\n test:\n runs-on: ubuntu-latest\n' + ) + + const result = await lint({ cwd: root, ruleId: 'storybook', concernId: 'storybook/ci' }) + const reasons = result.violations.map(v => v.reason) + expect(reasons.length).toBeGreaterThan(0) + expect(reasons.every(r => r === 'storybook-workflow-marker-missing')).toBe(true) + }) + + test('повністю канонічний репо — без порушень', async () => { + await writeVueLibraryPkg(root, 'packages/ui') + await writeFileDeep(root, PLAYWRIGHT_ACTION_REL, await renderPlaywrightAction(join(CONCERN_DIR, 'template'))) + await writeFileDeep( + root, + STORYBOOK_WORKFLOW_REL, + await renderStorybookWorkflow(['packages/ui'], join(CONCERN_DIR, 'template')) + ) + + const result = await lint({ cwd: root, ruleId: 'storybook', concernId: 'storybook/ci' }) + expect(result.violations).toEqual([]) + }) +}) + +describe('renderPackageDirsYaml', () => { + test('рендерить по одному елементу на рядок з відступом рівня matrix.package', () => { + expect(renderPackageDirsYaml(['.', 'packages/ui'])).toBe(' - .\n - packages/ui') + }) +}) + +describe('renderStorybookWorkflow', () => { + test('підставляє матрицю пакетів у валідний YAML з канонічними маркерами', async () => { + const content = await renderStorybookWorkflow(['packages/ui', 'packages/forms'], join(CONCERN_DIR, 'template')) + expect(content).toContain('- packages/ui') + expect(content).toContain('- packages/forms') + expect(content).toContain('./.github/actions/setup-playwright-chromium') + expect(content).toContain('--project=storybook') + expect(content).not.toContain('__STORYBOOK_CI_PACKAGE_DIRS__') + }) +}) + +describe('fix-ci: T0 autofix відтворює канонічні файли', () => { + let root + let recordedWrites + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'storybook-ci-fix-')) + await writeFileDeep(root, 'package.json', JSON.stringify({ name: 'root', workspaces: ['packages/*'] }, null, 2)) + recordedWrites = [] + }) + + afterEach(async () => { + await rm(root, { recursive: true, force: true }) + }) + + /** @returns {import('@7n/rules/scripts/lib/lint-surface/types.mjs').FixContext} fix-контекст рунга для тесту */ + function fixCtx() { + return { + cwd: root, + ruleId: 'storybook', + concernId: 'storybook/ci', + concernDir: CONCERN_DIR, + tier: 'local-min', + recordWrite: abs => { + recordedWrites.push(abs) + } + } + } + + test('storybook-ci-playwright-action: створює composite action verbatim з шаблону', async () => { + const pattern = patterns.find(p => p.id === 'storybook-ci-playwright-action') + const violations = [{ reason: 'missing-playwright-action', message: 'x', file: PLAYWRIGHT_ACTION_REL }] + expect(pattern.test(violations)).toBe(true) + + const result = await pattern.apply(violations, fixCtx()) + expect(result.touchedFiles).toHaveLength(1) + const written = await readFile(join(root, PLAYWRIGHT_ACTION_REL), 'utf8') + expect(written).toContain('ms-playwright') + expect(written).toContain('playwright install chromium') + expect(recordedWrites).toEqual(result.touchedFiles) + }) + + test('storybook-ci-workflow: створює lint-storybook.yml з матрицею фактичних пакетів у скоупі', async () => { + await writeFileDeep(root, 'packages/ui/package.json', JSON.stringify({ peerDependencies: { vue: '^3.6.0' } })) + await writeFileDeep(root, 'packages/ui/vite.config.js', 'export default {}\n') + for (let i = 0; i < 3; i++) { + await writeFileDeep(root, `packages/ui/src/components/Comp${i}.vue`, '\n') + } + + const pattern = patterns.find(p => p.id === 'storybook-ci-workflow') + const violations = [{ reason: 'missing-storybook-workflow', message: 'x', file: STORYBOOK_WORKFLOW_REL }] + expect(pattern.test(violations)).toBe(true) + + const result = await pattern.apply(violations, fixCtx()) + expect(result.touchedFiles).toHaveLength(1) + const written = await readFile(join(root, STORYBOOK_WORKFLOW_REL), 'utf8') + expect(written).toContain('- packages/ui') + expect(written).toContain('--project=storybook') + expect(recordedWrites).toEqual(result.touchedFiles) + }) + + test('storybook-ci-workflow: без пакетів у скоупі — нічого не пише', async () => { + const pattern = patterns.find(p => p.id === 'storybook-ci-workflow') + const violations = [{ reason: 'missing-storybook-workflow', message: 'x', file: STORYBOOK_WORKFLOW_REL }] + const result = await pattern.apply(violations, fixCtx()) + expect(result.touchedFiles).toEqual([]) + }) +}) diff --git a/plugins/lang-js/rules/storybook/main.mdc b/plugins/lang-js/rules/storybook/main.mdc index db0f53697..70a0e2a28 100644 --- a/plugins/lang-js/rules/storybook/main.mdc +++ b/plugins/lang-js/rules/storybook/main.mdc @@ -31,7 +31,7 @@ alwaysApply: false - **Порядок Vite-плагінів фіксований**: `@vitejs/plugin-vue` **перед** `quasar()` — інакше Quasar-плагін не бачить SFC, уже скомпільований `plugin-vue`. - **Layout-детекція**: `src/components/` присутній → stories-glob звужується до нього; пласка структура (`src/` без `components/`) — ширший glob по всьому `src/`. -- **`viteFinal`** зчитує `vite.config` самого пакета (той самий, що й для звичайного білду) і мерджить його плагіни в конфіг Storybook, **знімаючи** `vite-plugin-pages`/`vite-plugin-vue-layouts` — файлова маршрутизація належить додатку-споживачу, не ізольованому рендеру компонента. +- **`viteFinal`** зчитує `vite.config` самого пакета (той самий, що й для звичайного білду) і мерджить його плагіни в конфіг Storybook, **знімаючи** роутинг-плагіни файлової маршрутизації додатка-споживача (`vite-plugin-pages`, `unplugin-vue-router`, `vite-plugin-vue-layouts`, `vite-plugin-vue-layouts-next`) — вони не мають сенсу в ізольованому рендері компонента. Плагіни `plugins`-масиву можуть бути `Promise`/вкладеними масивами (`VueMacros({ plugins: { vue: Vue() } })` — реальний стек пілотного консюмера повертає `Promise`, що резолвиться в масив плагінів) — фільтр спершу resolve/flatten-ить їх, інакше порівняння імені з `Promise`, що ще не резолвився, мовчки пропускає дублікат `@vitejs/plugin-vue`-трансформу (подвійна SFC-трансформація). Vue-трансформери фільтруються за сімейством імені (`vite:vue`-префікс або підрядок `vue-macros`), не лише буквальним `@vitejs/plugin-vue` — деталі й обґрунтування коментарем у `scaffold/template/main.js`. - **`staticDirs`** покриває `.storybook/public` — статичний asset для msw service worker (`preview.js`). - **`preview.js`**: повний `Quasar`-install (не тільки окремі компоненти) + `iconSet`+`iconMapFn`-комбо — без цієї пари внутрішні Quasar-компоненти (напр. стрілка `QSelect`) не резолвлять вбудовані іконки поза full CLI build. `msw-storybook-addon` ініціалізується з `onUnhandledRequest`-фільтром: **same-origin GET мовчки пропускається** (Vite HMR/asset-шум), решта — попередження (не білд-помилка — навмисно м'яко для хвилі 1). - **`.storybook/mocks/gql-sse.js`**: єдиний канонічний хелпер `sseSubscription` для MSW-мокання Apollo-підписок через wire-протокол `graphql-sse` (`event: next\ndata: …`, distinct-connection mode) — переносити цю логіку в кожен пакет окремо заборонено, є одне джерело істини. @@ -43,6 +43,12 @@ alwaysApply: false Канонічний `test.projects` (`unit`+`storybook`, browser-mode лише chromium) і ізольований `vitest.stryker.config` (той самий unit-набір, без browser-mode — `@stryker-mutator/vitest-runner` крашиться на browser-mode `projects`) — `vitest-config/main.mjs` (перевірка, AST через `oxc-parser`) і `vitest-config/fix-vitest-config.mjs` (точкові insert-only правки наявного конфіга, fixability: `config`). Деталі канону, чому саме chromium і межі автофіксу — `vitest-config/vitest-config.mdc`, не дублюється тут. +### CI: Playwright-кеш і швидкий PR-прогін (Кластер 5, CI-частина) + +`ci/main.mjs` + `ci/fix-ci.mjs` (fixability: `config`, `requires.capability: ci:github` — спить у репозиторіях без плагіна `@7n/rules-ci-github`): для кожного репозиторію з бодай одним пакетом у скоупі — канонічний composite action `.github/actions/setup-playwright-chromium/action.yml` (кеш `~/.cache/ms-playwright`/`~/Library/Caches/ms-playwright` за версією playwright з `bun.lock`, install **лише** `chromium --with-deps` при cache miss) і `.github/workflows/lint-storybook.yml` (матриця `strategy.matrix.package` — фактичні пакети у скоупі, `checkout` → `setup-bun-deps` → `setup-playwright-chromium` → `vitest run --project=storybook`). Композитний action і workflow — репо-рівневі файли (не per-package), перевірка й автофікс не per-package. + +Nightly-only `@7n/test coverage` (mutation testing) — свідомо поза цим concern-ом: ADR розділяє швидкий PR-шлях (`--project=storybook`, цей concern) і nightly mutation-прогін, який лишається окремою інфраструктурою (`test/stryker_config`) і не дублюється тут. + ## Гігієна сторонніх залежностей (Кластер 6) `hygiene/main.mjs`: (1) undeclared third-party imports у `.vue`-файлах пакета — import стороннього пакета, якого немає в `dependencies`/`peerDependencies` (реальний кейс ADR — зламаний default-export `@vuepic/vue-datepicker` v14, silent breakage без цієї перевірки); (2) наявність `src/css/quasar.variables.{scss,sass}` без відповідного `quasar({ sassVariables: true })` у `.storybook/main.js` — глобальні Quasar SCSS-змінні пакета інакше не резолвляться в ізольованому Storybook-рендері. Docs-only детальний виклад не потрібен — концерн самодостатній, без окремого `.mdc`. diff --git a/plugins/lang-js/rules/storybook/mocking/mocking.mdc b/plugins/lang-js/rules/storybook/mocking/mocking.mdc index 577be56e2..27fbdb578 100644 --- a/plugins/lang-js/rules/storybook/mocking/mocking.mdc +++ b/plugins/lang-js/rules/storybook/mocking/mocking.mdc @@ -73,6 +73,30 @@ export const Realtime = { } ``` +### Точковий alias-мок одного модуля + +**Скоуп цього рецепта — компонентні бібліотеки, НЕ сторінки.** Рішення «виключно MSW» вище (розділ «Apollo/GraphQL — виключно MSW») стосується сторінок (розширення ADR, `2026-07-20`) — там повний data-flow (query+mutation+subscription, кілька викликів на сторінку), і мок-хендлери мають бути переносними в майбутні vitest/playwright-сценарії. Реальний кейс компонентної бібліотеки (пілот `components/npm`) — інший: один компонент статично імпортує модуль із side-effect на рівні build-у (напр. `@nitra/vite-boot/apollo` — конструює Apollo-клієнт із `VITE_HASURA_HOST`/WS-link env-змінних, яких немає в Storybook), і компонент має 1-2 точкові виклики цього модуля, не повний GraphQL data-flow. Розгортати MSW-стек (`msw-storybook-addon`, worker, handlers) заради одного статичного імпорту — зайві накладні витрати, надто коли `msw` навіть не встановлений у пакеті. + +Рецепт — build-time підміна імпорту на рівні Vite-resolve у `viteFinal` `.storybook/main.js` (не мокає мережу, підміняє сам модуль): + +```js +async viteFinal(config) { + const { mergeConfig } = await import('vite') + return mergeConfig(config, { + resolve: { + alias: { + // NImages.vue статично імпортує apolloClient/gql (не provide/inject) — реальний + // модуль конструює Apollo-клієнт з env-змінних, яких немає в Storybook. + // Підміна на рівні Vite-resolve замість зміни коду компонента. + '@nitra/vite-boot/apollo': join(dirName, 'mocks/apollo.js') + } + } + }) +} +``` + +**Критерій вибору:** компонент з 1-2 точковими викликами стороннього модуля (side-effect boot/client-конструктор, не повний query/mutation/subscription-flow) → alias-мок одного модуля. Сторінки, підписки чи повний GraphQL data-flow (кілька query/mutation, стан, error/loading-сценарії) → MSW (розділ вище) — не супереч рішенню розширення ADR, а розмежування скоупу: alias — для компонентних бібліотек із мінімальним мережевим слідом, MSW — для сторінок і повнішого data-flow. + ### Pinia Справжня `createPinia()` у page-декораторі — **без** `pinia-plugin-persistedstate` (`persist: true` у сторі стає no-op, читання/запис localStorage не потрібні в ізольованому рендері). Наповнення початкового стану — через `parameters.pinia.initialState`, не хардкод у декораторі: diff --git a/plugins/lang-js/rules/storybook/scaffold/docs/main.md b/plugins/lang-js/rules/storybook/scaffold/docs/main.md index 6cdf97cc9..39cbecd56 100644 --- a/plugins/lang-js/rules/storybook/scaffold/docs/main.md +++ b/plugins/lang-js/rules/storybook/scaffold/docs/main.md @@ -3,7 +3,7 @@ type: JS Module title: main.mjs resource: plugins/lang-js/rules/storybook/scaffold/main.mjs docgen: - crc: c06a9e15 + crc: 2cad0405 model: openai-codex/gpt-5.4-mini score: 100 issues: judge:inaccurate:0.98 diff --git a/plugins/lang-js/rules/storybook/scaffold/main.mjs b/plugins/lang-js/rules/storybook/scaffold/main.mjs index b88616f0d..874a194fe 100644 --- a/plugins/lang-js/rules/storybook/scaffold/main.mjs +++ b/plugins/lang-js/rules/storybook/scaffold/main.mjs @@ -1,5 +1,5 @@ /** @see ./docs/main.md */ -import { existsSync } from 'node:fs' +import { existsSync, readdirSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { join } from 'node:path' @@ -17,7 +17,16 @@ export const MAIN_JS_MARKERS = [ { token: '@storybook/vue3-vite', hint: 'framework @storybook/vue3-vite' }, { token: 'viteFinal', hint: 'viteFinal-override vite.config пакета' }, { token: "'vite-plugin-pages'", hint: 'фільтр vite-plugin-pages у viteFinal' }, - { token: "'vite-plugin-vue-layouts'", hint: 'фільтр vite-plugin-vue-layouts у viteFinal' } + { token: "'vite-plugin-vue-layouts'", hint: 'фільтр vite-plugin-vue-layouts у viteFinal' }, + { token: "'vite-plugin-vue-layouts-next'", hint: 'фільтр vite-plugin-vue-layouts-next у viteFinal' }, + { + token: 'isVueTransformFamily', + hint: 'сімейний фільтр vue-трансформерів (vite:vue/vue-macros) — стійкість до VueMacros-стека' + }, + { + token: 'resolvePluginEntry', + hint: 'resolve/flatten Promise/масиву плагінів перед фільтрацією (VueMacros повертає Promise)' + } ] /** Маркери канону `.storybook/preview.js`. Експортовано — переюз у `adopt/main.mjs`. */ @@ -30,13 +39,36 @@ export const PREVIEW_JS_MARKERS = [ ] /** - * Layout-детекція для stories-glob (ADR Кластер 2): `src/components/` присутній → glob - * звужується до нього; пласка структура (`src/` без `components/`) — ширший glob по `src/`. - * Шлях відносний до `.storybook/` (де лежить сам `main.js`), тому з префіксом `../`. + * Чи має корінь пакета плоскі `.vue`-файли (flat-root layout — `NDialog.vue`, + * `NDialog.stories.js` лежать прямо в КОРЕНІ пакета, `src/` майже порожній чи + * відсутній). Реальний кейс пілотного консюмера (components/npm) — component + * library без `src/components/`, детекція за самою наявністю `src/` дала б 0 + * знайдених історій (тихий регрес adopt-діагностики). Перевірка нерекурсивна — + * дивиться лише файли безпосередньо в `absPkgDir`. + * @param {string} absPkgDir абсолютний шлях кореня пакета + * @returns {boolean} true — у корені пакета є хоча б один `.vue`-файл + */ +function hasFlatRootVueFiles(absPkgDir) { + let entries + try { + entries = readdirSync(absPkgDir, { withFileTypes: true }) + } catch { + return false + } + return entries.some(e => e.isFile() && e.name.endsWith('.vue')) +} + +/** + * Layout-детекція для stories-glob (ADR Кластер 2, розширено пілотом на flat-root): + * `.vue`-файли прямо в корені пакета (без `src/`) → flat-root glob по корені; + * інакше `src/components/` присутній → glob звужується до нього; інакше — ширший + * glob по всьому `src/`. Шлях відносний до `.storybook/` (де лежить сам `main.js`), + * тому з префіксом `../`. * @param {string} absPkgDir абсолютний шлях кореня пакета * @returns {string} glob для `stories` у `.storybook/main.js` */ export function detectStoriesGlob(absPkgDir) { + if (hasFlatRootVueFiles(absPkgDir)) return '../*.stories.@(js|ts)' return existsSync(join(absPkgDir, 'src/components')) ? '../src/components/**/*.stories.@(js|ts)' : '../src/**/*.stories.@(js|ts)' diff --git a/plugins/lang-js/rules/storybook/scaffold/template/docs/main.md b/plugins/lang-js/rules/storybook/scaffold/template/docs/main.md index dd4347125..178af66d3 100644 --- a/plugins/lang-js/rules/storybook/scaffold/template/docs/main.md +++ b/plugins/lang-js/rules/storybook/scaffold/template/docs/main.md @@ -3,9 +3,8 @@ type: JS Module title: main.js resource: plugins/lang-js/rules/storybook/scaffold/template/main.js docgen: - crc: 5b21e6e2 + crc: 74cf3397 model: openai-codex/gpt-5.4-mini - tier: cloud-min score: 100 issues: judge:inaccurate:0.96 judgeModel: openai-codex/gpt-5.4-mini diff --git a/plugins/lang-js/rules/storybook/scaffold/template/main.js b/plugins/lang-js/rules/storybook/scaffold/template/main.js index 068cb16b6..12ab7e28c 100644 --- a/plugins/lang-js/rules/storybook/scaffold/template/main.js +++ b/plugins/lang-js/rules/storybook/scaffold/template/main.js @@ -12,8 +12,53 @@ import { quasar, transformAssetUrls } from '@quasar/vite-plugin' const OWN_PLUGINS = [vue({ template: { transformAssetUrls } }), quasar({ sassVariables: true })] // Плагіни файлової маршрутизації додатка-споживача — не мають сенсу в ізольованому -// рендері одного компонента, знімаються при обʼєднанні з vite.config пакета. -const INCOMPATIBLE_PLUGIN_NAMES = new Set(['vite-plugin-pages', 'vite-plugin-vue-layouts']) +// рендері одного компонента, знімаються при обʼєднанні з vite.config пакета. Обидві +// версії layouts-пакета (старий і `-next`-форк) та pages-роутери — реальні консюмери +// мігрують поступово, старий і новий пакет можуть співіснувати в різних vite.config. +const INCOMPATIBLE_PLUGIN_NAMES = new Set([ + 'vite-plugin-pages', + 'unplugin-vue-router', + 'vite-plugin-vue-layouts', + 'vite-plugin-vue-layouts-next' +]) + +/** + * Плагін належить до сімейства Vue SFC-трансформерів, які ДУБЛЮЮТЬ `OWN_PLUGINS`' + * `vue()`. Реальний стек консюмерів (components/npm/vite.config.js) обгортає + * `@vitejs/plugin-vue` через `VueMacros({ plugins: { vue: Vue() } })` — після + * резолву цей виклик повертає МАСИВ плагінів: сам `vite:vue` (той самий transform, + * що й `OWN_PLUGINS`' `vue()` — дублювання дає ПОДВІЙНУ SFC-трансформацію) плюс + * службові `vue-macros-*` (devtools/exclude-dep-optimize). Плагіни macro-синтаксису + * (`unplugin-vue-define-props`/`define-emit`/`reactivity-transform` тощо) — НЕ + * фільтруються: вони не дублюються `OWN_PLUGINS` і потрібні для macro-фіч пакета; + * Vite впорядковує їх через власний `enforce: 'pre'|'post'` незалежно від позиції + * в підсумковому масиві `plugins`, тож порядок відносно `OWN_PLUGINS` тут не важливий. + * @param {string | undefined} name ім'я плагіна + * @returns {boolean} true — плагін дублює `OWN_PLUGINS`' `vue()` + */ +function isVueTransformFamily(name) { + return typeof name === 'string' && (name.startsWith('vite:vue') || name.includes('vue-macros')) +} + +/** + * Резолвить один запис `config.plugins` у плаский масив реальних плагінів. Vite + * офіційно підтримує `Plugin | Promise | (Plugin | Promise)[]` + * (довільна вкладеність) — `VueMacros(...)` сам повертає `Promise`, що резолвиться + * в масив плагінів. `loadConfigFromFile` читає файл конфіга як є й НЕ виконує це + * resolve/flatten (це робить лише повний `resolveConfig` пізніше у власному циклі + * Vite) — без ручного resolve/flatten тут фільтр порівнював би ім'я з + * `Promise`-об'єктом, що ще не резолвився (`undefined`), і пропускав би дублікат далі. + * @param {unknown} entry один елемент/Promise/масив із `config.plugins` + * @returns {Promise} плаский масив плагінів після resolve + */ +async function resolvePluginEntry(entry) { + const resolved = await entry + if (Array.isArray(resolved)) { + const nested = await Promise.all(resolved.map(resolvePluginEntry)) + return nested.flat(Infinity) + } + return resolved ? [resolved] : [] +} /** @type {import('@storybook/vue3-vite').StorybookConfig} */ const config = { @@ -26,13 +71,15 @@ const config = { staticDirs: ['./public'], async viteFinal(storybookConfig) { const loaded = await loadConfigFromFile({ command: 'serve', mode: 'development' }, undefined, process.cwd()) - const userPlugins = (loaded?.config?.plugins ?? []) - .flat() + const rawPlugins = loaded?.config?.plugins ?? [] + const resolvedNested = await Promise.all(rawPlugins.map(resolvePluginEntry)) + const userPlugins = resolvedNested + .flat(Infinity) .filter(Boolean) .filter(p => !INCOMPATIBLE_PLUGIN_NAMES.has(p.name)) // vue()/quasar() пакета замінюємо власними екземплярами у фіксованому порядку вище — - // не дублюємо; решта плагінів пакета (auto-import, svg-loader тощо) лишається. - .filter(p => p.name !== 'vite:vue' && p.name !== 'quasar') + // не дублюємо; решта плагінів пакета (auto-import, VueMacros macro-sugar тощо) лишається. + .filter(p => !isVueTransformFamily(p.name) && p.name !== 'quasar') return mergeConfig(storybookConfig, { resolve: loaded?.config?.resolve, diff --git a/plugins/lang-js/rules/storybook/scaffold/tests/scaffold.test.mjs b/plugins/lang-js/rules/storybook/scaffold/tests/scaffold.test.mjs index 25e89134c..a4c3e1687 100644 --- a/plugins/lang-js/rules/storybook/scaffold/tests/scaffold.test.mjs +++ b/plugins/lang-js/rules/storybook/scaffold/tests/scaffold.test.mjs @@ -63,6 +63,23 @@ describe('detectStoriesGlob', () => { await writeFileDeep(root, 'src/Comp.vue', '