@application-kit/testing 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +59 -0
- package/dist/cjs/stryker/bin/application-mutation-layer.d.ts +2 -0
- package/dist/cjs/stryker/bin/application-mutation-layer.js +64 -0
- package/dist/cjs/stryker/bin/index.d.ts +1 -0
- package/dist/cjs/stryker/bin/index.js +17 -0
- package/dist/cjs/stryker/code-without-logic.d.ts +1 -0
- package/dist/cjs/stryker/code-without-logic.js +31 -0
- package/dist/cjs/stryker/decide-layer-run.d.ts +12 -0
- package/dist/cjs/stryker/decide-layer-run.js +40 -0
- package/dist/cjs/stryker/define-stryker-config.d.ts +2 -0
- package/dist/cjs/stryker/define-stryker-config.js +24 -0
- package/dist/cjs/stryker/define-stryker-layers.d.ts +2 -0
- package/dist/cjs/stryker/define-stryker-layers.js +113 -0
- package/dist/cjs/stryker/index.d.ts +7 -0
- package/dist/cjs/stryker/index.js +23 -0
- package/dist/cjs/stryker/mutation-layer-cli.d.ts +9 -0
- package/dist/cjs/stryker/mutation-layer-cli.js +64 -0
- package/dist/cjs/stryker/stryker-config.interface.d.ts +23 -0
- package/dist/cjs/stryker/stryker-config.interface.js +2 -0
- package/dist/cjs/stryker/stryker-layers.interface.d.ts +31 -0
- package/dist/cjs/stryker/stryker-layers.interface.js +2 -0
- package/dist/esm/stryker/bin/application-mutation-layer.d.ts +2 -0
- package/dist/esm/stryker/bin/application-mutation-layer.js +26 -0
- package/dist/esm/stryker/bin/index.d.ts +1 -0
- package/dist/esm/stryker/bin/index.js +1 -0
- package/dist/esm/stryker/code-without-logic.d.ts +1 -0
- package/dist/esm/stryker/code-without-logic.js +28 -0
- package/dist/esm/stryker/decide-layer-run.d.ts +12 -0
- package/dist/esm/stryker/decide-layer-run.js +36 -0
- package/dist/esm/stryker/define-stryker-config.d.ts +2 -0
- package/dist/esm/stryker/define-stryker-config.js +20 -0
- package/dist/esm/stryker/define-stryker-layers.d.ts +2 -0
- package/dist/esm/stryker/define-stryker-layers.js +106 -0
- package/dist/esm/stryker/index.d.ts +7 -0
- package/dist/esm/stryker/index.js +7 -0
- package/dist/esm/stryker/mutation-layer-cli.d.ts +9 -0
- package/dist/esm/stryker/mutation-layer-cli.js +60 -0
- package/dist/esm/stryker/stryker-config.interface.d.ts +23 -0
- package/dist/esm/stryker/stryker-config.interface.js +1 -0
- package/dist/esm/stryker/stryker-layers.interface.d.ts +31 -0
- package/dist/esm/stryker/stryker-layers.interface.js +1 -0
- package/package.json +15 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# @application-kit/testing
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- ba67414: Вход `@application-kit/testing/stryker`: `defineStrykerConfig` (база Stryker приложения), `defineStrykerLayers`
|
|
8
|
+
(мутации по группам каталогов с порогом на группу, проверкой «каталог ровно в одной группе», сверкой с матрицей CI и
|
|
9
|
+
чисткой incremental-отчёта), `CODE_WITHOUT_LOGIC` — общий список файлов без логики для `coverage.exclude` vitest и
|
|
10
|
+
`mutate` Stryker. Bin `application-mutation-layer` (`names`, `paths`, `run`) пропускает ячейку CI, если дифф MR группу не трогает.
|
|
11
|
+
|
|
12
|
+
lint-config: подпуть `@application-kit/testing/stryker` добавлен в allow-list импортов приложения.
|
|
13
|
+
|
|
3
14
|
## 0.4.0
|
|
4
15
|
|
|
5
16
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -94,3 +94,62 @@ web-карту того же файла, и statements/functions задваив
|
|
|
94
94
|
`defineApplicationUnitConfig` выбирает ячейку по `process.argv` в момент вызова: 1 — только проект `client` и покрытие
|
|
95
95
|
по `clientSources`, 2..n — только проект `server` и покрытие без `clientSources`, без флага — оба проекта. Свой конфиг
|
|
96
96
|
без фабрики собирается из `parseShard` и `ProjectShardSequencer` напрямую.
|
|
97
|
+
|
|
98
|
+
## Вход `/stryker`: мутации по группам каталогов
|
|
99
|
+
|
|
100
|
+
Вход не тянет `vitest`: конфиг Stryker — обычный объект. `stryker.config.mjs` приложения собирается из трёх частей.
|
|
101
|
+
|
|
102
|
+
`CODE_WITHOUT_LOGIC` — глобы файлов без логики (команды, DTO, модули, мапперы, манифесты, сущности и миграции TypeORM,
|
|
103
|
+
навигация, виджет-плагины). Список один на оба инструмента: `defineStrykerConfig` добавляет к нему `!` для `mutate`, а
|
|
104
|
+
приложение кладёт его в `coverage.exclude` vitest. Расхождение допустимо только в явных добавках приложения:
|
|
105
|
+
`*.spec.ts` и `tests/**` для мутаций, `*.html` для покрытия.
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
import { CODE_WITHOUT_LOGIC, defineStrykerConfig } from '@application-kit/testing/stryker'
|
|
109
|
+
|
|
110
|
+
// stryker.conf.mjs — база
|
|
111
|
+
export default defineStrykerConfig({
|
|
112
|
+
ignorePatterns: ['docs', 'tasks'],
|
|
113
|
+
mutate: { exclude: ['src/**/*.spec.ts', 'src/**/*.vue', 'src/**/tests/**'], include: ['src/modules/**/*.ts', 'src/libs/**/*.ts'] },
|
|
114
|
+
thresholds: { break: 94, high: 100, low: 95 },
|
|
115
|
+
vitestConfigFile: 'vitest.stryker.config.mts',
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// vitest.config.mts
|
|
119
|
+
coverage: { exclude: [...CODE_WITHOUT_LOGIC, '**/*.html'], include: ['src'], thresholds: { lines: 95 } }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`defineStrykerLayers` режет прогон на группы каталогов: ячейка CI мутирует одну группу со своим порогом `break` (общий
|
|
123
|
+
порог разбавлял бы требование к сильной группе слабой). Группу называет `TYPESCRIPT_MUTATION_LAYER` (имя меняется
|
|
124
|
+
`layerVariable`); без переменной берётся `defaultLayer`. Фабрика сама проверяет при загрузке: каждый корень (`scope`)
|
|
125
|
+
лежит ровно в одной группе — новый модуль без группы падает с его именем, а не выпадает молча из мутаций; список ячеек
|
|
126
|
+
`parallel: matrix` в файле CI совпадает с таблицей групп; из incremental-отчёта перед прогоном Stryker убираются файлы
|
|
127
|
+
вне области группы. Каталог внутри чужого (слой модуля) принадлежит своей группе и у объемлющей снимается сам.
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
// stryker.layer.conf.mjs
|
|
131
|
+
export const layers = defineStrykerLayers({
|
|
132
|
+
base,
|
|
133
|
+
ciFile: path.join(import.meta.dirname, '.gitlab-ci.yml'),
|
|
134
|
+
defaultLayer: 'modules',
|
|
135
|
+
layers: {
|
|
136
|
+
libs: { break: 80, paths: ['src/libs'] },
|
|
137
|
+
modules: { break: 94, paths: ['src/modules/client', 'src/modules/common'] },
|
|
138
|
+
},
|
|
139
|
+
root: import.meta.dirname,
|
|
140
|
+
scope: { directories: ['src/libs'], parents: ['src/modules'] },
|
|
141
|
+
skip: { configFiles: /^(\.gitlab-ci\.yml|vitest\.config[^/]*\.mts)$/, sharedDirectories: ['src/libs/testing-module'], sourceRoot: 'src' },
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
export default layers.config
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Bin `application-mutation-layer` читает экспорт `layers` (`--config=`, по умолчанию `stryker.layer.conf.mjs`):
|
|
148
|
+
|
|
149
|
+
- `names` — группы через пробел (`for layer in $(application-mutation-layer names)`);
|
|
150
|
+
- `paths` — каталоги группы из переменной окружения, вырезанные каталоги других групп — с `!`;
|
|
151
|
+
- `run [base]` — в MR (`base` или `CI_MERGE_REQUEST_DIFF_BASE_SHA`) пропускает ячейку, если дифф не трогает её группу,
|
|
152
|
+
иначе запускает `yarn stryker run <config> --logLevel info --concurrency 2` (`MUTATION_LAYER_COMMAND` подменяет
|
|
153
|
+
команду). Причину решения печатает всегда: зелёная ячейка без прогона и с прогоном в логе различимы. Прогон идёт при
|
|
154
|
+
любом сомнении — нет базы, дифф не читается или пуст, тронут файл из `skip.configFiles`, файл под `sourceRoot` вне
|
|
155
|
+
групп, каталог из `skip.sharedDirectories`. Образу без `git` его ставит приложение до вызова.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
36
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
37
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
38
|
+
};
|
|
39
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
+
const node_child_process_1 = require("node:child_process");
|
|
41
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
42
|
+
const node_process_1 = __importDefault(require("node:process"));
|
|
43
|
+
const node_url_1 = require("node:url");
|
|
44
|
+
const mutation_layer_cli_js_1 = require("../mutation-layer-cli.js");
|
|
45
|
+
const changedFiles = (base) => (0, node_child_process_1.execFileSync)('git', ['diff', '--name-only', `${base}...HEAD`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
|
|
46
|
+
.split('\n')
|
|
47
|
+
.filter(Boolean);
|
|
48
|
+
const loadLayers = async (config) => {
|
|
49
|
+
const loaded = (await Promise.resolve(`${(0, node_url_1.pathToFileURL)(node_path_1.default.resolve(config)).href}`).then(s => __importStar(require(s))));
|
|
50
|
+
return loaded.layers;
|
|
51
|
+
};
|
|
52
|
+
const main = async () => {
|
|
53
|
+
const code = await (0, mutation_layer_cli_js_1.executeMutationLayerCli)(node_process_1.default.argv.slice(2), {
|
|
54
|
+
changedFiles,
|
|
55
|
+
env: node_process_1.default.env,
|
|
56
|
+
loadLayers,
|
|
57
|
+
print: (line) => {
|
|
58
|
+
console.log(line);
|
|
59
|
+
},
|
|
60
|
+
run: (command) => (0, node_child_process_1.spawnSync)(command, { shell: true, stdio: 'inherit' }).status ?? 1,
|
|
61
|
+
});
|
|
62
|
+
node_process_1.default.exitCode = code;
|
|
63
|
+
};
|
|
64
|
+
void main();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './application-mutation-layer.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./application-mutation-layer.js"), exports);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const CODE_WITHOUT_LOGIC: readonly string[];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CODE_WITHOUT_LOGIC = void 0;
|
|
4
|
+
exports.CODE_WITHOUT_LOGIC = [
|
|
5
|
+
'src/**/*.command.ts',
|
|
6
|
+
'src/**/*.config.ts',
|
|
7
|
+
'src/**/*.d.ts',
|
|
8
|
+
'src/**/*.dto.ts',
|
|
9
|
+
'src/**/*.environment.ts',
|
|
10
|
+
'src/**/*.error.ts',
|
|
11
|
+
'src/**/*.fake.ts',
|
|
12
|
+
'src/**/*.interface.ts',
|
|
13
|
+
'src/**/*.mock.ts',
|
|
14
|
+
'src/**/*.module.ts',
|
|
15
|
+
'src/**/*.query.ts',
|
|
16
|
+
'src/**/*.type.ts',
|
|
17
|
+
'src/**/*constants.ts',
|
|
18
|
+
'src/**/entry.*.ts',
|
|
19
|
+
'src/**/errors/*',
|
|
20
|
+
'src/**/graphql/**/*.input.ts',
|
|
21
|
+
'src/**/graphql/**/*.output.ts',
|
|
22
|
+
'src/**/mappers/*',
|
|
23
|
+
'src/**/module.cli.ts',
|
|
24
|
+
'src/**/module.consumers.ts',
|
|
25
|
+
'src/**/module.manifest.ts',
|
|
26
|
+
'src/**/typeorm/entities/*.ts',
|
|
27
|
+
'src/**/typeorm/migrations/*.ts',
|
|
28
|
+
'src/**/user-interface/client/app/navigation.ts',
|
|
29
|
+
'src/**/user-interface/client/app/plugins/*-widget.plugin.ts',
|
|
30
|
+
'src/**/user-interface/client/app/plugins/container-plugin.ts',
|
|
31
|
+
];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { StrykerLayerSkipOptionsInterface } from './stryker-layers.interface.js';
|
|
2
|
+
export interface LayerRunDecisionInterface {
|
|
3
|
+
readonly reason: string;
|
|
4
|
+
readonly run: boolean;
|
|
5
|
+
readonly touched: readonly string[];
|
|
6
|
+
}
|
|
7
|
+
export interface LayerRunInputInterface extends StrykerLayerSkipOptionsInterface {
|
|
8
|
+
changed: readonly string[];
|
|
9
|
+
layerPaths: readonly string[];
|
|
10
|
+
roots: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
export declare const decideLayerRun: (input: LayerRunInputInterface) => LayerRunDecisionInterface;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.decideLayerRun = void 0;
|
|
4
|
+
const isUnder = (file, directory) => file.startsWith(`${directory}/`);
|
|
5
|
+
const isInLayer = (file, layerPaths) => {
|
|
6
|
+
let isHit = false;
|
|
7
|
+
for (const entry of layerPaths) {
|
|
8
|
+
if (entry.startsWith('!')) {
|
|
9
|
+
if (isUnder(file, entry.slice(1))) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
else if (isUnder(file, entry)) {
|
|
14
|
+
isHit = true;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return isHit;
|
|
18
|
+
};
|
|
19
|
+
const runBecause = (reason) => ({ reason, run: true, touched: [] });
|
|
20
|
+
const decideLayerRun = (input) => {
|
|
21
|
+
if (input.changed.length === 0) {
|
|
22
|
+
return runBecause('дифф пуст');
|
|
23
|
+
}
|
|
24
|
+
if (input.changed.some((file) => input.configFiles.test(file))) {
|
|
25
|
+
return runBecause('тронут конфиг прогона');
|
|
26
|
+
}
|
|
27
|
+
const source = input.changed.filter((file) => isUnder(file, input.sourceRoot));
|
|
28
|
+
if (source.some((file) => input.roots.every((root) => !isUnder(file, root)))) {
|
|
29
|
+
return runBecause(`тронут ${input.sourceRoot}/ вне групп`);
|
|
30
|
+
}
|
|
31
|
+
const shared = input.sharedDirectories.find((directory) => input.changed.some((file) => isUnder(file, directory)));
|
|
32
|
+
if (shared !== undefined) {
|
|
33
|
+
return runBecause(`тронута общая тестовая инфраструктура ${shared}`);
|
|
34
|
+
}
|
|
35
|
+
const touched = input.changed.filter((file) => isInLayer(file, input.layerPaths));
|
|
36
|
+
return touched.length === 0
|
|
37
|
+
? { reason: 'дифф группу не трогает', run: false, touched }
|
|
38
|
+
: { reason: 'группа тронута', run: true, touched };
|
|
39
|
+
};
|
|
40
|
+
exports.decideLayerRun = decideLayerRun;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.defineStrykerConfig = void 0;
|
|
4
|
+
const code_without_logic_js_1 = require("./code-without-logic.js");
|
|
5
|
+
const DEFAULT_IGNORE_PATTERNS = ['coverage', 'dist', 'node_modules', 'reports'];
|
|
6
|
+
const compare = (left, right) => left.localeCompare(right);
|
|
7
|
+
const defineStrykerConfig = (options) => ({
|
|
8
|
+
clearTextReporter: { reportTests: false },
|
|
9
|
+
dryRunTimeoutMinutes: 10,
|
|
10
|
+
ignorePatterns: [...DEFAULT_IGNORE_PATTERNS, ...(options.ignorePatterns ?? [])].toSorted(compare),
|
|
11
|
+
ignoreStatic: true,
|
|
12
|
+
incremental: true,
|
|
13
|
+
incrementalFile: options.incrementalFile ?? '.stryker-cache/incremental.json',
|
|
14
|
+
logLevel: 'warn',
|
|
15
|
+
mutate: [
|
|
16
|
+
...options.mutate.include,
|
|
17
|
+
...[...code_without_logic_js_1.CODE_WITHOUT_LOGIC, ...(options.mutate.exclude ?? [])].map((pattern) => `!${pattern}`),
|
|
18
|
+
],
|
|
19
|
+
tempDirName: options.tempDirName ?? '/tmp/.stryker-tmp',
|
|
20
|
+
testRunner: 'vitest',
|
|
21
|
+
thresholds: options.thresholds,
|
|
22
|
+
vitest: { configFile: options.vitestConfigFile, dir: './' },
|
|
23
|
+
});
|
|
24
|
+
exports.defineStrykerConfig = defineStrykerConfig;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.defineStrykerLayers = void 0;
|
|
7
|
+
const node_fs_1 = require("node:fs");
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const node_process_1 = __importDefault(require("node:process"));
|
|
10
|
+
const DEFAULT_LAYER_VARIABLE = 'TYPESCRIPT_MUTATION_LAYER';
|
|
11
|
+
const TEMPLATE_LAYER_SENTINEL = 'all';
|
|
12
|
+
const MATRIX_PATTERN = (variable) => {
|
|
13
|
+
const source = [variable, ':\\p{White_Space}*\\[([^\\]]*)\\]'].join('');
|
|
14
|
+
return new RegExp(source, 'u');
|
|
15
|
+
};
|
|
16
|
+
const sorted = (values) => [...values].toSorted((left, right) => left.localeCompare(right));
|
|
17
|
+
const isUnderDirectory = (file, directory) => file.startsWith(`${directory}/`);
|
|
18
|
+
const rootsOf = (options) => [
|
|
19
|
+
...options.scope.directories,
|
|
20
|
+
...options.scope.parents.flatMap((parent) => (0, node_fs_1.readdirSync)(node_path_1.default.join(options.root, parent), { withFileTypes: true })
|
|
21
|
+
.filter((entry) => entry.isDirectory())
|
|
22
|
+
.map((entry) => `${parent}/${entry.name}`)),
|
|
23
|
+
];
|
|
24
|
+
const assertLayout = (layers, roots, configName) => {
|
|
25
|
+
const paths = Object.values(layers).flatMap((layer) => layer.paths);
|
|
26
|
+
const unassigned = roots.filter((root) => !paths.includes(root));
|
|
27
|
+
const duplicated = paths.filter((entry, index) => paths.indexOf(entry) !== index);
|
|
28
|
+
const orphaned = paths.filter((entry) => roots.every((root) => !(entry === root || isUnderDirectory(entry, root))));
|
|
29
|
+
if (unassigned.length > 0 || duplicated.length > 0 || orphaned.length > 0) {
|
|
30
|
+
throw new Error(`${configName}: каталог обязан быть ровно в одной группе — без группы: [${unassigned.join(', ')}], `
|
|
31
|
+
+ `в двух: [${duplicated.join(', ')}], вне корней: [${orphaned.join(', ')}]`);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const assertMatrix = (options, variable, configName) => {
|
|
35
|
+
const matrix = MATRIX_PATTERN(variable).exec((0, node_fs_1.readFileSync)(options.ciFile).toString());
|
|
36
|
+
const matrixLayers = sorted((matrix?.[1] ?? '').split(',').map((name) => name.trim())
|
|
37
|
+
.filter(Boolean));
|
|
38
|
+
const layerNames = sorted(Object.keys(options.layers));
|
|
39
|
+
if (matrixLayers.join(',') !== layerNames.join(',')) {
|
|
40
|
+
throw new Error(`${configName}: матрица [${matrixLayers.join(', ')}] в ${node_path_1.default.basename(options.ciFile)} `
|
|
41
|
+
+ `расходится с группами [${layerNames.join(', ')}]`);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const carvedOutOf = (allPaths, layer) => {
|
|
45
|
+
const isCarvedOut = (entry) => {
|
|
46
|
+
const isOwn = layer.paths.includes(entry);
|
|
47
|
+
return !isOwn && layer.paths.some((own) => isUnderDirectory(entry, own));
|
|
48
|
+
};
|
|
49
|
+
return allPaths.filter((entry) => isCarvedOut(entry));
|
|
50
|
+
};
|
|
51
|
+
const pruneIncrementalReport = (incrementalFile, isInScope, configName) => {
|
|
52
|
+
if (!(0, node_fs_1.existsSync)(incrementalFile)) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const report = JSON.parse((0, node_fs_1.readFileSync)(incrementalFile, 'utf8'));
|
|
56
|
+
const files = report.files ?? {};
|
|
57
|
+
const foreign = Object.keys(files).filter((file) => !isInScope(file));
|
|
58
|
+
if (foreign.length === 0) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
report.files = Object.fromEntries(Object.entries(files).filter(([file]) => isInScope(file)));
|
|
62
|
+
(0, node_fs_1.writeFileSync)(`${incrementalFile}.tmp`, JSON.stringify(report));
|
|
63
|
+
(0, node_fs_1.renameSync)(`${incrementalFile}.tmp`, incrementalFile);
|
|
64
|
+
console.error(`${configName}: из ${incrementalFile} убрано файлов вне области группы: ${foreign.length}`);
|
|
65
|
+
};
|
|
66
|
+
const defineStrykerLayers = (options) => {
|
|
67
|
+
const variable = options.layerVariable ?? DEFAULT_LAYER_VARIABLE;
|
|
68
|
+
const configName = options.configName ?? 'stryker layers';
|
|
69
|
+
const names = Object.keys(options.layers);
|
|
70
|
+
const allPaths = Object.values(options.layers).flatMap((layer) => layer.paths);
|
|
71
|
+
const roots = rootsOf(options);
|
|
72
|
+
assertLayout(options.layers, roots, configName);
|
|
73
|
+
assertMatrix(options, variable, configName);
|
|
74
|
+
const layerOf = (name) => {
|
|
75
|
+
const layer = options.layers[String(name)];
|
|
76
|
+
if (layer === undefined) {
|
|
77
|
+
throw new Error(`${variable}=${String(name)}: ожидается одна из ${names.join(', ')}`);
|
|
78
|
+
}
|
|
79
|
+
return { layer, name: String(name) };
|
|
80
|
+
};
|
|
81
|
+
const pathsOf = (name) => {
|
|
82
|
+
const { layer } = layerOf(name);
|
|
83
|
+
return [...layer.paths, ...carvedOutOf(allPaths, layer).map((entry) => `!${entry}`)];
|
|
84
|
+
};
|
|
85
|
+
const raw = node_process_1.default.env[variable];
|
|
86
|
+
const isLoadedByStryker = node_process_1.default.argv.some((argument) => argument.includes('@stryker-mutator'));
|
|
87
|
+
const isUnset = raw === undefined || (!isLoadedByStryker && raw === TEMPLATE_LAYER_SENTINEL);
|
|
88
|
+
const { layer, name } = layerOf(isUnset ? options.defaultLayer : raw);
|
|
89
|
+
const carvedOut = carvedOutOf(allPaths, layer);
|
|
90
|
+
const incrementalFile = `.stryker-cache/incremental-${name}.json`;
|
|
91
|
+
if (isLoadedByStryker) {
|
|
92
|
+
pruneIncrementalReport(node_path_1.default.join(options.root, incrementalFile), (file) => layer.paths.some((own) => isUnderDirectory(file, own))
|
|
93
|
+
&& carvedOut.every((entry) => !isUnderDirectory(file, entry)), configName);
|
|
94
|
+
}
|
|
95
|
+
const exclusions = options.base.mutate.filter((pattern) => pattern.startsWith('!'));
|
|
96
|
+
return {
|
|
97
|
+
config: {
|
|
98
|
+
...options.base,
|
|
99
|
+
incrementalFile,
|
|
100
|
+
mutate: [
|
|
101
|
+
...layer.paths.map((entry) => `${entry}/**/*.ts`),
|
|
102
|
+
...carvedOut.map((entry) => `!${entry}/**`),
|
|
103
|
+
...exclusions,
|
|
104
|
+
],
|
|
105
|
+
thresholds: { ...options.base.thresholds, break: layer.break },
|
|
106
|
+
},
|
|
107
|
+
names,
|
|
108
|
+
paths: pathsOf,
|
|
109
|
+
roots,
|
|
110
|
+
skip: options.skip,
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
exports.defineStrykerLayers = defineStrykerLayers;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from './code-without-logic.js';
|
|
2
|
+
export * from './decide-layer-run.js';
|
|
3
|
+
export * from './define-stryker-config.js';
|
|
4
|
+
export * from './define-stryker-layers.js';
|
|
5
|
+
export * from './mutation-layer-cli.js';
|
|
6
|
+
export * from './stryker-config.interface.js';
|
|
7
|
+
export * from './stryker-layers.interface.js';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./code-without-logic.js"), exports);
|
|
18
|
+
__exportStar(require("./decide-layer-run.js"), exports);
|
|
19
|
+
__exportStar(require("./define-stryker-config.js"), exports);
|
|
20
|
+
__exportStar(require("./define-stryker-layers.js"), exports);
|
|
21
|
+
__exportStar(require("./mutation-layer-cli.js"), exports);
|
|
22
|
+
__exportStar(require("./stryker-config.interface.js"), exports);
|
|
23
|
+
__exportStar(require("./stryker-layers.interface.js"), exports);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { StrykerLayersInterface } from './stryker-layers.interface.js';
|
|
2
|
+
export interface MutationLayerCliDependenciesInterface {
|
|
3
|
+
changedFiles(base: string): string[];
|
|
4
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
5
|
+
loadLayers(config: string): Promise<StrykerLayersInterface>;
|
|
6
|
+
print(line: string): void;
|
|
7
|
+
run(command: string): number;
|
|
8
|
+
}
|
|
9
|
+
export declare const executeMutationLayerCli: (argv: readonly string[], dependencies: MutationLayerCliDependenciesInterface) => Promise<number>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.executeMutationLayerCli = void 0;
|
|
4
|
+
const decide_layer_run_js_1 = require("./decide-layer-run.js");
|
|
5
|
+
const DEFAULT_CONFIG = 'stryker.layer.conf.mjs';
|
|
6
|
+
const DEFAULT_LAYER_VARIABLE = 'TYPESCRIPT_MUTATION_LAYER';
|
|
7
|
+
const defaultCommand = (config) => `yarn stryker run ${config} --logLevel info --concurrency 2`;
|
|
8
|
+
const parse = (argv) => {
|
|
9
|
+
const positional = argv.filter((argument) => !argument.startsWith('--'));
|
|
10
|
+
const flag = argv.find((argument) => argument.startsWith('--config='));
|
|
11
|
+
return { base: positional[1], command: positional[0], config: flag?.slice('--config='.length) ?? DEFAULT_CONFIG };
|
|
12
|
+
};
|
|
13
|
+
const NO_BASE_REASON = 'базы диффа нет (не MR-пайплайн)';
|
|
14
|
+
const changedFilesOf = (dependencies, base) => {
|
|
15
|
+
try {
|
|
16
|
+
return dependencies.changedFiles(base);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
return Error.isError(error) ? error : new Error(String(error));
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const labelOf = (cell) => `ts:test:mutation [${String(cell.layer)}]`;
|
|
23
|
+
const launch = (cell, reason) => {
|
|
24
|
+
cell.dependencies.print(`${labelOf(cell)}: прогон — ${reason}`);
|
|
25
|
+
return cell.dependencies.run(cell.dependencies.env.MUTATION_LAYER_COMMAND ?? defaultCommand(cell.config));
|
|
26
|
+
};
|
|
27
|
+
const runCell = (cell) => {
|
|
28
|
+
const { dependencies, layer, layers } = cell;
|
|
29
|
+
const diffBase = cell.base ?? dependencies.env.CI_MERGE_REQUEST_DIFF_BASE_SHA;
|
|
30
|
+
if (diffBase === undefined || diffBase === '') {
|
|
31
|
+
return launch(cell, NO_BASE_REASON);
|
|
32
|
+
}
|
|
33
|
+
const changed = changedFilesOf(dependencies, diffBase);
|
|
34
|
+
if (Error.isError(changed)) {
|
|
35
|
+
return launch(cell, `дифф к ${diffBase} не читается: ${changed.message.replace(/\n[\s\S]*/u, '')}`);
|
|
36
|
+
}
|
|
37
|
+
const decision = (0, decide_layer_run_js_1.decideLayerRun)({ ...layers.skip, changed, layerPaths: layers.paths(layer), roots: layers.roots });
|
|
38
|
+
if (!decision.run) {
|
|
39
|
+
dependencies.print(`${labelOf(cell)}: дифф MR (база ${diffBase}) группу не трогает — прогон пропущен, кеш и score группы без изменений`);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
if (decision.touched.length > 0) {
|
|
43
|
+
dependencies.print(`${labelOf(cell)}: дифф MR трогает группу:\n${decision.touched.map((file) => ` ${file}`).join('\n')}`);
|
|
44
|
+
}
|
|
45
|
+
return launch(cell, decision.reason);
|
|
46
|
+
};
|
|
47
|
+
const executeMutationLayerCli = async (argv, dependencies) => {
|
|
48
|
+
const { base, command, config } = parse(argv);
|
|
49
|
+
const layers = await dependencies.loadLayers(config);
|
|
50
|
+
if (command === 'names') {
|
|
51
|
+
dependencies.print(layers.names.join(' '));
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
if (command === 'paths') {
|
|
55
|
+
dependencies.print(layers.paths(dependencies.env[DEFAULT_LAYER_VARIABLE]).join('\n'));
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
if (command === 'run') {
|
|
59
|
+
return runCell({ base, config, dependencies, layer: dependencies.env[DEFAULT_LAYER_VARIABLE], layers });
|
|
60
|
+
}
|
|
61
|
+
dependencies.print(`application-mutation-layer: ожидается names | paths | run, получено ${String(command)}`);
|
|
62
|
+
return 2;
|
|
63
|
+
};
|
|
64
|
+
exports.executeMutationLayerCli = executeMutationLayerCli;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface StrykerConfigInterface {
|
|
2
|
+
readonly incrementalFile: string;
|
|
3
|
+
readonly mutate: readonly string[];
|
|
4
|
+
readonly [option: string]: unknown;
|
|
5
|
+
readonly thresholds: StrykerThresholdsInterface;
|
|
6
|
+
}
|
|
7
|
+
export interface StrykerConfigOptionsInterface {
|
|
8
|
+
ignorePatterns?: readonly string[];
|
|
9
|
+
incrementalFile?: string;
|
|
10
|
+
mutate: StrykerMutateScopeInterface;
|
|
11
|
+
tempDirName?: string;
|
|
12
|
+
thresholds: StrykerThresholdsInterface;
|
|
13
|
+
vitestConfigFile: string;
|
|
14
|
+
}
|
|
15
|
+
export interface StrykerMutateScopeInterface {
|
|
16
|
+
exclude?: readonly string[];
|
|
17
|
+
include: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
export interface StrykerThresholdsInterface {
|
|
20
|
+
readonly break: number;
|
|
21
|
+
readonly high: number;
|
|
22
|
+
readonly low: number;
|
|
23
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { StrykerConfigInterface } from './stryker-config.interface.js';
|
|
2
|
+
export interface StrykerLayerInterface {
|
|
3
|
+
break: number;
|
|
4
|
+
paths: readonly string[];
|
|
5
|
+
}
|
|
6
|
+
export interface StrykerLayersInterface {
|
|
7
|
+
config: StrykerConfigInterface;
|
|
8
|
+
names: readonly string[];
|
|
9
|
+
paths(name: string | undefined): string[];
|
|
10
|
+
roots: readonly string[];
|
|
11
|
+
skip: StrykerLayerSkipOptionsInterface;
|
|
12
|
+
}
|
|
13
|
+
export interface StrykerLayerSkipOptionsInterface {
|
|
14
|
+
configFiles: RegExp;
|
|
15
|
+
sharedDirectories: readonly string[];
|
|
16
|
+
sourceRoot: string;
|
|
17
|
+
}
|
|
18
|
+
export interface StrykerLayersOptionsInterface {
|
|
19
|
+
base: StrykerConfigInterface;
|
|
20
|
+
ciFile: string;
|
|
21
|
+
configName?: string;
|
|
22
|
+
defaultLayer: string;
|
|
23
|
+
layers: Readonly<Record<string, StrykerLayerInterface>>;
|
|
24
|
+
layerVariable?: string;
|
|
25
|
+
root: string;
|
|
26
|
+
scope: {
|
|
27
|
+
directories: readonly string[];
|
|
28
|
+
parents: readonly string[];
|
|
29
|
+
};
|
|
30
|
+
skip: StrykerLayerSkipOptionsInterface;
|
|
31
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { executeMutationLayerCli } from '../mutation-layer-cli.js';
|
|
7
|
+
const changedFiles = (base) => execFileSync('git', ['diff', '--name-only', `${base}...HEAD`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
|
|
8
|
+
.split('\n')
|
|
9
|
+
.filter(Boolean);
|
|
10
|
+
const loadLayers = async (config) => {
|
|
11
|
+
const loaded = (await import(pathToFileURL(path.resolve(config)).href));
|
|
12
|
+
return loaded.layers;
|
|
13
|
+
};
|
|
14
|
+
const main = async () => {
|
|
15
|
+
const code = await executeMutationLayerCli(process.argv.slice(2), {
|
|
16
|
+
changedFiles,
|
|
17
|
+
env: process.env,
|
|
18
|
+
loadLayers,
|
|
19
|
+
print: (line) => {
|
|
20
|
+
console.log(line);
|
|
21
|
+
},
|
|
22
|
+
run: (command) => spawnSync(command, { shell: true, stdio: 'inherit' }).status ?? 1,
|
|
23
|
+
});
|
|
24
|
+
process.exitCode = code;
|
|
25
|
+
};
|
|
26
|
+
void main();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './application-mutation-layer.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './application-mutation-layer.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const CODE_WITHOUT_LOGIC: readonly string[];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const CODE_WITHOUT_LOGIC = [
|
|
2
|
+
'src/**/*.command.ts',
|
|
3
|
+
'src/**/*.config.ts',
|
|
4
|
+
'src/**/*.d.ts',
|
|
5
|
+
'src/**/*.dto.ts',
|
|
6
|
+
'src/**/*.environment.ts',
|
|
7
|
+
'src/**/*.error.ts',
|
|
8
|
+
'src/**/*.fake.ts',
|
|
9
|
+
'src/**/*.interface.ts',
|
|
10
|
+
'src/**/*.mock.ts',
|
|
11
|
+
'src/**/*.module.ts',
|
|
12
|
+
'src/**/*.query.ts',
|
|
13
|
+
'src/**/*.type.ts',
|
|
14
|
+
'src/**/*constants.ts',
|
|
15
|
+
'src/**/entry.*.ts',
|
|
16
|
+
'src/**/errors/*',
|
|
17
|
+
'src/**/graphql/**/*.input.ts',
|
|
18
|
+
'src/**/graphql/**/*.output.ts',
|
|
19
|
+
'src/**/mappers/*',
|
|
20
|
+
'src/**/module.cli.ts',
|
|
21
|
+
'src/**/module.consumers.ts',
|
|
22
|
+
'src/**/module.manifest.ts',
|
|
23
|
+
'src/**/typeorm/entities/*.ts',
|
|
24
|
+
'src/**/typeorm/migrations/*.ts',
|
|
25
|
+
'src/**/user-interface/client/app/navigation.ts',
|
|
26
|
+
'src/**/user-interface/client/app/plugins/*-widget.plugin.ts',
|
|
27
|
+
'src/**/user-interface/client/app/plugins/container-plugin.ts',
|
|
28
|
+
];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { StrykerLayerSkipOptionsInterface } from './stryker-layers.interface.js';
|
|
2
|
+
export interface LayerRunDecisionInterface {
|
|
3
|
+
readonly reason: string;
|
|
4
|
+
readonly run: boolean;
|
|
5
|
+
readonly touched: readonly string[];
|
|
6
|
+
}
|
|
7
|
+
export interface LayerRunInputInterface extends StrykerLayerSkipOptionsInterface {
|
|
8
|
+
changed: readonly string[];
|
|
9
|
+
layerPaths: readonly string[];
|
|
10
|
+
roots: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
export declare const decideLayerRun: (input: LayerRunInputInterface) => LayerRunDecisionInterface;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const isUnder = (file, directory) => file.startsWith(`${directory}/`);
|
|
2
|
+
const isInLayer = (file, layerPaths) => {
|
|
3
|
+
let isHit = false;
|
|
4
|
+
for (const entry of layerPaths) {
|
|
5
|
+
if (entry.startsWith('!')) {
|
|
6
|
+
if (isUnder(file, entry.slice(1))) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
else if (isUnder(file, entry)) {
|
|
11
|
+
isHit = true;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return isHit;
|
|
15
|
+
};
|
|
16
|
+
const runBecause = (reason) => ({ reason, run: true, touched: [] });
|
|
17
|
+
export const decideLayerRun = (input) => {
|
|
18
|
+
if (input.changed.length === 0) {
|
|
19
|
+
return runBecause('дифф пуст');
|
|
20
|
+
}
|
|
21
|
+
if (input.changed.some((file) => input.configFiles.test(file))) {
|
|
22
|
+
return runBecause('тронут конфиг прогона');
|
|
23
|
+
}
|
|
24
|
+
const source = input.changed.filter((file) => isUnder(file, input.sourceRoot));
|
|
25
|
+
if (source.some((file) => input.roots.every((root) => !isUnder(file, root)))) {
|
|
26
|
+
return runBecause(`тронут ${input.sourceRoot}/ вне групп`);
|
|
27
|
+
}
|
|
28
|
+
const shared = input.sharedDirectories.find((directory) => input.changed.some((file) => isUnder(file, directory)));
|
|
29
|
+
if (shared !== undefined) {
|
|
30
|
+
return runBecause(`тронута общая тестовая инфраструктура ${shared}`);
|
|
31
|
+
}
|
|
32
|
+
const touched = input.changed.filter((file) => isInLayer(file, input.layerPaths));
|
|
33
|
+
return touched.length === 0
|
|
34
|
+
? { reason: 'дифф группу не трогает', run: false, touched }
|
|
35
|
+
: { reason: 'группа тронута', run: true, touched };
|
|
36
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { CODE_WITHOUT_LOGIC } from './code-without-logic.js';
|
|
2
|
+
const DEFAULT_IGNORE_PATTERNS = ['coverage', 'dist', 'node_modules', 'reports'];
|
|
3
|
+
const compare = (left, right) => left.localeCompare(right);
|
|
4
|
+
export const defineStrykerConfig = (options) => ({
|
|
5
|
+
clearTextReporter: { reportTests: false },
|
|
6
|
+
dryRunTimeoutMinutes: 10,
|
|
7
|
+
ignorePatterns: [...DEFAULT_IGNORE_PATTERNS, ...(options.ignorePatterns ?? [])].toSorted(compare),
|
|
8
|
+
ignoreStatic: true,
|
|
9
|
+
incremental: true,
|
|
10
|
+
incrementalFile: options.incrementalFile ?? '.stryker-cache/incremental.json',
|
|
11
|
+
logLevel: 'warn',
|
|
12
|
+
mutate: [
|
|
13
|
+
...options.mutate.include,
|
|
14
|
+
...[...CODE_WITHOUT_LOGIC, ...(options.mutate.exclude ?? [])].map((pattern) => `!${pattern}`),
|
|
15
|
+
],
|
|
16
|
+
tempDirName: options.tempDirName ?? '/tmp/.stryker-tmp',
|
|
17
|
+
testRunner: 'vitest',
|
|
18
|
+
thresholds: options.thresholds,
|
|
19
|
+
vitest: { configFile: options.vitestConfigFile, dir: './' },
|
|
20
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
const DEFAULT_LAYER_VARIABLE = 'TYPESCRIPT_MUTATION_LAYER';
|
|
5
|
+
const TEMPLATE_LAYER_SENTINEL = 'all';
|
|
6
|
+
const MATRIX_PATTERN = (variable) => {
|
|
7
|
+
const source = [variable, ':\\p{White_Space}*\\[([^\\]]*)\\]'].join('');
|
|
8
|
+
return new RegExp(source, 'u');
|
|
9
|
+
};
|
|
10
|
+
const sorted = (values) => [...values].toSorted((left, right) => left.localeCompare(right));
|
|
11
|
+
const isUnderDirectory = (file, directory) => file.startsWith(`${directory}/`);
|
|
12
|
+
const rootsOf = (options) => [
|
|
13
|
+
...options.scope.directories,
|
|
14
|
+
...options.scope.parents.flatMap((parent) => readdirSync(path.join(options.root, parent), { withFileTypes: true })
|
|
15
|
+
.filter((entry) => entry.isDirectory())
|
|
16
|
+
.map((entry) => `${parent}/${entry.name}`)),
|
|
17
|
+
];
|
|
18
|
+
const assertLayout = (layers, roots, configName) => {
|
|
19
|
+
const paths = Object.values(layers).flatMap((layer) => layer.paths);
|
|
20
|
+
const unassigned = roots.filter((root) => !paths.includes(root));
|
|
21
|
+
const duplicated = paths.filter((entry, index) => paths.indexOf(entry) !== index);
|
|
22
|
+
const orphaned = paths.filter((entry) => roots.every((root) => !(entry === root || isUnderDirectory(entry, root))));
|
|
23
|
+
if (unassigned.length > 0 || duplicated.length > 0 || orphaned.length > 0) {
|
|
24
|
+
throw new Error(`${configName}: каталог обязан быть ровно в одной группе — без группы: [${unassigned.join(', ')}], `
|
|
25
|
+
+ `в двух: [${duplicated.join(', ')}], вне корней: [${orphaned.join(', ')}]`);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const assertMatrix = (options, variable, configName) => {
|
|
29
|
+
const matrix = MATRIX_PATTERN(variable).exec(readFileSync(options.ciFile).toString());
|
|
30
|
+
const matrixLayers = sorted((matrix?.[1] ?? '').split(',').map((name) => name.trim())
|
|
31
|
+
.filter(Boolean));
|
|
32
|
+
const layerNames = sorted(Object.keys(options.layers));
|
|
33
|
+
if (matrixLayers.join(',') !== layerNames.join(',')) {
|
|
34
|
+
throw new Error(`${configName}: матрица [${matrixLayers.join(', ')}] в ${path.basename(options.ciFile)} `
|
|
35
|
+
+ `расходится с группами [${layerNames.join(', ')}]`);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const carvedOutOf = (allPaths, layer) => {
|
|
39
|
+
const isCarvedOut = (entry) => {
|
|
40
|
+
const isOwn = layer.paths.includes(entry);
|
|
41
|
+
return !isOwn && layer.paths.some((own) => isUnderDirectory(entry, own));
|
|
42
|
+
};
|
|
43
|
+
return allPaths.filter((entry) => isCarvedOut(entry));
|
|
44
|
+
};
|
|
45
|
+
const pruneIncrementalReport = (incrementalFile, isInScope, configName) => {
|
|
46
|
+
if (!existsSync(incrementalFile)) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const report = JSON.parse(readFileSync(incrementalFile, 'utf8'));
|
|
50
|
+
const files = report.files ?? {};
|
|
51
|
+
const foreign = Object.keys(files).filter((file) => !isInScope(file));
|
|
52
|
+
if (foreign.length === 0) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
report.files = Object.fromEntries(Object.entries(files).filter(([file]) => isInScope(file)));
|
|
56
|
+
writeFileSync(`${incrementalFile}.tmp`, JSON.stringify(report));
|
|
57
|
+
renameSync(`${incrementalFile}.tmp`, incrementalFile);
|
|
58
|
+
console.error(`${configName}: из ${incrementalFile} убрано файлов вне области группы: ${foreign.length}`);
|
|
59
|
+
};
|
|
60
|
+
export const defineStrykerLayers = (options) => {
|
|
61
|
+
const variable = options.layerVariable ?? DEFAULT_LAYER_VARIABLE;
|
|
62
|
+
const configName = options.configName ?? 'stryker layers';
|
|
63
|
+
const names = Object.keys(options.layers);
|
|
64
|
+
const allPaths = Object.values(options.layers).flatMap((layer) => layer.paths);
|
|
65
|
+
const roots = rootsOf(options);
|
|
66
|
+
assertLayout(options.layers, roots, configName);
|
|
67
|
+
assertMatrix(options, variable, configName);
|
|
68
|
+
const layerOf = (name) => {
|
|
69
|
+
const layer = options.layers[String(name)];
|
|
70
|
+
if (layer === undefined) {
|
|
71
|
+
throw new Error(`${variable}=${String(name)}: ожидается одна из ${names.join(', ')}`);
|
|
72
|
+
}
|
|
73
|
+
return { layer, name: String(name) };
|
|
74
|
+
};
|
|
75
|
+
const pathsOf = (name) => {
|
|
76
|
+
const { layer } = layerOf(name);
|
|
77
|
+
return [...layer.paths, ...carvedOutOf(allPaths, layer).map((entry) => `!${entry}`)];
|
|
78
|
+
};
|
|
79
|
+
const raw = process.env[variable];
|
|
80
|
+
const isLoadedByStryker = process.argv.some((argument) => argument.includes('@stryker-mutator'));
|
|
81
|
+
const isUnset = raw === undefined || (!isLoadedByStryker && raw === TEMPLATE_LAYER_SENTINEL);
|
|
82
|
+
const { layer, name } = layerOf(isUnset ? options.defaultLayer : raw);
|
|
83
|
+
const carvedOut = carvedOutOf(allPaths, layer);
|
|
84
|
+
const incrementalFile = `.stryker-cache/incremental-${name}.json`;
|
|
85
|
+
if (isLoadedByStryker) {
|
|
86
|
+
pruneIncrementalReport(path.join(options.root, incrementalFile), (file) => layer.paths.some((own) => isUnderDirectory(file, own))
|
|
87
|
+
&& carvedOut.every((entry) => !isUnderDirectory(file, entry)), configName);
|
|
88
|
+
}
|
|
89
|
+
const exclusions = options.base.mutate.filter((pattern) => pattern.startsWith('!'));
|
|
90
|
+
return {
|
|
91
|
+
config: {
|
|
92
|
+
...options.base,
|
|
93
|
+
incrementalFile,
|
|
94
|
+
mutate: [
|
|
95
|
+
...layer.paths.map((entry) => `${entry}/**/*.ts`),
|
|
96
|
+
...carvedOut.map((entry) => `!${entry}/**`),
|
|
97
|
+
...exclusions,
|
|
98
|
+
],
|
|
99
|
+
thresholds: { ...options.base.thresholds, break: layer.break },
|
|
100
|
+
},
|
|
101
|
+
names,
|
|
102
|
+
paths: pathsOf,
|
|
103
|
+
roots,
|
|
104
|
+
skip: options.skip,
|
|
105
|
+
};
|
|
106
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from './code-without-logic.js';
|
|
2
|
+
export * from './decide-layer-run.js';
|
|
3
|
+
export * from './define-stryker-config.js';
|
|
4
|
+
export * from './define-stryker-layers.js';
|
|
5
|
+
export * from './mutation-layer-cli.js';
|
|
6
|
+
export * from './stryker-config.interface.js';
|
|
7
|
+
export * from './stryker-layers.interface.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from './code-without-logic.js';
|
|
2
|
+
export * from './decide-layer-run.js';
|
|
3
|
+
export * from './define-stryker-config.js';
|
|
4
|
+
export * from './define-stryker-layers.js';
|
|
5
|
+
export * from './mutation-layer-cli.js';
|
|
6
|
+
export * from './stryker-config.interface.js';
|
|
7
|
+
export * from './stryker-layers.interface.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { StrykerLayersInterface } from './stryker-layers.interface.js';
|
|
2
|
+
export interface MutationLayerCliDependenciesInterface {
|
|
3
|
+
changedFiles(base: string): string[];
|
|
4
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
5
|
+
loadLayers(config: string): Promise<StrykerLayersInterface>;
|
|
6
|
+
print(line: string): void;
|
|
7
|
+
run(command: string): number;
|
|
8
|
+
}
|
|
9
|
+
export declare const executeMutationLayerCli: (argv: readonly string[], dependencies: MutationLayerCliDependenciesInterface) => Promise<number>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { decideLayerRun } from './decide-layer-run.js';
|
|
2
|
+
const DEFAULT_CONFIG = 'stryker.layer.conf.mjs';
|
|
3
|
+
const DEFAULT_LAYER_VARIABLE = 'TYPESCRIPT_MUTATION_LAYER';
|
|
4
|
+
const defaultCommand = (config) => `yarn stryker run ${config} --logLevel info --concurrency 2`;
|
|
5
|
+
const parse = (argv) => {
|
|
6
|
+
const positional = argv.filter((argument) => !argument.startsWith('--'));
|
|
7
|
+
const flag = argv.find((argument) => argument.startsWith('--config='));
|
|
8
|
+
return { base: positional[1], command: positional[0], config: flag?.slice('--config='.length) ?? DEFAULT_CONFIG };
|
|
9
|
+
};
|
|
10
|
+
const NO_BASE_REASON = 'базы диффа нет (не MR-пайплайн)';
|
|
11
|
+
const changedFilesOf = (dependencies, base) => {
|
|
12
|
+
try {
|
|
13
|
+
return dependencies.changedFiles(base);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
return Error.isError(error) ? error : new Error(String(error));
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const labelOf = (cell) => `ts:test:mutation [${String(cell.layer)}]`;
|
|
20
|
+
const launch = (cell, reason) => {
|
|
21
|
+
cell.dependencies.print(`${labelOf(cell)}: прогон — ${reason}`);
|
|
22
|
+
return cell.dependencies.run(cell.dependencies.env.MUTATION_LAYER_COMMAND ?? defaultCommand(cell.config));
|
|
23
|
+
};
|
|
24
|
+
const runCell = (cell) => {
|
|
25
|
+
const { dependencies, layer, layers } = cell;
|
|
26
|
+
const diffBase = cell.base ?? dependencies.env.CI_MERGE_REQUEST_DIFF_BASE_SHA;
|
|
27
|
+
if (diffBase === undefined || diffBase === '') {
|
|
28
|
+
return launch(cell, NO_BASE_REASON);
|
|
29
|
+
}
|
|
30
|
+
const changed = changedFilesOf(dependencies, diffBase);
|
|
31
|
+
if (Error.isError(changed)) {
|
|
32
|
+
return launch(cell, `дифф к ${diffBase} не читается: ${changed.message.replace(/\n[\s\S]*/u, '')}`);
|
|
33
|
+
}
|
|
34
|
+
const decision = decideLayerRun({ ...layers.skip, changed, layerPaths: layers.paths(layer), roots: layers.roots });
|
|
35
|
+
if (!decision.run) {
|
|
36
|
+
dependencies.print(`${labelOf(cell)}: дифф MR (база ${diffBase}) группу не трогает — прогон пропущен, кеш и score группы без изменений`);
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
if (decision.touched.length > 0) {
|
|
40
|
+
dependencies.print(`${labelOf(cell)}: дифф MR трогает группу:\n${decision.touched.map((file) => ` ${file}`).join('\n')}`);
|
|
41
|
+
}
|
|
42
|
+
return launch(cell, decision.reason);
|
|
43
|
+
};
|
|
44
|
+
export const executeMutationLayerCli = async (argv, dependencies) => {
|
|
45
|
+
const { base, command, config } = parse(argv);
|
|
46
|
+
const layers = await dependencies.loadLayers(config);
|
|
47
|
+
if (command === 'names') {
|
|
48
|
+
dependencies.print(layers.names.join(' '));
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
if (command === 'paths') {
|
|
52
|
+
dependencies.print(layers.paths(dependencies.env[DEFAULT_LAYER_VARIABLE]).join('\n'));
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
if (command === 'run') {
|
|
56
|
+
return runCell({ base, config, dependencies, layer: dependencies.env[DEFAULT_LAYER_VARIABLE], layers });
|
|
57
|
+
}
|
|
58
|
+
dependencies.print(`application-mutation-layer: ожидается names | paths | run, получено ${String(command)}`);
|
|
59
|
+
return 2;
|
|
60
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface StrykerConfigInterface {
|
|
2
|
+
readonly incrementalFile: string;
|
|
3
|
+
readonly mutate: readonly string[];
|
|
4
|
+
readonly [option: string]: unknown;
|
|
5
|
+
readonly thresholds: StrykerThresholdsInterface;
|
|
6
|
+
}
|
|
7
|
+
export interface StrykerConfigOptionsInterface {
|
|
8
|
+
ignorePatterns?: readonly string[];
|
|
9
|
+
incrementalFile?: string;
|
|
10
|
+
mutate: StrykerMutateScopeInterface;
|
|
11
|
+
tempDirName?: string;
|
|
12
|
+
thresholds: StrykerThresholdsInterface;
|
|
13
|
+
vitestConfigFile: string;
|
|
14
|
+
}
|
|
15
|
+
export interface StrykerMutateScopeInterface {
|
|
16
|
+
exclude?: readonly string[];
|
|
17
|
+
include: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
export interface StrykerThresholdsInterface {
|
|
20
|
+
readonly break: number;
|
|
21
|
+
readonly high: number;
|
|
22
|
+
readonly low: number;
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { StrykerConfigInterface } from './stryker-config.interface.js';
|
|
2
|
+
export interface StrykerLayerInterface {
|
|
3
|
+
break: number;
|
|
4
|
+
paths: readonly string[];
|
|
5
|
+
}
|
|
6
|
+
export interface StrykerLayersInterface {
|
|
7
|
+
config: StrykerConfigInterface;
|
|
8
|
+
names: readonly string[];
|
|
9
|
+
paths(name: string | undefined): string[];
|
|
10
|
+
roots: readonly string[];
|
|
11
|
+
skip: StrykerLayerSkipOptionsInterface;
|
|
12
|
+
}
|
|
13
|
+
export interface StrykerLayerSkipOptionsInterface {
|
|
14
|
+
configFiles: RegExp;
|
|
15
|
+
sharedDirectories: readonly string[];
|
|
16
|
+
sourceRoot: string;
|
|
17
|
+
}
|
|
18
|
+
export interface StrykerLayersOptionsInterface {
|
|
19
|
+
base: StrykerConfigInterface;
|
|
20
|
+
ciFile: string;
|
|
21
|
+
configName?: string;
|
|
22
|
+
defaultLayer: string;
|
|
23
|
+
layers: Readonly<Record<string, StrykerLayerInterface>>;
|
|
24
|
+
layerVariable?: string;
|
|
25
|
+
root: string;
|
|
26
|
+
scope: {
|
|
27
|
+
directories: readonly string[];
|
|
28
|
+
parents: readonly string[];
|
|
29
|
+
};
|
|
30
|
+
skip: StrykerLayerSkipOptionsInterface;
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@application-kit/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Тестовая инфраструктура NestJS-приложений: шины CQRS, логгер, фейковый microservice, сборка web-приложения, Postgres в testcontainers, раскладка ячеек CI по проектам vitest и фабрики конфигов vitest",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cqrs",
|
|
@@ -34,6 +34,16 @@
|
|
|
34
34
|
}
|
|
35
35
|
},
|
|
36
36
|
"./package.json": "./package.json",
|
|
37
|
+
"./stryker": {
|
|
38
|
+
"import": {
|
|
39
|
+
"types": "./dist/esm/stryker/index.d.ts",
|
|
40
|
+
"default": "./dist/esm/stryker/index.js"
|
|
41
|
+
},
|
|
42
|
+
"require": {
|
|
43
|
+
"types": "./dist/cjs/stryker/index.d.ts",
|
|
44
|
+
"default": "./dist/cjs/stryker/index.js"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
37
47
|
"./vitest": {
|
|
38
48
|
"import": {
|
|
39
49
|
"types": "./dist/esm/vitest/index.d.ts",
|
|
@@ -48,11 +58,14 @@
|
|
|
48
58
|
"main": "./dist/cjs/index.js",
|
|
49
59
|
"module": "./dist/esm/index.js",
|
|
50
60
|
"types": "./dist/cjs/index.d.ts",
|
|
61
|
+
"bin": {
|
|
62
|
+
"application-mutation-layer": "./dist/esm/stryker/bin/application-mutation-layer.js"
|
|
63
|
+
},
|
|
51
64
|
"files": [
|
|
52
65
|
"dist"
|
|
53
66
|
],
|
|
54
67
|
"scripts": {
|
|
55
|
-
"build": "rm -rf ./dist && node ../../scripts/barrels.mjs create ./src --entry-only=vitest && tsc --project tsconfig.build.json && tsc --project tsconfig.build.cjs.json && node ../../scripts/barrels.mjs clean ./src && node ../../scripts/mark-cjs.mjs ./dist/cjs",
|
|
68
|
+
"build": "rm -rf ./dist && node ../../scripts/barrels.mjs create ./src --entry-only=bin,stryker,vitest && tsc --project tsconfig.build.json && tsc --project tsconfig.build.cjs.json && node ../../scripts/barrels.mjs clean ./src && node ../../scripts/mark-cjs.mjs ./dist/cjs",
|
|
56
69
|
"lint:tsc": "tsc --noEmit --pretty --project .",
|
|
57
70
|
"test:mutation": "stryker run",
|
|
58
71
|
"test:unit": "vitest run"
|