Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ dist
jest-report.json
.spec-box-meta.yml
.tms.json
specs-demo
specs-demo
index.json
65 changes: 63 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,11 @@ trees:

## Автоматическое определение признака automationState

Вместе с информацией о функциональных требованиях можно выгружать информацию о том, что их проверка автоматизирована. Определение автоматизированных ФТ выполняется автоматически, на основе информации об отчете jest в формате json. Отчет о выполнении тестов можно сформировать, запустив jest с параметром `--json`, например:
Вместе с информацией о функциональных требованиях можно выгружать информацию о том, что их проверка автоматизирована. Для некоторых видов автотестов реализовано автоматическое вычисление признака `automationState`. На текущий момент поддерживаются `jest` и `storybook`.

### Jest

Отчет о выполнении тестов можно сформировать, запустив jest с параметром `--json`, например:

```sh
jest --json --outputFile=jest-report.json
Expand Down Expand Up @@ -200,6 +204,48 @@ describe('Главная страница', () => {
- `@<attribute>` — значение указанного атрибута (идентификатор значения)
- `$<attribute>` — значение указанного атрибута (человеко-понятное название)

### 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<typeof Cart>;

export const Default: Story = {
name: 'Отображается количество и общая стоимость товаров в корзине',
render: () => <Cart />,
};
```

## Формат конфига

Ниже указаны все возможные параметры конфигурационного файла:
Expand Down Expand Up @@ -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": {}
}
}
```
7 changes: 6 additions & 1 deletion src/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);

Expand Down
7 changes: 6 additions & 1 deletion src/commands/validate-only.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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('При валидации были обнаружены критические ошибки');
Expand Down
3 changes: 3 additions & 0 deletions src/lib/config/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -86,6 +88,7 @@ export const configDecoder = d.intersect(
projectPath: d.string,
validation: validationConfigDecoder,
jest: jestConfigDecoder,
plugins: pluginsDecoder,
}),
);

Expand Down
2 changes: 1 addition & 1 deletion src/lib/domain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
18 changes: 17 additions & 1 deletion src/lib/domain/keys.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Attribute } from './models';
import { Assertion, AssertionGroup, Attribute, Feature } from './models';

export const UNDEFINED = 'UNDEFINED';
export const AMBIGUOUS = 'AMBIGUOUS';
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 5 additions & 14 deletions src/lib/jest/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down
28 changes: 28 additions & 0 deletions src/lib/pluginsLoader/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => {
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/';
80 changes: 80 additions & 0 deletions src/lib/storybook/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();

const automatedAssertions = new Set<string>();

// формируем список ключей сторей из конфига 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;
};
31 changes: 31 additions & 0 deletions src/lib/storybook/models.ts
Original file line number Diff line number Diff line change
@@ -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<typeof storybookConfigDecoder>;
export type Story = d.TypeOf<typeof storyDecoder>;
export type StorybookIndex = d.TypeOf<typeof storybookIndexDecoder>;
Loading