mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
ci: перф-смок по диффу — изометрия и взаимодействие в код-ревью
Классификация changes вынесена в scripts/classify-changes.mjs (выходы perf_iso/perf_interaction, fallback --all). performance_smoke добавляет large-house-isometric-v1 при правке src/iso-* и large-house-interaction-v1 при правке живого пути, по 3 образца против hardMaxMs полных профилей (budgets-*-smoke.json). Набор профилей входит в ключ reuse. PROCESS.md §8. Issue: #473 User-Visible: no
This commit is contained in:
@@ -182,6 +182,9 @@ jobs:
|
||||
frontend: ${{ steps.classify.outputs.frontend }}
|
||||
backend: ${{ steps.classify.outputs.backend }}
|
||||
integration: ${{ steps.classify.outputs.integration }}
|
||||
# Диффозависимые профили перф-смока (#473 §5).
|
||||
perf_iso: ${{ steps.classify.outputs.perf_iso }}
|
||||
perf_interaction: ${{ steps.classify.outputs.perf_interaction }}
|
||||
base: ${{ steps.base.outputs.base }}
|
||||
# Разные вещи под разными именами намеренно: `base` — до какого коммита
|
||||
# классифицировать файлы ветки (#387), `range_base` — от какого коммита
|
||||
@@ -237,7 +240,7 @@ jobs:
|
||||
run: |
|
||||
if [ "$REF" = "refs/heads/dev" ]; then
|
||||
echo "dev: без фильтров, всё true"
|
||||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||||
node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
zero=$(printf '%040d' 0)
|
||||
@@ -261,7 +264,7 @@ jobs:
|
||||
echo "\`before\` (=$BEFORE_SHA) переписан force-push'ем: диапазон недоказуем,"
|
||||
echo "все тяжёлые job запускаются без фильтра путей (#347)."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||||
node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Диапазон считается от последнего ДОКАЗАННО зелёного предка (#387),
|
||||
@@ -271,17 +274,14 @@ jobs:
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
|
||||
if [ -z "$base" ]; then
|
||||
echo "база не определена — полный прогон без классификации"
|
||||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||||
node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
files=$(git diff --name-only "$base" "$HEAD_SHA")
|
||||
printf '%s\n' "$files" | head -50
|
||||
has() { printf '%s\n' "$files" | grep -qE "$1" && echo true || echo false; }
|
||||
{
|
||||
echo "frontend=$(has '^(src/|demo/|test/|dist/|custom_components/houseplan/frontend/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)')"
|
||||
echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|scripts/support-relay/|pytest\.ini$)')"
|
||||
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
# Шаблоны — в scripts/classify-changes.mjs: там они проверяемы
|
||||
# unit-тестом, здесь — только переписываются в выходы (#473 AC8).
|
||||
printf '%s\n' "$files" | node scripts/classify-changes.mjs | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
# Переиспользование результата тяжёлой job (#208). Ключ = входы поведения
|
||||
# (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты
|
||||
@@ -298,6 +298,10 @@ jobs:
|
||||
# заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда.
|
||||
reuse:
|
||||
name: "Переиспользование: это дерево уже проверено"
|
||||
# `changes` нужна ради набора профилей перф-смока (#473): ключ
|
||||
# performance_smoke включает набор, иначе glow-only результат засчитался бы
|
||||
# прогону, которому нужен изометрический профиль.
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
smoke: ${{ steps.probe.outputs.smoke }}
|
||||
@@ -307,6 +311,7 @@ jobs:
|
||||
smoke_key: ${{ steps.keys.outputs.smoke }}
|
||||
golden_key: ${{ steps.keys.outputs.golden }}
|
||||
performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }}
|
||||
performance_smoke_set: ${{ steps.keys.outputs.performance_smoke_set }}
|
||||
backend_key: ${{ steps.keys.outputs.backend }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -314,12 +319,21 @@ jobs:
|
||||
with: { node-version: 22 }
|
||||
- name: Ключи переиспользования
|
||||
id: keys
|
||||
env:
|
||||
PERF_ISO: ${{ needs.changes.outputs.perf_iso }}
|
||||
PERF_INTERACTION: ${{ needs.changes.outputs.perf_interaction }}
|
||||
run: |
|
||||
for job in smoke golden performance_smoke backend; do
|
||||
key=$(node scripts/gate-reuse.mjs --job="$job")
|
||||
echo "$job=$key" >> "$GITHUB_OUTPUT"
|
||||
echo "$job: $key"
|
||||
done
|
||||
# Набор профилей перф-смока — часть ключа (#473 AC5).
|
||||
set=glow
|
||||
[ "$PERF_ISO" = "true" ] && set="$set-iso"
|
||||
[ "$PERF_INTERACTION" = "true" ] && set="$set-interaction"
|
||||
echo "performance_smoke_set=$set" >> "$GITHUB_OUTPUT"
|
||||
echo "performance_smoke set: $set"
|
||||
# lookup-only: маркер только проверяется, но не восстанавливается —
|
||||
# сохранять его в этой job нечего, она ничего не прогоняла.
|
||||
- name: Маркер smoke
|
||||
@@ -341,7 +355,7 @@ jobs:
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}
|
||||
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}-${{ steps.keys.outputs.performance_smoke_set }}
|
||||
lookup-only: true
|
||||
- name: Маркер backend
|
||||
id: m_backend
|
||||
@@ -711,13 +725,14 @@ jobs:
|
||||
name: "Перф-смок: бюджет времени кадра"
|
||||
# Candidate-only catastrophic-regression guard for ordinary pushes and
|
||||
# prereleases. The expensive same-runner comparison lives in performance.yml.
|
||||
needs: [frontend, reuse]
|
||||
needs: [changes, frontend, reuse]
|
||||
if: needs.reuse.outputs.performance_smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# 15 минут не хватало, когда установка браузера шла через apt: замер
|
||||
# начинался на исходе окна (#206). Запас на холодный кэш — при попадании
|
||||
# job укладывается в те же минуты, что и раньше.
|
||||
timeout-minutes: 20
|
||||
# job укладывается в те же минуты, что и раньше. Диффозависимые профили
|
||||
# (#473) добавляют до двух замеров по 3 образца — окно расширено под них.
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
@@ -757,6 +772,20 @@ jobs:
|
||||
run: |
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-space-glow-smoke.json --candidate=artifacts/performance-smoke/space-candidate.json --output=artifacts/performance-smoke/space-comparison.json
|
||||
# Диффозависимые профили (#473 §5): изометрия — когда дифф задел
|
||||
# `src/iso-*`, взаимодействие — когда живой путь или оркестраторы кадра.
|
||||
# Абсолютные потолки — `hardMaxMs` полных профилей: регрессия #160
|
||||
# (9 870 мс против 3 500) ловится с одного образца, шум — нет.
|
||||
- name: Изометрический профиль по диффу (#473)
|
||||
if: needs.changes.outputs.perf_iso == 'true'
|
||||
run: |
|
||||
npm run benchmark:large-house -- --profile=large-house-isometric-v1 --samples=3 --warmups=1 --output=artifacts/performance-smoke/isometric-candidate.json
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-isometric-smoke.json --candidate=artifacts/performance-smoke/isometric-candidate.json --output=artifacts/performance-smoke/isometric-comparison.json
|
||||
- name: Профиль взаимодействия по диффу (#473)
|
||||
if: needs.changes.outputs.perf_interaction == 'true'
|
||||
run: |
|
||||
npm run benchmark:large-house -- --profile=large-house-interaction-v1 --samples=3 --warmups=1 --output=artifacts/performance-smoke/interaction-candidate.json
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-interaction-smoke.json --candidate=artifacts/performance-smoke/interaction-candidate.json --output=artifacts/performance-smoke/interaction-comparison.json
|
||||
# #330 AC7: перф-контракт ограничений стыков — быстрый (без браузера),
|
||||
# ловит возврат квадратичных путей в оба зеркала прямо на пуше.
|
||||
- name: Бюджеты ограничений стыков (#330)
|
||||
@@ -784,7 +813,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}
|
||||
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}-${{ needs.reuse.outputs.performance_smoke_set }}
|
||||
|
||||
backend:
|
||||
name: "Бэкенд: pytest в Home Assistant"
|
||||
|
||||
+11
@@ -661,6 +661,17 @@ python -m pytest tests_backend -q # py3.13, если менялся бэке
|
||||
при следующей задаче (#237). Пересъёмка — `npm run build && node
|
||||
demo/docs/capture.mjs`, коммит вместе с задачей.
|
||||
|
||||
**Перф-смок в Validate зависит от диффа** (#473). Два glow-профиля
|
||||
гоняются всегда; при правке `src/iso-*` добавляется `large-house-isometric-v1`,
|
||||
при правке `src/live-*`, `src/render-*`, `houseplan-render-lifecycle.ts`,
|
||||
`houseplan-card.ts` — `large-house-interaction-v1`, оба по три образца против
|
||||
абсолютных потолков `hardMaxMs` полных профилей (`budgets-*-smoke.json`).
|
||||
Это гейт на «в разы», а не «на проценты»: регрессия #160 (первый кадр 9 870 мс
|
||||
против потолка 3 500) ловится ещё в ревью, а не предрелизным гейтом под тегом.
|
||||
Классификацию делает `scripts/classify-changes.mjs`, набор профилей входит в
|
||||
ключ переиспользования `performance_smoke`. Ревьюер по-прежнему принимает
|
||||
зелёный Validate на SHA как подтверждение дешёвых гейтов — смок его часть.
|
||||
|
||||
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
|
||||
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
|
||||
пропуском.
|
||||
|
||||
@@ -82,6 +82,16 @@ reviewed hard timing, Long Task, heap, cache and rendered-device ceilings from
|
||||
`budgets-glow-smoke.json`; it deliberately makes no noisy base-relative claim.
|
||||
This is a catastrophic-regression guard, not a performance trend detector.
|
||||
|
||||
Two more profiles join the smoke only when the diff touches their code path
|
||||
(#473, classified by `scripts/classify-changes.mjs`): `large-house-isometric-v1`
|
||||
for `src/iso-*` and `large-house-interaction-v1` for `src/live-*`,
|
||||
`src/render-*`, `houseplan-render-lifecycle.ts` and `houseplan-card.ts`. Both
|
||||
run `--samples=3 --warmups=1` against `budgets-isometric-smoke.json` and
|
||||
`budgets-interaction-smoke.json`, whose ceilings are the `hardMaxMs` values of
|
||||
the full profiles. The set of profiles is part of the `performance_smoke`
|
||||
reuse key, so a glow-only success never stands in for a run that needed the
|
||||
isometric profile.
|
||||
|
||||
The dedicated `performance.yml` workflow is the full comparison. It runs on
|
||||
every `main` promotion, weekly and on manual dispatch for an important beta or
|
||||
performance-sensitive change. It checks out the candidate and its base SHA,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-house-interaction-v1",
|
||||
"minimumSamples": 3,
|
||||
"timings": {
|
||||
"modelReadyMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 2500
|
||||
},
|
||||
"firstStableRenderMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 3000
|
||||
},
|
||||
"interactionSeriesMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 3000
|
||||
},
|
||||
"hoverSeriesMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"panSeriesMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"cameraSeriesMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"editorSeriesMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 750
|
||||
},
|
||||
"irrelevantHaTicksMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 250
|
||||
},
|
||||
"relevantHaTickMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 250
|
||||
},
|
||||
"spaceSwitchMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 1500
|
||||
},
|
||||
"stateUpdateMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 1000
|
||||
},
|
||||
"resizePreviewMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 2000
|
||||
},
|
||||
"panZoomMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"settingsDialogMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 1000
|
||||
},
|
||||
"switchCycleMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 7000
|
||||
}
|
||||
},
|
||||
"longTasks": {
|
||||
"maxSingleMs": 3000,
|
||||
"maxCountP95": 30,
|
||||
"maxTotalP95Ms": 12000
|
||||
},
|
||||
"longTaskWindows": {
|
||||
"hoverSeries": {
|
||||
"maxSingleMs": 150,
|
||||
"maxCountP95": 3,
|
||||
"maxTotalP95Ms": 300
|
||||
},
|
||||
"panSeries": {
|
||||
"maxSingleMs": 150,
|
||||
"maxCountP95": 3,
|
||||
"maxTotalP95Ms": 300
|
||||
},
|
||||
"cameraSeries": {
|
||||
"maxSingleMs": 150,
|
||||
"maxCountP95": 3,
|
||||
"maxTotalP95Ms": 300
|
||||
},
|
||||
"editorSeries": {
|
||||
"maxSingleMs": 150,
|
||||
"maxCountP95": 3,
|
||||
"maxTotalP95Ms": 300
|
||||
}
|
||||
},
|
||||
"heap": {
|
||||
"required": true,
|
||||
"hardMaxGrowthBytes": 67108864
|
||||
},
|
||||
"cacheEntries": {
|
||||
"cleanFloor": 100,
|
||||
"glowClip": 200,
|
||||
"wallUnion": 1,
|
||||
"openingTunnel": 1,
|
||||
"openingWallIndex": 1,
|
||||
"planSnapGeometry": 1,
|
||||
"wallFaceGraph": 4
|
||||
},
|
||||
"cacheGrowth": {
|
||||
"cleanFloor": 0,
|
||||
"glowClip": 0,
|
||||
"wallUnion": 0,
|
||||
"openingTunnel": 0,
|
||||
"openingWallIndex": 0,
|
||||
"planSnapGeometry": 0,
|
||||
"wallFaceGraph": 0
|
||||
},
|
||||
"renderedDevices": 200
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-house-isometric-v1",
|
||||
"minimumSamples": 3,
|
||||
"timings": {
|
||||
"modelReadyMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 3000
|
||||
},
|
||||
"firstStableRenderMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 3500
|
||||
},
|
||||
"viewToggleMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 1500
|
||||
},
|
||||
"spaceSwitchMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 1800
|
||||
},
|
||||
"stateUpdateMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 1000
|
||||
},
|
||||
"resizePreviewMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 2200
|
||||
},
|
||||
"panZoomMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 600
|
||||
},
|
||||
"settingsDialogMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 1200
|
||||
},
|
||||
"switchCycleMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 8000
|
||||
}
|
||||
},
|
||||
"longTasks": {
|
||||
"maxSingleMs": 3000,
|
||||
"maxCountP95": 30,
|
||||
"maxTotalP95Ms": 12000
|
||||
},
|
||||
"heap": {
|
||||
"required": true,
|
||||
"hardMaxGrowthBytes": 67108864
|
||||
},
|
||||
"cacheEntries": {
|
||||
"cleanFloor": 100,
|
||||
"glowClip": 200,
|
||||
"wallUnion": 1,
|
||||
"openingTunnel": 1,
|
||||
"openingWallIndex": 1,
|
||||
"isoGeometry": 8
|
||||
},
|
||||
"cacheGrowth": {
|
||||
"cleanFloor": 0,
|
||||
"glowClip": 0,
|
||||
"wallUnion": 0,
|
||||
"openingTunnel": 0,
|
||||
"openingWallIndex": 0,
|
||||
"isoGeometry": 0
|
||||
},
|
||||
"renderedDevices": 200
|
||||
}
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
// Классификация изменённых файлов для job `changes` в validate.yml (#473 AC8).
|
||||
//
|
||||
// Шаблоны жили inline в shell-шаге `classify` как `has('regex')`. Пока выходов
|
||||
// было три, это терпимо; с диффозависимыми профилями перф-смока (#473) их
|
||||
// пять, и вопрос «запустит ли правка src/iso-x.ts изометрический профиль»
|
||||
// стал вопросом к workflow, на который нельзя ответить тестом — shell в YAML
|
||||
// не исполняется локально. Теперь ответ даёт функция, а shell только
|
||||
// переписывает её вывод в `$GITHUB_OUTPUT`.
|
||||
//
|
||||
// Контракт неизменен: каждый выход — строка 'true'/'false', как её и читают
|
||||
// условия `if: needs.changes.outputs.X == 'true'`. Fallback «полный прогон
|
||||
// без классификации» (--all) выставляет все выходы в 'true' — в том числе те,
|
||||
// что появятся позже: пропущенный ключ в fallback-е означал бы, что job
|
||||
// с этим условием молча не запускается ровно в тех прогонах, где база
|
||||
// недоказуема и проверять надо всё.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
export const CLASSIFIERS = {
|
||||
frontend: /^(src\/|demo\/|test\/|dist\/|custom_components\/houseplan\/frontend\/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)/,
|
||||
backend: /^(custom_components\/.*\.py$|tests_backend\/|scripts\/support-relay\/|pytest\.ini$)/,
|
||||
integration: /^(custom_components\/houseplan\/manifest\.json$|hacs\.json$|custom_components\/.*\.py$|custom_components\/.*\/translations\/)/,
|
||||
// Перф-смок (#473 §5): изометрический профиль — при правке изометрии,
|
||||
// профиль взаимодействия — при правке живого пути и оркестраторов кадра.
|
||||
// Только `src/**`: тесты и демо кадр не замедляют.
|
||||
perf_iso: /^src\/iso-[^/]+\.ts$/,
|
||||
perf_interaction: /^src\/(live-[^/]+|render-[^/]+|houseplan-render-lifecycle|houseplan-card)\.ts$/,
|
||||
};
|
||||
|
||||
export const OUTPUTS = Object.keys(CLASSIFIERS);
|
||||
|
||||
/** Список файлов → выходы job `changes` ('true'/'false' по каждому ключу). */
|
||||
export function classifyChanges(files) {
|
||||
const list = (Array.isArray(files) ? files : String(files).split('\n'))
|
||||
.map((file) => file.trim()).filter(Boolean);
|
||||
const result = {};
|
||||
for (const [name, pattern] of Object.entries(CLASSIFIERS)) {
|
||||
result[name] = list.some((file) => pattern.test(file)) ? 'true' : 'false';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Fallback без классификации: всё прогоняется. */
|
||||
export function classifyAll() {
|
||||
return Object.fromEntries(OUTPUTS.map((name) => [name, 'true']));
|
||||
}
|
||||
|
||||
/** Формат `$GITHUB_OUTPUT`. */
|
||||
export function formatOutputs(outputs) {
|
||||
return OUTPUTS.map((name) => `${name}=${outputs[name]}`).join('\n') + '\n';
|
||||
}
|
||||
|
||||
const invokedDirectly = process.argv[1]
|
||||
&& import.meta.url === new URL(`file://${process.argv[1]}`).href;
|
||||
if (invokedDirectly) {
|
||||
const all = process.argv.includes('--all');
|
||||
const outputs = all ? classifyAll() : classifyChanges(readFileSync(0, 'utf8'));
|
||||
process.stdout.write(formatOutputs(outputs));
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { classifyChanges, classifyAll, formatOutputs, OUTPUTS, CLASSIFIERS } from '../scripts/classify-changes.mjs';
|
||||
|
||||
// #473 AC8: диффозависимость перф-смока доказана на самой функции
|
||||
// классификации, которую исполняет job `changes`.
|
||||
|
||||
test('дифф по изометрии включает perf_iso и только его из перф-выходов (#473 AC8)', () => {
|
||||
const out = classifyChanges(['src/iso-x.ts']);
|
||||
assert.equal(out.perf_iso, 'true');
|
||||
assert.equal(out.perf_interaction, 'false');
|
||||
assert.equal(out.frontend, 'true');
|
||||
});
|
||||
|
||||
test('дифф по живому пути и оркестраторам кадра включает perf_interaction (#473 AC8)', () => {
|
||||
for (const file of ['src/live-x.ts', 'src/render-invalidation.ts',
|
||||
'src/houseplan-render-lifecycle.ts', 'src/houseplan-card.ts']) {
|
||||
const out = classifyChanges([file]);
|
||||
assert.equal(out.perf_interaction, 'true', file);
|
||||
assert.equal(out.perf_iso, 'false', file);
|
||||
}
|
||||
});
|
||||
|
||||
test('дифф по документации не включает ни одного перф-профиля (#473 AC8)', () => {
|
||||
const out = classifyChanges(['docs/x.md']);
|
||||
assert.equal(out.perf_iso, 'false');
|
||||
assert.equal(out.perf_interaction, 'false');
|
||||
assert.equal(out.frontend, 'false');
|
||||
});
|
||||
|
||||
test('тесты и демо перф-профили не включают: кадр они не замедляют', () => {
|
||||
const out = classifyChanges(['test/iso-scene-render.test.mjs', 'demo/benchmark_large_house.mjs', 'src/iso-x.test.ts.md']);
|
||||
assert.equal(out.perf_iso, 'false');
|
||||
assert.equal(out.perf_interaction, 'false');
|
||||
});
|
||||
|
||||
test('прежние три выхода классифицируются как в inline-shell до выноса', () => {
|
||||
assert.deepEqual(classifyChanges(['custom_components/houseplan/frontend_registration.py']),
|
||||
{ frontend: 'false', backend: 'true', integration: 'true', perf_iso: 'false', perf_interaction: 'false' });
|
||||
assert.deepEqual(classifyChanges(['custom_components/houseplan/frontend/houseplan-card.js']),
|
||||
{ frontend: 'true', backend: 'false', integration: 'false', perf_iso: 'false', perf_interaction: 'false' });
|
||||
assert.deepEqual(classifyChanges(['hacs.json', 'tsconfig.json']),
|
||||
{ frontend: 'true', backend: 'false', integration: 'true', perf_iso: 'false', perf_interaction: 'false' });
|
||||
assert.equal(classifyChanges(['scripts/support-relay/x.py']).backend, 'true');
|
||||
assert.equal(classifyChanges(['']).frontend, 'false');
|
||||
});
|
||||
|
||||
test('fallback --all выставляет каждый известный выход, включая перф-профили', () => {
|
||||
const all = classifyAll();
|
||||
assert.deepEqual(Object.keys(all), OUTPUTS);
|
||||
assert.ok(OUTPUTS.every((name) => all[name] === 'true'));
|
||||
assert.deepEqual(Object.keys(CLASSIFIERS), OUTPUTS);
|
||||
});
|
||||
|
||||
test('CLI пишет формат $GITHUB_OUTPUT: stdin — список файлов, --all — всё true', () => {
|
||||
const script = new URL('../scripts/classify-changes.mjs', import.meta.url).pathname;
|
||||
const fromStdin = execFileSync('node', [script], { input: 'src/iso-x.ts\ndocs/x.md\n', encoding: 'utf8' });
|
||||
assert.equal(fromStdin, formatOutputs(classifyChanges(['src/iso-x.ts', 'docs/x.md'])));
|
||||
assert.match(fromStdin, /^perf_iso=true$/m);
|
||||
assert.match(fromStdin, /^perf_interaction=false$/m);
|
||||
const all = execFileSync('node', [script, '--all'], { input: '', encoding: 'utf8' });
|
||||
assert.equal(all, OUTPUTS.map((name) => `${name}=true`).join('\n') + '\n');
|
||||
});
|
||||
@@ -181,3 +181,67 @@ test('named interaction windows enforce their own absolute Long Task limits', ()
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// #473 AC4: smoke-бюджеты диффозависимых профилей повторяют абсолютные потолки
|
||||
// полных профилей и пригодны для `compare --absolute-only` на трёх образцах.
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const readBudget = (name) => JSON.parse(readFileSync(new URL(`../demo/performance/${name}`, import.meta.url), 'utf8'));
|
||||
|
||||
for (const [smokeName, fullName] of [
|
||||
['budgets-isometric-smoke.json', 'budgets-large-house-isometric.json'],
|
||||
['budgets-interaction-smoke.json', 'budgets-large-house-interaction.json'],
|
||||
]) {
|
||||
test(`${smokeName} повторяет hardMaxMs полного профиля и держит 3 образца (#473 AC4)`, () => {
|
||||
const smoke = readBudget(smokeName);
|
||||
const full = readBudget(fullName);
|
||||
assert.equal(smoke.profile, full.profile);
|
||||
assert.equal(smoke.minimumSamples, 3);
|
||||
assert.deepEqual(Object.keys(smoke.timings), Object.keys(full.timings), 'набор метрик тот же');
|
||||
for (const [metric, budget] of Object.entries(smoke.timings)) {
|
||||
assert.equal(budget.hardMaxMs, full.timings[metric].hardMaxMs, `${metric}: потолок отличается от полного`);
|
||||
assert.equal(budget.stat, full.timings[metric].stat);
|
||||
// Регрессионных коэффициентов в смоке нет: сравнивать не с чем (§5).
|
||||
assert.equal(budget.maxRegressionRatio, undefined, `${metric}: в смоке нет относительных лимитов`);
|
||||
}
|
||||
assert.equal(smoke.longTasks.maxSingleMs, full.longTasks.maxSingleMs);
|
||||
assert.equal(smoke.longTasks.maxCountP95, full.longTasks.maxCountP95);
|
||||
assert.equal(smoke.longTasks.maxTotalP95Ms, full.longTasks.maxTotalP95Ms);
|
||||
assert.deepEqual(smoke.longTaskWindows, full.longTaskWindows);
|
||||
assert.equal(smoke.heap.hardMaxGrowthBytes, full.heap.hardMaxGrowthBytes);
|
||||
assert.deepEqual(smoke.cacheEntries, full.cacheEntries);
|
||||
assert.deepEqual(smoke.renderedDevices, full.renderedDevices);
|
||||
|
||||
// Пригодность для --absolute-only: синтетический отчёт под потолками
|
||||
// проходит, первый кадр как у de215578 (9 870 мс) — красный.
|
||||
const build = (firstFrame) => ({
|
||||
schema: 2, profile: smoke.profile, sourceSha: '1'.repeat(40), buildFingerprint: 'fixture',
|
||||
runtime: { node: 'v22.0.0', chromium: '1.2.3', platform: 'linux', arch: 'x64' },
|
||||
fixture: { rooms: 60 },
|
||||
summary: Object.fromEntries(Object.keys(smoke.timings).map((metric) => {
|
||||
const value = metric === 'firstStableRenderMs' ? firstFrame : 1;
|
||||
return [metric, { median: value, p95: value, min: value, max: value }];
|
||||
})),
|
||||
longTasks: { maxSingleMs: 1, countP95: 1, totalP95Ms: 1 },
|
||||
...(smoke.profile === 'large-house-isometric-v1' ? { effectiveProjection: ['iso'] } : {}),
|
||||
rows: [0, 1, 2].map(() => ({
|
||||
heapGrowthBytes: 1, preciseGc: true,
|
||||
longTasks: Object.fromEntries(['load', ...Object.keys(smoke.longTaskWindows ?? {})]
|
||||
.map((name) => [name, { supported: true, count: 1, maxMs: 1, totalMs: 1 }])),
|
||||
cacheEntries: { ...smoke.cacheEntries },
|
||||
cacheGrowth: Object.fromEntries(Object.keys(smoke.cacheGrowth).map((key) => [key, 0])),
|
||||
renderedDevices: smoke.renderedDevices,
|
||||
...(smoke.profile === 'large-house-isometric-v1'
|
||||
? {
|
||||
effectiveProjection: 'iso',
|
||||
isoStructuralBuilds: { supported: true, initial: 1, beforeHaUpdate: 2, afterHaUpdate: 2, haUpdateDelta: 0 },
|
||||
} : {}),
|
||||
})),
|
||||
});
|
||||
const ok = evaluatePerformanceBudget({ candidate: build(1), budgets: smoke, absoluteOnly: true });
|
||||
assert.deepEqual(ok.failures, [], 'отчёт под потолками обязан проходить');
|
||||
const regressed = evaluatePerformanceBudget({ candidate: build(9870), budgets: smoke, absoluteOnly: true });
|
||||
assert.ok(regressed.failures.some((check) => check.id === 'timing.firstStableRenderMs.median'),
|
||||
'первый кадр 9 870 мс обязан краснеть');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -178,8 +178,10 @@ test('#347: a rewritten before forces the full run instead of guessing the range
|
||||
workflow.indexOf('Классификация изменённых файлов'),
|
||||
workflow.indexOf('reuse:'),
|
||||
);
|
||||
// Полный прогон — `classify-changes.mjs --all`: все выходы true (#473 вынес
|
||||
// список выходов из inline-shell, ветка force-push идёт тем же путём).
|
||||
assert.ok(
|
||||
/force-push[\s\S]*?frontend=true[\s\S]*?backend=true[\s\S]*?integration=true/.test(classify),
|
||||
/force-push[\s\S]*?node scripts\/classify-changes\.mjs --all >> "\$GITHUB_OUTPUT"/.test(classify),
|
||||
'мёртвый before обязан включать полный прогон, не merge-base-угадывание');
|
||||
assert.ok(classify.includes('GITHUB_STEP_SUMMARY'),
|
||||
'пропуск классификации обязан быть громким в summary');
|
||||
|
||||
@@ -170,10 +170,58 @@ test('классификация опирается на завершённый
|
||||
// Пустая база означает «доказательства нет» и обязана вести к полному
|
||||
// прогону, а не к пустому диффу, который выглядел бы как «ничего не менялось».
|
||||
const empty = changes.slice(changes.indexOf('if [ -z "$base" ]'));
|
||||
assert.match(empty, /frontend=true\\nbackend=true\\nintegration=true/,
|
||||
assert.match(empty, /node scripts\/classify-changes\.mjs --all >> "\$GITHUB_OUTPUT"/,
|
||||
'без базы классификация обязана раскрываться в полный прогон');
|
||||
});
|
||||
|
||||
test('перф-смок добавляет профиль ровно при своём выходе changes (#473 AC3)', () => {
|
||||
const workflow = read('validate.yml');
|
||||
const changes = workflow.slice(workflow.indexOf('\n changes:\n'), workflow.indexOf('\n reuse:\n'));
|
||||
assert.match(changes, /perf_iso: \$\{\{ steps\.classify\.outputs\.perf_iso \}\}/);
|
||||
assert.match(changes, /perf_interaction: \$\{\{ steps\.classify\.outputs\.perf_interaction \}\}/);
|
||||
// Выходы пишет скрипт, а не inline-shell: шаблоны проверяются unit-тестом (AC8).
|
||||
assert.match(changes, /printf '%s\\n' "\$files" \| node scripts\/classify-changes\.mjs \| tee -a "\$GITHUB_OUTPUT"/);
|
||||
// Все три fallback-а «без классификации» идут через тот же скрипт с --all:
|
||||
// новый выход не может выпасть из fallback-а.
|
||||
const fallbacks = changes.split('node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"').length - 1;
|
||||
assert.equal(fallbacks, 3, 'fallback-и классификатора раскрываются через --all');
|
||||
assert.ok(!changes.includes("printf 'frontend=true"), 'ручной список выходов в fallback-е запрещён');
|
||||
|
||||
const start = workflow.indexOf('\n performance_smoke:\n');
|
||||
const job = workflow.slice(start, workflow.indexOf('\n backend:\n', start));
|
||||
assert.match(job, /needs: \[changes, frontend, reuse\]/);
|
||||
const iso = job.slice(job.indexOf('Изометрический профиль по диффу'), job.indexOf('Профиль взаимодействия по диффу'));
|
||||
assert.match(iso, /if: needs\.changes\.outputs\.perf_iso == 'true'/);
|
||||
assert.match(iso, /--profile=large-house-isometric-v1 --samples=3 --warmups=1/);
|
||||
assert.match(iso, /--absolute-only --budgets=demo\/performance\/budgets-isometric-smoke\.json/);
|
||||
const interaction = job.slice(job.indexOf('Профиль взаимодействия по диффу'), job.indexOf('#330 AC7'));
|
||||
assert.match(interaction, /if: needs\.changes\.outputs\.perf_interaction == 'true'/);
|
||||
assert.match(interaction, /--profile=large-house-interaction-v1 --samples=3 --warmups=1/);
|
||||
assert.match(interaction, /--absolute-only --budgets=demo\/performance\/budgets-interaction-smoke\.json/);
|
||||
// Glow-профили остаются безусловными.
|
||||
const glow = job.slice(job.indexOf('Capture the heaviest Glow state'), job.indexOf('Enforce absolute smoke ceilings'));
|
||||
assert.ok(!/\n\s+if:/.test(glow), 'glow-профили гоняются всегда');
|
||||
});
|
||||
|
||||
test('ключ reuse перф-смока различает наборы профилей (#473 AC5)', () => {
|
||||
const workflow = read('validate.yml');
|
||||
const reuse = workflow.slice(workflow.indexOf('\n reuse:\n'), workflow.indexOf('\n hacs:\n'));
|
||||
assert.match(reuse, /needs: changes/);
|
||||
assert.match(reuse, /PERF_ISO: \$\{\{ needs\.changes\.outputs\.perf_iso \}\}/);
|
||||
assert.match(reuse, /PERF_INTERACTION: \$\{\{ needs\.changes\.outputs\.perf_interaction \}\}/);
|
||||
assert.match(reuse, /\[ "\$PERF_ISO" = "true" \] && set="\$set-iso"/);
|
||||
assert.match(reuse, /\[ "\$PERF_INTERACTION" = "true" \] && set="\$set-interaction"/);
|
||||
assert.match(reuse, /performance_smoke_set: \$\{\{ steps\.keys\.outputs\.performance_smoke_set \}\}/);
|
||||
// Набор входит и в маркер-lookup, и в маркер-save: расхождение ключей
|
||||
// означало бы, что маркер пишется под именем, которого никто не ищет.
|
||||
const lookup = 'reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}-${{ steps.keys.outputs.performance_smoke_set }}';
|
||||
const save = 'reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}-${{ needs.reuse.outputs.performance_smoke_set }}';
|
||||
assert.ok(reuse.includes(lookup), 'lookup-ключ без набора профилей');
|
||||
assert.ok(workflow.includes(save), 'save-ключ без набора профилей');
|
||||
assert.ok(!workflow.includes('reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}\n'),
|
||||
'старый save-ключ без набора остался');
|
||||
});
|
||||
|
||||
test('гейты диапазона судят от доказанного предка, а не от предыдущего пуша (#388)', () => {
|
||||
const workflow = read('validate.yml');
|
||||
const preflight = workflow.slice(
|
||||
|
||||
Reference in New Issue
Block a user