From 0cb185fa89470229851c64dccb79c0353f5fd092 Mon Sep 17 00:00:00 2001 From: ghato Date: Wed, 17 Apr 2024 16:20:50 +0300 Subject: [PATCH 1/3] =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BB=20=D0=B2=D1=8B=D0=B3=D1=80=D1=83=D0=B7=D0=BA?= =?UTF-8?q?=D1=83=20=D0=B8=D0=BD=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=BE=20=D1=81=D1=82=D0=BE=D1=80=D1=8F=D1=85=20=D0=B8?= =?UTF-8?q?=D0=B7=20index.json=20storybook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- README.md | 43 ++++++++++++++++++++++- src/commands/sync.ts | 9 ++++- src/commands/validate-only.ts | 9 ++++- src/lib/config/models.ts | 7 ++++ src/lib/domain/index.ts | 2 +- src/lib/domain/keys.ts | 18 +++++++++- src/lib/jest/index.ts | 19 +++------- src/lib/storybook/index.ts | 61 +++++++++++++++++++++++++++++++++ src/lib/storybook/models.ts | 23 +++++++++++++ src/lib/validators/models.ts | 9 ++++- src/lib/validators/renderer.ts | 2 ++ src/lib/validators/validator.ts | 29 +++++++++++++--- 13 files changed, 209 insertions(+), 25 deletions(-) create mode 100644 src/lib/storybook/index.ts create mode 100644 src/lib/storybook/models.ts diff --git a/.gitignore b/.gitignore index 1d995a0..4a7c6ee 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ dist jest-report.json .spec-box-meta.yml .tms.json -specs-demo \ No newline at end of file +specs-demo +index.json \ No newline at end of file diff --git a/README.md b/README.md index 29f17a2..98879b7 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,11 @@ trees: ## Автоматическое определение признака automationState -Вместе с информацией о функциональных требованиях можно выгружать информацию о том, что их проверка автоматизирована. Определение автоматизированных ФТ выполняется автоматически, на основе информации об отчете jest в формате json. Отчет о выполнении тестов можно сформировать, запустив jest с параметром `--json`, например: +Вместе с информацией о функциональных требованиях можно выгружать информацию о том, что их проверка автоматизирована. Определение автоматизированных ФТ выполняется автоматически, на основе информации об отчете jest в формате json и на основе файла `index.json`, генерируемого при сборке storybook. + +### Jest + +Отчет о выполнении тестов можно сформировать, запустив jest с параметром `--json`, например: ```sh jest --json --outputFile=jest-report.json @@ -200,6 +204,43 @@ describe('Главная страница', () => { - `@` — значение указанного атрибута (идентификатор значения) - `$` — значение указанного атрибута (человеко-понятное название) +### Storybook + +Чтобы добавить в выгрузку ФТ информацию из storybook, добавьте в корень [конфигурационного файла](#формат-конфига) секцию `"storybook"`: + +```js +{ + // ... + "storybook": { + // путь к файлу index.json, генерируемого при билде сторибука + "indexPath": "index.json", + + // сегменты идентификатора для сопоставления автотестов с ФТ + "keys": ["featureTitle", "groupTitle", "assertionTitle"] + } +``` + +Поле `"keys"` работает таким же образом, как и для [конфигурации jest](###jest). Если в `index.json` есть стори, путь до которой в дереве сторей совпадает с идентификатором ФТ, то считается, что проверка этого ФТ — автоматизирована. + +Например, если в проекте есть yml файл с содержимым, [указанным выше](#формат-yml) и поле `"keys"` в разделе `"storybook"` конфигурационного файла имеет значение `["featureTitle", "groupTitle", "assertionTitle"]`, то указанная ниже стори будет сопоставлена с ФТ `"Отображается количество и общая стоимость товаров в корзине"`: + +```js +import type { Meta, StoryObj } from '@storybook/react'; +import { Cart } from './Cart'; + +export default { + title: 'Главная страница/Блок корзины', + component: Cart, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + name: 'Отображается количество и общая стоимость товаров в корзине', + render: () => , +}; +``` + ## Формат конфига Ниже указаны все возможные параметры конфигурационного файла: diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 58068e3..6dd8b75 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -8,12 +8,13 @@ import { applyJestReport, loadJestReport } from '../lib/jest'; import { uploadEntities } from '../lib/upload/upload-entities'; import { CommonOptions } from '../lib/utils'; import { Validator } from '../lib/validators'; +import { loadStorybookIndex, applyStorybookIndex } from '../lib/storybook'; export const cmdSync: CommandModule<{}, CommonOptions> = { command: 'sync', handler: async (args) => { console.log('SYNC'); - const { yml, api, jest, validation = {}, projectPath } = await loadConfig(args.config); + const { yml, api, jest, storybook, validation = {}, projectPath } = await loadConfig(args.config); const validationContext = new Validator(validation); const meta = await loadMeta(validationContext, yml.metaPath, projectPath); @@ -32,6 +33,12 @@ export const cmdSync: CommandModule<{}, CommonOptions> = { applyJestReport(validationContext, projectData, jestReport, jest.keys); } + if (storybook) { + const storybookIndex = await loadStorybookIndex(storybook.indexPath, projectPath); + + applyStorybookIndex(validationContext, projectData, storybookIndex, storybook); + } + validationContext.printReport(); if (validationContext.hasCriticalErrors) { throw Error('Выгрузка невозможна из-за наличия критических ошибок'); diff --git a/src/commands/validate-only.ts b/src/commands/validate-only.ts index 3bf5ffa..d2c4d2b 100644 --- a/src/commands/validate-only.ts +++ b/src/commands/validate-only.ts @@ -7,13 +7,14 @@ import { processYamlFiles } from '../lib/domain'; import { applyJestReport, loadJestReport } from '../lib/jest'; import { CommonOptions } from '../lib/utils'; import { Validator } from '../lib/validators'; +import { applyStorybookIndex, loadStorybookIndex } from '../lib/storybook'; export const cmdValidateOnly: CommandModule<{}, CommonOptions> = { command: 'validate', handler: async (args) => { console.log('VALIDATION'); - const { yml, jest, validation = {}, projectPath } = await loadConfig(args.config); + const { yml, jest, storybook, validation = {}, projectPath } = await loadConfig(args.config); const validationContext = new Validator(validation); const meta = await loadMeta(validationContext, yml.metaPath, projectPath); @@ -33,6 +34,12 @@ export const cmdValidateOnly: CommandModule<{}, CommonOptions> = { applyJestReport(validationContext, projectData, jestReport, jest.keys); } + if (storybook) { + const storybookIndex = await loadStorybookIndex(storybook.indexPath, projectPath); + + applyStorybookIndex(validationContext, projectData, storybookIndex, storybook); + } + validationContext.printReport(); if (validationContext.hasCriticalErrors) { throw Error('При валидации были обнаружены критические ошибки'); diff --git a/src/lib/config/models.ts b/src/lib/config/models.ts index 8640130..0b1d664 100644 --- a/src/lib/config/models.ts +++ b/src/lib/config/models.ts @@ -76,6 +76,11 @@ export const jestConfigDecoder = d.struct({ keys: d.array(d.union(literalKeyPartDecoder, attributeKeyPartDecoder)), }); +export const storybookConfigDecoder = d.struct({ + indexPath: d.string, + keys: d.array(d.union(literalKeyPartDecoder, attributeKeyPartDecoder)), +}); + export const configDecoder = d.intersect( d.struct({ api: apiConfigDecoder, @@ -86,6 +91,7 @@ export const configDecoder = d.intersect( projectPath: d.string, validation: validationConfigDecoder, jest: jestConfigDecoder, + storybook: storybookConfigDecoder, }), ); @@ -93,6 +99,7 @@ export type RootConfig = d.TypeOf; export type ApiConfig = d.TypeOf; export type YmlConfig = d.TypeOf; export type JestConfig = d.TypeOf; +export type StorybookConfig = d.TypeOf; export type ValidationConfig = d.TypeOf; export type ValidationSeverity = d.TypeOf; diff --git a/src/lib/domain/index.ts b/src/lib/domain/index.ts index 4011b13..d7d26a2 100644 --- a/src/lib/domain/index.ts +++ b/src/lib/domain/index.ts @@ -5,7 +5,7 @@ import { Meta } from '../config/models'; import { YamlFile, Assertion as YmlAssertion } from '../yaml'; import { Assertion, AssertionGroup, Attribute, AttributeValue, Feature, ProjectData, Tree } from './models'; -export { getAttributesContext, getKey } from './keys'; +export { getAttributesContext, getAssertionContext, getKey } from './keys'; export type { AssertionContext, AttributesContext } from './keys'; export type { Assertion, AssertionGroup, Attribute, AttributeValue, Feature, ProjectData, Tree } from './models'; diff --git a/src/lib/domain/keys.ts b/src/lib/domain/keys.ts index 280f6ec..d09be12 100644 --- a/src/lib/domain/keys.ts +++ b/src/lib/domain/keys.ts @@ -1,4 +1,4 @@ -import { Attribute } from './models'; +import { Assertion, AssertionGroup, Attribute, Feature } from './models'; export const UNDEFINED = 'UNDEFINED'; export const AMBIGUOUS = 'AMBIGUOUS'; @@ -30,6 +30,22 @@ export const getAttributesContext = (alLAttributes: Attribute[] = []): Attribute return obj; }; +export const getAssertionContext = ( + feature: Feature, + group: AssertionGroup, + assertion: Assertion, +): AssertionContext => { + return { + featureTitle: feature.title, + featureCode: feature.code, + groupTitle: group.title, + assertionTitle: assertion.title, + attributes: feature.attributes ?? {}, + fileName: feature.fileName, + filePath: feature.filePath, + }; +}; + const getAttributeValue = ( attributeCode: string, { attributes }: AssertionContext, diff --git a/src/lib/jest/index.ts b/src/lib/jest/index.ts index 16c7ad9..0b140df 100644 --- a/src/lib/jest/index.ts +++ b/src/lib/jest/index.ts @@ -1,4 +1,4 @@ -import { AssertionContext, ProjectData, getAttributesContext, getKey } from '../domain'; +import { ProjectData, getAssertionContext, getAttributesContext, getKey } from '../domain'; import { AutomationState } from '../domain/models'; import { parseObject, readTextFile } from '../utils'; import { Validator } from '../validators'; @@ -40,19 +40,10 @@ export const applyJestReport = ( const attributesCtx = getAttributesContext(attributes); // заполняем поле isAutomated - for (let { title: featureTitle, code: featureCode, groups, fileName, filePath, attributes = {} } of features) { - for (let { title: groupTitle, assertions } of groups || []) { - for (let assertion of assertions || []) { - // TODO: перенести в domain? - const assertionCtx: AssertionContext = { - featureTitle, - featureCode, - groupTitle, - assertionTitle: assertion.title, - attributes, - fileName, - filePath, - }; + for (let feature of features) { + for (let group of feature.groups || []) { + for (let assertion of group.assertions || []) { + const assertionCtx = getAssertionContext(feature, group, assertion); const parts = getKey(keyParts, assertionCtx, attributesCtx); const fullName = getFullName(...parts); diff --git a/src/lib/storybook/index.ts b/src/lib/storybook/index.ts new file mode 100644 index 0000000..560e1aa --- /dev/null +++ b/src/lib/storybook/index.ts @@ -0,0 +1,61 @@ +import { StorybookConfig } from '../config/models'; +import { ProjectData, getAssertionContext, getAttributesContext, getKey } from '../domain'; +import { AutomationState } from '../domain/models'; +import { parseObject, readTextFile } from '../utils'; +import { Validator } from '../validators'; +import { StorybookIndex, storybookIndexDecoder } from './models'; + +export const getFullName = (...parts: string[]) => parts.join(' / '); + +export const applyStorybookIndex = ( + validationContext: Validator, + { features, attributes }: ProjectData, + index: StorybookIndex, + storybook: StorybookConfig, +) => { + const names = new Map(); + + const state = new Map(); + + // формируем список ключей сторей из конфига storybook + for (let { title, name, importPath } of Object.values(index.entries)) { + const fullName = getFullName(title.split('/').join(' / '), name); + + state.set(fullName, 'Automated'); + names.set(fullName, importPath); + } + + const attributesCtx = getAttributesContext(attributes); + + // заполняем поле isAutomated + for (let feature of features) { + for (let group of feature.groups || []) { + for (let assertion of group.assertions || []) { + const assertionCtx = getAssertionContext(feature, group, assertion); + + const parts = getKey(storybook.keys, assertionCtx, attributesCtx); + const fullName = getFullName(...parts); + + const automationState = state.get(fullName); + if (automationState) { + assertion.automationState = automationState; + } + + names.delete(fullName); + } + } + } + + for (const [name, path] of names.entries()) { + validationContext.registerStorybookUnusedStory(name, path); + } +}; + +export const loadStorybookIndex = async (path: string, basePath?: string) => { + const json = await readTextFile(path, basePath); + const data: unknown = JSON.parse(json); + + const entity = parseObject(data, storybookIndexDecoder); + + return entity; +}; diff --git a/src/lib/storybook/models.ts b/src/lib/storybook/models.ts new file mode 100644 index 0000000..421852f --- /dev/null +++ b/src/lib/storybook/models.ts @@ -0,0 +1,23 @@ +import * as d from 'io-ts/Decoder'; + +export const storyDecoder = d.intersect( + d.struct({ + type: d.literal('story', 'docs'), + id: d.string, + name: d.string, + title: d.string, + importPath: d.string, + }), +)( + d.partial({ + tags: d.array(d.string), + }), +); + +export const storybookIndexDecoder = d.struct({ + v: d.number, + entries: d.record(storyDecoder), +}); + +export type Story = d.TypeOf; +export type StorybookIndex = d.TypeOf; diff --git a/src/lib/validators/models.ts b/src/lib/validators/models.ts index 4089546..4bac6ec 100644 --- a/src/lib/validators/models.ts +++ b/src/lib/validators/models.ts @@ -85,6 +85,11 @@ export type JestUnusedTestError = { filePath: string; test: string; }; +export type StorybookUnusedStoryError = { + type: 'storybook-unused'; + filePath: string; + story: string; +}; export type ValidationError = | AttributeDuplicateError @@ -100,7 +105,8 @@ export type ValidationError = | AssertionDuplicateError | CodeError | LoaderError - | JestUnusedTestError; + | JestUnusedTestError + | StorybookUnusedStoryError; export type ValidationErrorTypes = ValidationError['type']; @@ -119,4 +125,5 @@ export const DEFAULT_ERROR_SEVERITY: { [key in ValidationErrorTypes]: Validation 'featrue-attribute-value-code-format': 'error', 'feature-missing-link': 'warning', 'jest-unused': 'warning', + 'storybook-unused': 'warning', }; diff --git a/src/lib/validators/renderer.ts b/src/lib/validators/renderer.ts index 34d46d5..321a642 100644 --- a/src/lib/validators/renderer.ts +++ b/src/lib/validators/renderer.ts @@ -32,6 +32,8 @@ const renderError = (e: ValidationError): string => { return `Дубликат утверждения: ${val(e.assertion.title)} (группа ${val(e.assertionGroup.title)})`; case 'jest-unused': return `Обнаружен тест без описания\n${val(e.test)}`; + case 'storybook-unused': + return `Обнаружена стори без описания\n${val(e.story)}`; } }; diff --git a/src/lib/validators/validator.ts b/src/lib/validators/validator.ts index 395da8f..b3640a2 100644 --- a/src/lib/validators/validator.ts +++ b/src/lib/validators/validator.ts @@ -14,6 +14,7 @@ import { FeatureMissingLinkError, JestUnusedTestError, LoaderError, + StorybookUnusedStoryError, TreeAttributeDuplicateError, TreeDuplicateError, TreeMissingAttributeError, @@ -28,6 +29,7 @@ export class Validator { private readonly metaErrors = new Array(); private readonly featureErrors = new Array(); private readonly jestUnusedTests = new Array(); + private readonly storybookUnusedStories = new Array(); private metaFilePath = ''; public readonly severity: Record; @@ -37,22 +39,33 @@ export class Validator { } get hasCriticalErrors(): boolean { - return [...this.loaderErrors, ...this.metaErrors, ...this.featureErrors, ...this.jestUnusedTests].some( - (e) => this.severity[e.type] === 'error', - ); + return [ + ...this.loaderErrors, + ...this.metaErrors, + ...this.featureErrors, + ...this.jestUnusedTests, + ...this.storybookUnusedStories, + ].some((e) => this.severity[e.type] === 'error'); } printReport() { const render = (e: ValidationError) => printError(e, this.severity); this.jestUnusedTests.forEach(render); + this.storybookUnusedStories.forEach(render); this.featureErrors.forEach(render); this.metaErrors.forEach(render); this.loaderErrors.forEach(render); renderStats( 'Всего', - [...this.loaderErrors, ...this.metaErrors, ...this.featureErrors, ...this.jestUnusedTests], + [ + ...this.loaderErrors, + ...this.metaErrors, + ...this.featureErrors, + ...this.jestUnusedTests, + ...this.storybookUnusedStories, + ], this.severity, ); } @@ -69,6 +82,14 @@ export class Validator { }); } + registerStorybookUnusedStory(story: string, filePath: string) { + this.storybookUnusedStories.push({ + type: 'storybook-unused', + story, + filePath, + }); + } + validate({ trees, attributes, metaFilePath, features }: ProjectData) { const metaAttributeValues = new Map>(); From f83c6519b6c25d434409aeecc23f754d1950f6b4 Mon Sep 17 00:00:00 2001 From: ghato Date: Thu, 18 Apr 2024 11:31:10 +0300 Subject: [PATCH 2/3] =?UTF-8?q?=D0=BF=D0=BE=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D1=84=D0=BE=D1=80=D0=BC=D1=83=D0=BB=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8,=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20trim=20=D0=B4=D0=BB=D1=8F=20=D1=87=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D0=B5=D0=B9=20=D0=BF=D1=83=D1=82=D0=B8=20=D0=B4=D0=BE=20=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=80=D0=B8=D0=B8=20storybook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 18 +++++++++++++++--- src/lib/storybook/index.ts | 18 ++++++++++++------ src/lib/validators/renderer.ts | 2 +- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 98879b7..f40435e 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ trees: ## Автоматическое определение признака automationState -Вместе с информацией о функциональных требованиях можно выгружать информацию о том, что их проверка автоматизирована. Определение автоматизированных ФТ выполняется автоматически, на основе информации об отчете jest в формате json и на основе файла `index.json`, генерируемого при сборке storybook. +Вместе с информацией о функциональных требованиях можно выгружать информацию о том, что их проверка автоматизирована. Для некоторых видов автотестов реализовано автоматическое вычисление признака `automationState`. На текущий момент поддерживаются `jest` и `storybook`. ### Jest @@ -206,6 +206,8 @@ describe('Главная страница', () => { ### Storybook +Мы считаем, что если в сторибуке написана история, то она проверяется скриншотным тестом и проверку соответствующего ФТ считаем автоматизированной. В качестве входной информации нужно предоставить синхронизатору файл `index.json`, который формируется сторибуком при сборке и содержит список историй. Поддерживается Storybook v7 и выше. + Чтобы добавить в выгрузку ФТ информацию из storybook, добавьте в корень [конфигурационного файла](#формат-конфига) секцию `"storybook"`: ```js @@ -237,7 +239,7 @@ type Story = StoryObj; export const Default: Story = { name: 'Отображается количество и общая стоимость товаров в корзине', - render: () => , + render: () => , }; ``` @@ -274,7 +276,17 @@ export const Default: Story = { // настройки для сопоставления ФТ с отчетами jest "jest": { "reportPath": "jest-report.json", // путь к файлу с отчетом о выполнении тестов - "keys": [ // + "keys": [ // сегменты идентификатора для сопоставления автотестов с ФТ + "featureTitle", + "$sub-component", + "groupTitle", + "assertionTitle" + ] + } + // настройки для сопоставления ФТ с историями storybook + "storybook": { + "indexPath": "index.json", // путь к файлу index.json, генерируемого при билде сторибука + "keys": [ // сегменты идентификатора для сопоставления историй из storybook с ФТ "featureTitle", "$sub-component", "groupTitle", diff --git a/src/lib/storybook/index.ts b/src/lib/storybook/index.ts index 560e1aa..36d28ed 100644 --- a/src/lib/storybook/index.ts +++ b/src/lib/storybook/index.ts @@ -15,13 +15,20 @@ export const applyStorybookIndex = ( ) => { const names = new Map(); - const state = new Map(); + const automatedAssertions = new Set(); // формируем список ключей сторей из конфига storybook for (let { title, name, importPath } of Object.values(index.entries)) { - const fullName = getFullName(title.split('/').join(' / '), name); + const fullName = getFullName( + title + .split('/') + .map((part) => part.trim()) + .join(' / '), + name, + ); + + automatedAssertions.add(fullName); - state.set(fullName, 'Automated'); names.set(fullName, importPath); } @@ -36,9 +43,8 @@ export const applyStorybookIndex = ( const parts = getKey(storybook.keys, assertionCtx, attributesCtx); const fullName = getFullName(...parts); - const automationState = state.get(fullName); - if (automationState) { - assertion.automationState = automationState; + if (automatedAssertions.has(fullName)) { + assertion.automationState = 'Automated'; } names.delete(fullName); diff --git a/src/lib/validators/renderer.ts b/src/lib/validators/renderer.ts index 321a642..6c1c23a 100644 --- a/src/lib/validators/renderer.ts +++ b/src/lib/validators/renderer.ts @@ -33,7 +33,7 @@ const renderError = (e: ValidationError): string => { case 'jest-unused': return `Обнаружен тест без описания\n${val(e.test)}`; case 'storybook-unused': - return `Обнаружена стори без описания\n${val(e.story)}`; + return `Обнаружена история без описания\n${val(e.story)}`; } }; From 958ca706c1cb78a58e06cbf0f5b7738ade1723c6 Mon Sep 17 00:00:00 2001 From: ghato Date: Fri, 19 Apr 2024 14:43:19 +0300 Subject: [PATCH 3/3] =?UTF-8?q?=D1=87=D0=B0=D1=81=D1=82=D0=B8=D1=87=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BF=D0=BB=D0=B0=D0=B3=D0=B8=D0=BD=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 22 +++++++++++++++------- src/commands/sync.ts | 14 ++++++-------- src/commands/validate-only.ts | 10 ++++------ src/lib/config/models.ts | 8 ++------ src/lib/pluginsLoader/index.ts | 28 ++++++++++++++++++++++++++++ src/lib/storybook/index.ts | 19 ++++++++++++++++--- src/lib/storybook/models.ts | 8 ++++++++ src/lib/validators/models.ts | 7 ++++--- src/lib/validators/renderer.ts | 4 ++-- src/lib/validators/validator.ts | 25 ++++++++++--------------- 10 files changed, 95 insertions(+), 50 deletions(-) create mode 100644 src/lib/pluginsLoader/index.ts diff --git a/README.md b/README.md index f40435e..ce52f7e 100644 --- a/README.md +++ b/README.md @@ -208,17 +208,20 @@ describe('Главная страница', () => { Мы считаем, что если в сторибуке написана история, то она проверяется скриншотным тестом и проверку соответствующего ФТ считаем автоматизированной. В качестве входной информации нужно предоставить синхронизатору файл `index.json`, который формируется сторибуком при сборке и содержит список историй. Поддерживается Storybook v7 и выше. -Чтобы добавить в выгрузку ФТ информацию из storybook, добавьте в корень [конфигурационного файла](#формат-конфига) секцию `"storybook"`: +Чтобы добавить в выгрузку ФТ информацию из storybook - надо установить пакет `@spec-box/storybook` и добавить в корень [конфигурационного файла](#формат-конфига) секцию `"storybook"`: ```js { // ... - "storybook": { - // путь к файлу index.json, генерируемого при билде сторибука - "indexPath": "index.json", - - // сегменты идентификатора для сопоставления автотестов с ФТ - "keys": ["featureTitle", "groupTitle", "assertionTitle"] + "plugins": { + // ... + "storybook": { + // путь к файлу index.json, генерируемого при билде сторибука + "indexPath": "index.json", + + // сегменты идентификатора для сопоставления автотестов с ФТ + "keys": ["featureTitle", "groupTitle", "assertionTitle"] + } } ``` @@ -293,5 +296,10 @@ export const Default: Story = { "assertionTitle" ] } + // Подключение плагинов + "plugins": { + // Имя плагина и его конфиг + "plugin": {} + } } ``` diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 6dd8b75..26e3055 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -8,13 +8,13 @@ import { applyJestReport, loadJestReport } from '../lib/jest'; import { uploadEntities } from '../lib/upload/upload-entities'; import { CommonOptions } from '../lib/utils'; import { Validator } from '../lib/validators'; -import { loadStorybookIndex, applyStorybookIndex } from '../lib/storybook'; +import { applyPlugins } from '../lib/pluginsLoader'; export const cmdSync: CommandModule<{}, CommonOptions> = { command: 'sync', handler: async (args) => { console.log('SYNC'); - const { yml, api, jest, storybook, validation = {}, projectPath } = await loadConfig(args.config); + const { yml, api, jest, plugins, validation = {}, projectPath } = await loadConfig(args.config); const validationContext = new Validator(validation); const meta = await loadMeta(validationContext, yml.metaPath, projectPath); @@ -27,18 +27,16 @@ export const cmdSync: CommandModule<{}, CommonOptions> = { const projectData = processYamlFiles(successYamls, meta); validationContext.validate(projectData); + if (plugins) { + await applyPlugins({ projectData, validationContext }, plugins); + } + if (jest) { const jestReport = await loadJestReport(jest.reportPath, projectPath); applyJestReport(validationContext, projectData, jestReport, jest.keys); } - if (storybook) { - const storybookIndex = await loadStorybookIndex(storybook.indexPath, projectPath); - - applyStorybookIndex(validationContext, projectData, storybookIndex, storybook); - } - validationContext.printReport(); if (validationContext.hasCriticalErrors) { throw Error('Выгрузка невозможна из-за наличия критических ошибок'); diff --git a/src/commands/validate-only.ts b/src/commands/validate-only.ts index d2c4d2b..b1833e7 100644 --- a/src/commands/validate-only.ts +++ b/src/commands/validate-only.ts @@ -7,14 +7,14 @@ import { processYamlFiles } from '../lib/domain'; import { applyJestReport, loadJestReport } from '../lib/jest'; import { CommonOptions } from '../lib/utils'; import { Validator } from '../lib/validators'; -import { applyStorybookIndex, loadStorybookIndex } from '../lib/storybook'; +import { applyPlugins } from '../lib/pluginsLoader'; export const cmdValidateOnly: CommandModule<{}, CommonOptions> = { command: 'validate', handler: async (args) => { console.log('VALIDATION'); - const { yml, jest, storybook, validation = {}, projectPath } = await loadConfig(args.config); + const { yml, jest, plugins, validation = {}, projectPath } = await loadConfig(args.config); const validationContext = new Validator(validation); const meta = await loadMeta(validationContext, yml.metaPath, projectPath); @@ -34,10 +34,8 @@ export const cmdValidateOnly: CommandModule<{}, CommonOptions> = { applyJestReport(validationContext, projectData, jestReport, jest.keys); } - if (storybook) { - const storybookIndex = await loadStorybookIndex(storybook.indexPath, projectPath); - - applyStorybookIndex(validationContext, projectData, storybookIndex, storybook); + if (plugins) { + await applyPlugins({ projectData, validationContext }, plugins); } validationContext.printReport(); diff --git a/src/lib/config/models.ts b/src/lib/config/models.ts index 0b1d664..2858152 100644 --- a/src/lib/config/models.ts +++ b/src/lib/config/models.ts @@ -76,10 +76,7 @@ export const jestConfigDecoder = d.struct({ keys: d.array(d.union(literalKeyPartDecoder, attributeKeyPartDecoder)), }); -export const storybookConfigDecoder = d.struct({ - indexPath: d.string, - keys: d.array(d.union(literalKeyPartDecoder, attributeKeyPartDecoder)), -}); +export const pluginsDecoder = d.record(d.UnknownRecord); export const configDecoder = d.intersect( d.struct({ @@ -91,7 +88,7 @@ export const configDecoder = d.intersect( projectPath: d.string, validation: validationConfigDecoder, jest: jestConfigDecoder, - storybook: storybookConfigDecoder, + plugins: pluginsDecoder, }), ); @@ -99,7 +96,6 @@ export type RootConfig = d.TypeOf; export type ApiConfig = d.TypeOf; export type YmlConfig = d.TypeOf; export type JestConfig = d.TypeOf; -export type StorybookConfig = d.TypeOf; export type ValidationConfig = d.TypeOf; export type ValidationSeverity = d.TypeOf; diff --git a/src/lib/pluginsLoader/index.ts b/src/lib/pluginsLoader/index.ts new file mode 100644 index 0000000..b8dec4e --- /dev/null +++ b/src/lib/pluginsLoader/index.ts @@ -0,0 +1,28 @@ +import { ProjectData } from '../domain'; +import { Validator } from '../validators'; +// TODO: надо будет убрать, когда сторибук будет вынесен в отдельный пакет +import storybookPlugin from '../storybook'; + +export type SpecBox = { + projectPath?: string; + projectData: ProjectData; + validationContext: Validator; +}; + +export const applyPlugins = async (specbox: SpecBox, plugins: Record) => { + for (const [name, opts] of Object.entries(plugins)) { + await requirePlugin(name)(specbox, opts); + } +}; + +const requirePlugin = (pluginName: string) => { + // TODO: надо будет убрать, когда сторибук будет вынесен в отдельный пакет + if (pluginName === 'storybook') { + return storybookPlugin; + } + + const pluginNameWithPrefix = `${PLUGIN_NAME_PREFIX}${pluginName}`; + return require(pluginNameWithPrefix); +}; + +const PLUGIN_NAME_PREFIX = '@spec-box/'; diff --git a/src/lib/storybook/index.ts b/src/lib/storybook/index.ts index 36d28ed..60896e4 100644 --- a/src/lib/storybook/index.ts +++ b/src/lib/storybook/index.ts @@ -1,12 +1,21 @@ -import { StorybookConfig } from '../config/models'; +// TODO: надо вынести в отдельный пакет и импоритовать зависимости из @spec-box/sync import { ProjectData, getAssertionContext, getAttributesContext, getKey } from '../domain'; import { AutomationState } from '../domain/models'; +import { SpecBox } from '../pluginsLoader'; import { parseObject, readTextFile } from '../utils'; import { Validator } from '../validators'; -import { StorybookIndex, storybookIndexDecoder } from './models'; + +import { StorybookConfig, StorybookIndex, storybookConfigDecoder, storybookIndexDecoder } from './models'; export const getFullName = (...parts: string[]) => parts.join(' / '); +export default async (specbox: SpecBox, opts: unknown) => { + const storybook = parseObject(opts, storybookConfigDecoder); + const index = await loadStorybookIndex(storybook.indexPath, specbox.projectPath); + + applyStorybookIndex(specbox.validationContext, specbox.projectData, index, storybook); +}; + export const applyStorybookIndex = ( validationContext: Validator, { features, attributes }: ProjectData, @@ -53,7 +62,11 @@ export const applyStorybookIndex = ( } for (const [name, path] of names.entries()) { - validationContext.registerStorybookUnusedStory(name, path); + validationContext.registerPluginError( + 'storybook', + ({ val }) => `Обнаружена история без описания\n${val(name)}`, + path, + ); } }; diff --git a/src/lib/storybook/models.ts b/src/lib/storybook/models.ts index 421852f..64a265a 100644 --- a/src/lib/storybook/models.ts +++ b/src/lib/storybook/models.ts @@ -1,4 +1,11 @@ import * as d from 'io-ts/Decoder'; +// TODO: надо импоритовать из @spec-box/sync +import { attributeKeyPartDecoder, literalKeyPartDecoder } from '../config/models'; + +export const storybookConfigDecoder = d.struct({ + indexPath: d.string, + keys: d.array(d.union(literalKeyPartDecoder, attributeKeyPartDecoder)), +}); export const storyDecoder = d.intersect( d.struct({ @@ -19,5 +26,6 @@ export const storybookIndexDecoder = d.struct({ entries: d.record(storyDecoder), }); +export type StorybookConfig = d.TypeOf; export type Story = d.TypeOf; export type StorybookIndex = d.TypeOf; diff --git a/src/lib/validators/models.ts b/src/lib/validators/models.ts index 4bac6ec..a923429 100644 --- a/src/lib/validators/models.ts +++ b/src/lib/validators/models.ts @@ -86,9 +86,10 @@ export type JestUnusedTestError = { test: string; }; export type StorybookUnusedStoryError = { - type: 'storybook-unused'; + type: 'plugin-error'; filePath: string; - story: string; + pluginName: string; + error: (ctx: { val: (val: string) => string }) => string; }; export type ValidationError = @@ -125,5 +126,5 @@ export const DEFAULT_ERROR_SEVERITY: { [key in ValidationErrorTypes]: Validation 'featrue-attribute-value-code-format': 'error', 'feature-missing-link': 'warning', 'jest-unused': 'warning', - 'storybook-unused': 'warning', + 'plugin-error': 'warning', }; diff --git a/src/lib/validators/renderer.ts b/src/lib/validators/renderer.ts index 6c1c23a..ad08717 100644 --- a/src/lib/validators/renderer.ts +++ b/src/lib/validators/renderer.ts @@ -32,8 +32,8 @@ const renderError = (e: ValidationError): string => { return `Дубликат утверждения: ${val(e.assertion.title)} (группа ${val(e.assertionGroup.title)})`; case 'jest-unused': return `Обнаружен тест без описания\n${val(e.test)}`; - case 'storybook-unused': - return `Обнаружена история без описания\n${val(e.story)}`; + case 'plugin-error': + return `[ @spec-box/${e.pluginName} ]: ${e.error({ val })}`; } }; diff --git a/src/lib/validators/validator.ts b/src/lib/validators/validator.ts index b3640a2..b02bee1 100644 --- a/src/lib/validators/validator.ts +++ b/src/lib/validators/validator.ts @@ -14,7 +14,7 @@ import { FeatureMissingLinkError, JestUnusedTestError, LoaderError, - StorybookUnusedStoryError, + StorybookUnusedStoryError as PluginError, TreeAttributeDuplicateError, TreeDuplicateError, TreeMissingAttributeError, @@ -29,7 +29,7 @@ export class Validator { private readonly metaErrors = new Array(); private readonly featureErrors = new Array(); private readonly jestUnusedTests = new Array(); - private readonly storybookUnusedStories = new Array(); + private readonly pluginsErrors = new Array(); private metaFilePath = ''; public readonly severity: Record; @@ -44,7 +44,7 @@ export class Validator { ...this.metaErrors, ...this.featureErrors, ...this.jestUnusedTests, - ...this.storybookUnusedStories, + ...this.pluginsErrors, ].some((e) => this.severity[e.type] === 'error'); } @@ -52,20 +52,14 @@ export class Validator { const render = (e: ValidationError) => printError(e, this.severity); this.jestUnusedTests.forEach(render); - this.storybookUnusedStories.forEach(render); + this.pluginsErrors.forEach(render); this.featureErrors.forEach(render); this.metaErrors.forEach(render); this.loaderErrors.forEach(render); renderStats( 'Всего', - [ - ...this.loaderErrors, - ...this.metaErrors, - ...this.featureErrors, - ...this.jestUnusedTests, - ...this.storybookUnusedStories, - ], + [...this.loaderErrors, ...this.metaErrors, ...this.featureErrors, ...this.jestUnusedTests, ...this.pluginsErrors], this.severity, ); } @@ -82,10 +76,11 @@ export class Validator { }); } - registerStorybookUnusedStory(story: string, filePath: string) { - this.storybookUnusedStories.push({ - type: 'storybook-unused', - story, + registerPluginError(pluginName: string, error: PluginError['error'], filePath: string) { + this.pluginsErrors.push({ + type: 'plugin-error', + pluginName, + error, filePath, }); }