mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
ci: мутанты по диффу на каждом пуше, отбор и по файлам гарда
User-Visible: no Issue: #475
This commit is contained in:
@@ -450,6 +450,68 @@ jobs:
|
||||
- name: Hassfest validation
|
||||
uses: home-assistant/actions/hassfest@master
|
||||
|
||||
# Мутанты по диффу (#475). Свидетель гниёт двумя способами: изменился
|
||||
# файл, который он патчит, либо изменился его гард. `--check` этого не
|
||||
# видит — он проверяет существование якоря, а не достижимость ветки.
|
||||
# Полный прогон раз в неделю ловит гниль с опозданием до релиза (#466,
|
||||
# #467). Здесь гоняются только затронутые мутанты — единицы, минуты —
|
||||
# с той же базы диапазона, что и остальные гейты ветки (#387/#388).
|
||||
changed_mutants:
|
||||
name: "Мутанты по диффу: затронутые свидетели краснеют"
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.14'
|
||||
- name: Backend test dependencies
|
||||
run: pip install -r tests_backend/requirements.txt
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Установить Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Затронутые мутанты ловятся
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
PROVEN_BASE: ${{ needs.changes.outputs.range_base }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch -q origin dev
|
||||
zero=$(printf '%040d' 0)
|
||||
base=""
|
||||
if [ "$EVENT_NAME" != "pull_request" ] && [ "$REF" = "refs/heads/dev" ]; then
|
||||
base="${PROVEN_BASE:-$BEFORE_SHA}"
|
||||
fi
|
||||
if [ -z "$base" ] || [ "$base" = "$zero" ] \
|
||||
|| ! git cat-file -e "$base" 2>/dev/null; then
|
||||
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "")
|
||||
fi
|
||||
if [ -z "$base" ]; then
|
||||
echo "не нашёл базу диапазона — мутанты по диффу пропущены, это увидит следующий пуш"
|
||||
exit 0
|
||||
fi
|
||||
echo "диапазон: $base..$HEAD_SHA"
|
||||
npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
|
||||
node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA"
|
||||
|
||||
frontend:
|
||||
name: "Фронтенд: типы, юниты, мутанты, синхрон бандла"
|
||||
needs: changes
|
||||
|
||||
@@ -2862,6 +2862,30 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: " if (asEscaped) { escaped.add(asEscaped[1]); continue; }\n if (/^FAIL (\\S+)/.test(line)) { escaped.add(line.split(' ')[1].replace(/:$/, '')); continue; }",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'changed-selection-ignores-guard-files',
|
||||
guard: 'node --test --test-name-pattern="#475 AC2" test/mutation-gate.test.mjs',
|
||||
because: 'a witness also rots when its guard changes and stops reaching the mutated branch; '
|
||||
+ 'selecting by patch file alone lets that class survive until the weekly full run (#475)',
|
||||
patches: [{
|
||||
file: 'scripts/mutation-gate.mjs',
|
||||
// Реестр живёт в том же файле, что и код: якорь собирается из двух
|
||||
// частей, иначе --check найдёт его дважды — в коде и здесь.
|
||||
find: ' || guardFiles(m.guard, exists)' + '.some((file) => changed.has(file)));',
|
||||
replace: ' || false);',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'changed-selection-matches-any-token',
|
||||
guard: 'node --test --test-name-pattern="#475 AC3" test/mutation-gate.test.mjs',
|
||||
because: 'treating every token of the guard command as a file turns pattern fragments '
|
||||
+ 'like `b.mjs"` into guard files and selects mutants for unrelated diffs (#475)',
|
||||
patches: [{
|
||||
file: 'scripts/mutation-gate.mjs',
|
||||
find: " if (!/^[\\w./-]+\\.(mjs|py)$/.test(bare) || bare.startsWith('-')) continue;",
|
||||
replace: " if (!/\\.(mjs|py)/.test(bare)) continue;",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'review-comment-source-ignores-issue-number',
|
||||
guard: 'node --test --test-name-pattern="по документу ЭТОЙ задачи|чужой номер задачи" '
|
||||
@@ -6975,9 +6999,41 @@ function runCleanGuards(mutants) {
|
||||
* проверки и ревью-циклов; полный набор остаётся предрелизным контрактом,
|
||||
* поэтому пустая выборка — честный успех с явным сообщением, а не ошибка.
|
||||
*/
|
||||
export function selectChangedMutants(mutants, changedFiles) {
|
||||
/**
|
||||
* Файлы, на которые ссылается команда гарда (#475).
|
||||
*
|
||||
* Без парсинга команды: берутся токены с суффиксом `.mjs`, `.test.mjs` или
|
||||
* `.py`, которые существуют в репозитории. Флаги (`--test-name-pattern=…`),
|
||||
* шаблоны и произвольные слова файлами не считаются. Фикстуры гардов
|
||||
* (`test/fixtures/*`, `tests_backend/fixtures/*`) по команде вывести нельзя —
|
||||
* это граница: их дрейф остаётся полному прогону.
|
||||
*/
|
||||
export function guardFiles(guard, exists = (file) => existsSync(join(repoRoot, file))) {
|
||||
const files = new Set();
|
||||
for (const token of String(guard || '').split(/\s+/)) {
|
||||
const bare = token.replace(/^["']|["']$/g, '');
|
||||
// Путь, а не обрывок шаблона: только [A-Za-z0-9_./-], без кавычек,
|
||||
// «|» и флагов. `--test-name-pattern="a|b.mjs"` даёт токен `b.mjs"` —
|
||||
// он не файл, даже если бы такой существовал.
|
||||
if (!/^[\w./-]+\.(mjs|py)$/.test(bare) || bare.startsWith('-')) continue;
|
||||
if (exists(bare)) files.add(bare);
|
||||
}
|
||||
return [...files];
|
||||
}
|
||||
|
||||
/**
|
||||
* Мутанты, затронутые диффом (#332, расширено в #475).
|
||||
*
|
||||
* Два способа свидетелю сгнить: изменился файл, который он патчит, — либо
|
||||
* изменился его гард (тест, смок, pytest-модуль), и тот перестал ходить по
|
||||
* мутированной ветке. Прежде отбор видел только первый; так после #302/#309
|
||||
* четыре мутанта пережили свои гарды и обнаружились лишь полным прогоном
|
||||
* перед v1.72.0 (#466, #467).
|
||||
*/
|
||||
export function selectChangedMutants(mutants, changedFiles, exists) {
|
||||
const changed = new Set(changedFiles);
|
||||
return mutants.filter((m) => m.patches.some((patch) => changed.has(patch.file)));
|
||||
return mutants.filter((m) => m.patches.some((patch) => changed.has(patch.file))
|
||||
|| guardFiles(m.guard, exists).some((file) => changed.has(file)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,8 +6,7 @@ import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
MUTANTS, applyPatches, guardNeedsBundle, guardNeedsTestBuild,
|
||||
selectChangedMutants, shardMutants,
|
||||
MUTANTS, applyPatches, guardNeedsBundle, guardNeedsTestBuild, selectChangedMutants, shardMutants, guardFiles,
|
||||
} from '../scripts/mutation-gate.mjs';
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
@@ -274,3 +273,56 @@ test('#472 AC7: отсутствие Telegram-секретов не роняет
|
||||
test('#472 AC8: Validate сверяет mutation-gate.yml между main и dev наравне с process.yml', () => {
|
||||
assert.match(validateWorkflowText, /for file in process\.yml mutation-gate\.yml; do/);
|
||||
});
|
||||
|
||||
// #475. Свидетель гниёт двумя способами: изменился файл, который он патчит,
|
||||
// либо изменился его гард — и тот перестал ходить по мутированной ветке.
|
||||
// Прежний отбор по диффу видел только первый; четыре мутанта пережили свои
|
||||
// гарды после #302/#309 и нашлись лишь полным прогоном перед v1.72.0.
|
||||
|
||||
const always = () => true;
|
||||
|
||||
test('#475 AC1: мутант отбирается по изменённому файлу патча', () => {
|
||||
const m = { id: 'x', guard: 'node --test test/x.test.mjs', patches: [{ file: 'src/x.ts' }] };
|
||||
assert.equal(selectChangedMutants([m], ['src/x.ts'], always).length, 1);
|
||||
assert.equal(selectChangedMutants([m], ['src/y.ts'], always).length, 0);
|
||||
});
|
||||
|
||||
test('#475 AC2: мутант отбирается по изменённому файлу гарда — смок, юнит, pytest', () => {
|
||||
const smoke = { id: 's', guard: 'node demo/smoke_x.mjs', patches: [{ file: 'src/a.ts' }] };
|
||||
const unit = { id: 'u', guard: 'node --test --test-name-pattern="p" test/u.test.mjs', patches: [{ file: 'src/a.ts' }] };
|
||||
const py = { id: 'p', guard: 'python3 -m pytest tests_backend/test_x.py -q -p no:cacheprovider', patches: [{ file: 'custom_components/houseplan/x.py' }] };
|
||||
assert.deepEqual(selectChangedMutants([smoke, unit, py], ['demo/smoke_x.mjs'], always).map((m) => m.id), ['s']);
|
||||
assert.deepEqual(selectChangedMutants([smoke, unit, py], ['test/u.test.mjs'], always).map((m) => m.id), ['u']);
|
||||
assert.deepEqual(selectChangedMutants([smoke, unit, py], ['tests_backend/test_x.py'], always).map((m) => m.id), ['p']);
|
||||
});
|
||||
|
||||
test('#475 AC3: флаги и шаблоны гарда файлами не считаются', () => {
|
||||
const files = guardFiles('node --test --test-name-pattern="magnet presses|x.mjs" test/furniture.test.mjs', always);
|
||||
assert.deepEqual(files, ['test/furniture.test.mjs']);
|
||||
// несуществующий путь — не файл гарда, даже если похож
|
||||
assert.deepEqual(guardFiles('node demo/smoke_nope.mjs', () => false), []);
|
||||
assert.deepEqual(guardFiles('', always), []);
|
||||
});
|
||||
|
||||
test('#475 AC5: дифф, не задевающий ни патчей, ни гардов, ничего не отбирает', () => {
|
||||
const m = { id: 'x', guard: 'node demo/smoke_x.mjs', patches: [{ file: 'src/x.ts' }] };
|
||||
assert.deepEqual(selectChangedMutants([m], ['docs/README.md', 'src/other.ts'], always), []);
|
||||
});
|
||||
|
||||
test('#475 AC6: воспроизведение #467 — дифф по src/wall-thickness.ts отбирает multi-wall мутантов', () => {
|
||||
const ids = selectChangedMutants(MUTANTS, ['src/wall-thickness.ts']).map((m) => m.id);
|
||||
for (const id of ['multi-wall-orthogonal-strip-protection-disabled', 'multi-wall-exterior-corridor-disabled', 'junction-fan-limit-back-to-249']) {
|
||||
assert.ok(ids.includes(id), `${id} не отобран`);
|
||||
}
|
||||
});
|
||||
|
||||
test('#475 AC7: воспроизведение находки ревью — бэкенд-мутанты отбираются по .py гарду и патчу', () => {
|
||||
const byGuard = selectChangedMutants(MUTANTS, ['tests_backend/test_ha_frontend_registration.py']).map((m) => m.id);
|
||||
const byPatch = selectChangedMutants(MUTANTS, ['custom_components/houseplan/frontend_registration.py']).map((m) => m.id);
|
||||
const registration = MUTANTS.filter((m) => m.id.startsWith('frontend-registration-')).map((m) => m.id);
|
||||
assert.ok(registration.length >= 3, 'в реестре есть бэкенд-мутанты регистрации');
|
||||
for (const id of registration) {
|
||||
assert.ok(byGuard.includes(id), `${id} не отобран по гарду`);
|
||||
assert.ok(byPatch.includes(id), `${id} не отобран по патчу`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -360,3 +360,21 @@ test('релизные гейты требуют трейлер Release: и св
|
||||
assert.match(read('release.yml'), trailer);
|
||||
assert.match(read('publish-prerelease.yml'), /check-docs\.mjs --screenshots=strict/);
|
||||
});
|
||||
|
||||
test('мутанты по диффу гоняются на каждом пуше с базы диапазона (#475 AC4)', () => {
|
||||
const workflow = read('validate.yml');
|
||||
const start = workflow.indexOf('\n changed_mutants:\n');
|
||||
assert.ok(start > 0, 'нет job changed_mutants');
|
||||
const job = workflow.slice(start, workflow.indexOf('\n frontend:\n', start));
|
||||
// Триггер — и фронтенд, и бэкенд: бэкенд-мутанты патчат .py и охраняются
|
||||
// pytest, а дифф только по ним даёт backend=true без frontend=true (ревью r1).
|
||||
assert.match(job, /if: needs\.changes\.outputs\.frontend == 'true' \|\| needs\.changes\.outputs\.backend == 'true'/);
|
||||
// База диапазона — та же, что у остальных гейтов ветки (#387/#388).
|
||||
assert.match(job, /PROVEN_BASE: \$\{\{ needs\.changes\.outputs\.range_base \}\}/);
|
||||
assert.match(job, /git merge-base origin\/dev "\$HEAD_SHA"/);
|
||||
assert.match(job, /node scripts\/mutation-gate\.mjs --changed="\$base\.\.\$HEAD_SHA"/);
|
||||
// pytest-гарды исполнимы: Python и зависимости ставятся, как в mutation-gate.yml.
|
||||
assert.match(job, /pip install -r tests_backend\/requirements\.txt/);
|
||||
// Блокирующая job: свидетель, разучившийся краснеть, — отказ, а не предупреждение.
|
||||
assert.ok(!job.includes('continue-on-error'), 'job обязана красить прогон');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user