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..ce52f7e 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,11 @@ trees: ## Автоматическое определение признака automationState -Вместе с информацией о функциональных требованиях можно выгружать информацию о том, что их проверка автоматизирована. Определение автоматизированных ФТ выполняется автоматически, на основе информации об отчете jest в формате json. Отчет о выполнении тестов можно сформировать, запустив jest с параметром `--json`, например: +Вместе с информацией о функциональных требованиях можно выгружать информацию о том, что их проверка автоматизирована. Для некоторых видов автотестов реализовано автоматическое вычисление признака `automationState`. На текущий момент поддерживаются `jest` и `storybook`. + +### Jest + +Отчет о выполнении тестов можно сформировать, запустив jest с параметром `--json`, например: ```sh jest --json --outputFile=jest-report.json @@ -200,6 +204,48 @@ describe('Главная страница', () => { - `@` — значение указанного атрибута (идентификатор значения) - `$` — значение указанного атрибута (человеко-понятное название) +### Storybook + +Мы считаем, что если в сторибуке написана история, то она проверяется скриншотным тестом и проверку соответствующего ФТ считаем автоматизированной. В качестве входной информации нужно предоставить синхронизатору файл `index.json`, который формируется сторибуком при сборке и содержит список историй. Поддерживается Storybook v7 и выше. + +Чтобы добавить в выгрузку ФТ информацию из storybook - надо установить пакет `@spec-box/storybook` и добавить в корень [конфигурационного файла](#формат-конфига) секцию `"storybook"`: + +```js +{ + // ... + "plugins": { + // ... + "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: () => , +}; +``` + ## Формат конфига Ниже указаны все возможные параметры конфигурационного файла: @@ -233,12 +279,27 @@ describe('Главная страница', () => { // настройки для сопоставления ФТ с отчетами 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", + "assertionTitle" + ] + } + // Подключение плагинов + "plugins": { + // Имя плагина и его конфиг + "plugin": {} + } } ``` diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 58068e3..26e3055 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 { applyPlugins } from '../lib/pluginsLoader'; 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, plugins, validation = {}, projectPath } = await loadConfig(args.config); const validationContext = new Validator(validation); const meta = await loadMeta(validationContext, yml.metaPath, projectPath); @@ -26,6 +27,10 @@ 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); diff --git a/src/commands/validate-only.ts b/src/commands/validate-only.ts index 3bf5ffa..b1833e7 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 { applyPlugins } from '../lib/pluginsLoader'; 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, plugins, validation = {}, projectPath } = await loadConfig(args.config); const validationContext = new Validator(validation); const meta = await loadMeta(validationContext, yml.metaPath, projectPath); @@ -33,6 +34,10 @@ export const cmdValidateOnly: CommandModule<{}, CommonOptions> = { applyJestReport(validationContext, projectData, jestReport, jest.keys); } + if (plugins) { + await applyPlugins({ projectData, validationContext }, plugins); + } + validationContext.printReport(); if (validationContext.hasCriticalErrors) { throw Error('При валидации были обнаружены критические ошибки'); diff --git a/src/lib/config/models.ts b/src/lib/config/models.ts index 8640130..2858152 100644 --- a/src/lib/config/models.ts +++ b/src/lib/config/models.ts @@ -76,6 +76,8 @@ export const jestConfigDecoder = d.struct({ keys: d.array(d.union(literalKeyPartDecoder, attributeKeyPartDecoder)), }); +export const pluginsDecoder = d.record(d.UnknownRecord); + export const configDecoder = d.intersect( d.struct({ api: apiConfigDecoder, @@ -86,6 +88,7 @@ export const configDecoder = d.intersect( projectPath: d.string, validation: validationConfigDecoder, jest: jestConfigDecoder, + plugins: pluginsDecoder, }), ); 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/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 new file mode 100644 index 0000000..60896e4 --- /dev/null +++ b/src/lib/storybook/index.ts @@ -0,0 +1,80 @@ +// 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 { 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, + index: StorybookIndex, + storybook: StorybookConfig, +) => { + const names = new Map(); + + const automatedAssertions = new Set(); + + // формируем список ключей сторей из конфига storybook + for (let { title, name, importPath } of Object.values(index.entries)) { + const fullName = getFullName( + title + .split('/') + .map((part) => part.trim()) + .join(' / '), + name, + ); + + automatedAssertions.add(fullName); + + 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); + + if (automatedAssertions.has(fullName)) { + assertion.automationState = 'Automated'; + } + + names.delete(fullName); + } + } + } + + for (const [name, path] of names.entries()) { + validationContext.registerPluginError( + 'storybook', + ({ val }) => `Обнаружена история без описания\n${val(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..64a265a --- /dev/null +++ b/src/lib/storybook/models.ts @@ -0,0 +1,31 @@ +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({ + 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 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 4089546..a923429 100644 --- a/src/lib/validators/models.ts +++ b/src/lib/validators/models.ts @@ -85,6 +85,12 @@ export type JestUnusedTestError = { filePath: string; test: string; }; +export type StorybookUnusedStoryError = { + type: 'plugin-error'; + filePath: string; + pluginName: string; + error: (ctx: { val: (val: string) => string }) => string; +}; export type ValidationError = | AttributeDuplicateError @@ -100,7 +106,8 @@ export type ValidationError = | AssertionDuplicateError | CodeError | LoaderError - | JestUnusedTestError; + | JestUnusedTestError + | StorybookUnusedStoryError; export type ValidationErrorTypes = ValidationError['type']; @@ -119,4 +126,5 @@ export const DEFAULT_ERROR_SEVERITY: { [key in ValidationErrorTypes]: Validation 'featrue-attribute-value-code-format': 'error', 'feature-missing-link': 'warning', 'jest-unused': 'warning', + 'plugin-error': 'warning', }; diff --git a/src/lib/validators/renderer.ts b/src/lib/validators/renderer.ts index 34d46d5..ad08717 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 '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 395da8f..b02bee1 100644 --- a/src/lib/validators/validator.ts +++ b/src/lib/validators/validator.ts @@ -14,6 +14,7 @@ import { FeatureMissingLinkError, JestUnusedTestError, LoaderError, + StorybookUnusedStoryError as PluginError, 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 pluginsErrors = new Array(); private metaFilePath = ''; public readonly severity: Record; @@ -37,22 +39,27 @@ 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.pluginsErrors, + ].some((e) => this.severity[e.type] === 'error'); } printReport() { const render = (e: ValidationError) => printError(e, this.severity); this.jestUnusedTests.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.loaderErrors, ...this.metaErrors, ...this.featureErrors, ...this.jestUnusedTests, ...this.pluginsErrors], this.severity, ); } @@ -69,6 +76,15 @@ export class Validator { }); } + registerPluginError(pluginName: string, error: PluginError['error'], filePath: string) { + this.pluginsErrors.push({ + type: 'plugin-error', + pluginName, + error, + filePath, + }); + } + validate({ trees, attributes, metaFilePath, features }: ProjectData) { const metaAttributeValues = new Map>();