mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
test: сделать pinned Windows gate воспроизводимым (#576)
Issue: #576 User-Visible: no
This commit is contained in:
@@ -380,9 +380,11 @@ npm run bundle:sync # dist → custom_components + demo/srv/assets (#255)
|
||||
npm run bundle:budget # initial View graph <= 256000 B gzip (#337)
|
||||
```
|
||||
|
||||
`npm run gate:small` runs the mandatory part of PROCESS §8 in one go (#479):
|
||||
unit tests, build with typecheck, `no-new-any` and `smoke-select` in parallel,
|
||||
then the bundle-tree comparison and the bundle budget. It prints the smokes the
|
||||
`npm run gate:small` runs the mandatory part of PROCESS §8 in one go (#479,
|
||||
#576): build with typecheck, `no-new-any` and `smoke-select` start in parallel;
|
||||
unit tests follow the completed build because their bundle-contract witnesses
|
||||
read the freshly produced `dist`, then the bundle-tree comparison and the
|
||||
bundle budget run. It prints the smokes the
|
||||
diff selects but does not run them by default — `npm run gate:small -- --smokes`
|
||||
(#496) adds the browser phase after the artefact preparation: `bundle-sync`, then
|
||||
the directly matched and registered smokes two at a time; "broad" matches stay
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
||||
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the stable release is produced by `release.yml` (`workflow_dispatch` on `main` with the tag) — the only publisher of installable assets since #540: gates on the exact SHA (Validate, Full Performance, E2E on the candidate commit), one build, `houseplan.zip` archived from the committed tree, `SHA256SUMS`, draft → publish → read-back verification; a release published by hand in the GitHub form is turned back into a draft and walked through the same path, and a re-dispatch on a public tag is a repair that adds only missing assets. Release bodies are short and bilingual (Russian first); every bullet links its GitHub issue (#NN) so the #328 rules stay machine-checkable. A STABLE body aggregates the changelog since the PREVIOUS STABLE release (never since the last beta): features/fixes described across the line's beta changelogs must appear, while bugs that were introduced and fixed strictly inside the beta line (never shipped in any stable) are excluded — draft with `npm run release:notes -- <tag>`, curate by hand, then `npm run release:notes -- <tag> --verify` must pass. `Мелкие исправления и улучшения` / `Small fixes and improvements` is allowed only when the range really contains user-visible work not itemised in the body; a single-issue hotfix ships without it (the verifier enforces this). Every body ends with separate links to the Russian and English changelogs. Open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
| CI | #541 replaces three incompatible meanings of “green” with one machine-verifiable Validate proof: candidate SHA/tree, run ID/attempt, requested checks, actually executed jobs and independently checked content-addressed reuse. Review, merge and release share the same closed state machine; a light green dispatch cannot hide a full red run, and a dispatch without six executed mutant jobs cannot authorize review or merge. #573 makes the proof composite — product-tree identity, accepted golden overlay (tree, index hash, `Baseline-Reviewed` run) and the content key of every reusable job — and release consumers on the candidate checkout recompute and compare all of it; the accepted overlay is an input of `golden` only, so a baseline-only commit after a golden-red candidate reuses smoke, performance smoke, parity and backend, skips caught witnesses and re-runs golden alone. Prerelease publication requires a green full exact-SHA proof covering frontend/backend, smoke (including the #73 rAF frame sampler), golden, HACS/Hassfest and the short absolute-ceiling performance smoke. Obsolete same-ref Validate runs are cancelled. Full seven-sample base/candidate performance remains in `performance.yml` (`main` push, weekly, manual); stable release assets fail closed unless Validate and Full Performance are green for the exact tagged SHA and the stable-only CDP compositor screencast finds no empty/black presented frame. |
|
||||
| Local toolchain | #557 removes ambient-PATH claims from the owner's workstation: `scripts/windows-toolchain.ps1` keeps verified portable repository-pinned Node and a dedicated repository-pinned Python `.venv-ci` without changing system defaults; `toolchain:check` reports the current versions and exact executable/package/browser paths. The WSL entrypoint uses its own nvm + `.venv-ci`, and `--verify` runs a real HA subset and one Linux golden capture from an ext4 clone. These are early-feedback paths only; exact-SHA Linux CI remains canonical. |
|
||||
| Local toolchain | #557 removes ambient-PATH claims from the owner's workstation: `scripts/windows-toolchain.ps1` keeps verified portable repository-pinned Node and a dedicated repository-pinned Python `.venv-ci` without changing system defaults; `toolchain:check` reports the current versions and exact executable/package/browser paths. #576 verifies the actual owner setup end to end: repeated Windows setup reuses the existing Node/Python/Chromium, the pinned small gate and pure backend subset are green, and repeated WSL `--verify` runs from an ext4 clone pass the real HA subset without skips and produce a Linux golden capture. The WSL entrypoint uses its own nvm + `.venv-ci`. These are early-feedback paths only; exact-SHA Linux CI remains canonical. |
|
||||
| HACS | **In the default catalog since 2026-08-25** (hacs/default#9004 merged). Install = plain HACS search. `houseplan.zip` is attached to stable tags automatically (verified on v1.72.0); forum/4pda announcement still pending |
|
||||
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), last direct copy was **v1.57.0**; from v1.58.0 on it updates itself through HACS by tag (no scp) |
|
||||
| Localization | UI en/ru/de (src/i18n/*.json), everything user-visible localized incl. kiosk popover; German is loaded lazily through the registry introduced by #62 |
|
||||
|
||||
+10
-8
@@ -3,8 +3,10 @@
|
||||
//
|
||||
// PROCESS §8 перечисляет автору шесть команд, и в #476 они гонялись
|
||||
// последовательно, вперемешку с гейтами, к задаче не относящимися. Здесь
|
||||
// обязательная часть §8 идёт параллельно — юниты, сборка с typecheck, «новый
|
||||
// код не добавляет any», выбор смоков по диффу — а затем сверяется бандл. Что
|
||||
// обязательная часть §8 начинается параллельно — сборка с typecheck, «новый
|
||||
// код не добавляет any», выбор смоков по диффу. Юниты читают свежий `dist`,
|
||||
// поэтому идут после сборки, а не одновременно с очисткой/записью её каталога;
|
||||
// затем сверяется бандл. Что
|
||||
// НЕ входит и остаётся по диффу и AC: сами смоки (их список печатается),
|
||||
// golden, pytest, инварианты модели, check-docs в строгом режиме.
|
||||
//
|
||||
@@ -56,19 +58,19 @@ export async function runLimited(items, limit, worker) {
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Шаги параллельной фазы: имя → команда. `base` — начало диапазона диффа. */
|
||||
/** Шаги параллельной read-only/producer-фазы. `base` — начало диапазона диффа. */
|
||||
export function parallelSteps(base) {
|
||||
return [
|
||||
{ name: 'юниты (npm test)', cmd: npm, args: ['test'] },
|
||||
{ name: 'сборка + typecheck (npm run build)', cmd: npm, args: ['run', 'build'] },
|
||||
{ name: 'новый код не добавляет any', cmd: process.execPath, args: ['scripts/no-new-any.mjs', '--base', base, '--head', 'HEAD'] },
|
||||
{ name: 'смоки по диффу (smoke-select)', cmd: process.execPath, args: ['scripts/smoke-select.mjs', '--base', base, '--head', 'HEAD', '--json'], informational: true },
|
||||
];
|
||||
}
|
||||
|
||||
/** Фаза после сборки: три копии бандла совпадают, бюджет не превышен. */
|
||||
export function serialSteps() {
|
||||
/** Фаза после сборки: consumers свежего dist и проверки готового артефакта. */
|
||||
export function postBuildSteps() {
|
||||
return [
|
||||
{ name: 'юниты (npm test)', cmd: npm, args: ['test'] },
|
||||
{ name: 'копии бандла совпадают (bundle-tree)', cmd: process.execPath, args: ['scripts/bundle-tree.mjs', 'dist', 'custom_components/houseplan/frontend'], hint: 'npm run bundle:sync' },
|
||||
{ name: 'бюджет бандла', cmd: npm, args: ['run', 'bundle:budget'] },
|
||||
];
|
||||
@@ -101,11 +103,11 @@ export function summarize(results) {
|
||||
|
||||
export async function gateSmall({ cwd = ROOT, base = 'origin/dev', smokes = false, jobs = 2, log = console.log } = {}) {
|
||||
const started = Date.now();
|
||||
log(`gate:small — база диапазона ${base}; параллельно: юниты, сборка, no-new-any, smoke-select${smokes ? `; затем bundle-sync и смоки по диффу (×${jobs})` : ''}`);
|
||||
log(`gate:small — база диапазона ${base}; параллельно: сборка, no-new-any, smoke-select; после сборки: юниты и проверки артефакта${smokes ? `; затем bundle-sync и смоки по диффу (×${jobs})` : ''}`);
|
||||
const parallel = await Promise.all(parallelSteps(base).map((step) => runStep(step, cwd)));
|
||||
const buildOk = parallel.find((r) => r.args.includes('build'))?.code === 0;
|
||||
const serial = [];
|
||||
if (buildOk) for (const step of serialSteps()) serial.push(await runStep(step, cwd));
|
||||
if (buildOk) for (const step of postBuildSteps()) serial.push(await runStep(step, cwd));
|
||||
// Browser-consumers — после подготовки артефактов (#496): bundle-sync раскладывает
|
||||
// собранное дерево в demo/srv/assets, смоки читают его и отказываются на несвежем.
|
||||
let selected = [];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
@@ -26,7 +27,7 @@ const MODULES = [
|
||||
];
|
||||
const DRAFTS = ['_markerDialog', '_spaceDialog', '_settingsDialog', '_roomDialog'];
|
||||
|
||||
const source = (file) => readFileSync(new URL(file, new URL(ROOT, 'file:')), 'utf8');
|
||||
const source = (file) => readFileSync(resolve(ROOT, file), 'utf8');
|
||||
|
||||
test('#592 вынесенные диалоги не заводят собственного состояния', () => {
|
||||
for (const file of MODULES) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { parseArgs, parallelSteps, serialSteps, summarize } from '../scripts/gate-small.mjs';
|
||||
import { parseArgs, parallelSteps, postBuildSteps, summarize } from '../scripts/gate-small.mjs';
|
||||
|
||||
// #479 AC6: одна команда вместо списка §8 — состав обязательной части закреплён,
|
||||
// информационный шаг (smoke-select) не считается падением, сверка бандла идёт
|
||||
@@ -9,11 +9,13 @@ import { parseArgs, parallelSteps, serialSteps, summarize } from '../scripts/gat
|
||||
|
||||
test('gate:small гоняет обязательную часть PROCESS §8 и сверяет бандл (#479)', () => {
|
||||
const names = parallelSteps('origin/dev').map((s) => `${s.cmd} ${s.args.join(' ')}`);
|
||||
assert.ok(names.some((n) => n.endsWith('npm test') || n.endsWith('npm.cmd test')));
|
||||
assert.ok(!names.some((n) => n.endsWith('npm test') || n.endsWith('npm.cmd test')),
|
||||
'юниты не должны гоняться одновременно со сборкой, которая пересоздаёт dist');
|
||||
assert.ok(names.some((n) => n.includes('run build')));
|
||||
assert.ok(names.some((n) => n.includes('scripts/no-new-any.mjs --base origin/dev --head HEAD')));
|
||||
assert.ok(names.some((n) => n.includes('scripts/smoke-select.mjs --base origin/dev --head HEAD')));
|
||||
const serial = serialSteps().map((s) => s.args.join(' '));
|
||||
const serial = postBuildSteps().map((s) => s.args.join(' '));
|
||||
assert.equal(serial[0], 'test', 'юниты первыми читают уже готовый свежий dist');
|
||||
assert.ok(serial.some((s) => s.includes('bundle-tree.mjs dist custom_components/houseplan/frontend')));
|
||||
assert.ok(serial.some((s) => s.includes('bundle:budget')));
|
||||
assert.equal(parseArgs(['--base=abc']).base, 'abc');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { cpSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
@@ -73,9 +73,13 @@ function fixture({ platform = 'linux', schema = CAPTURE_PROVENANCE_SCHEMA, captu
|
||||
}
|
||||
|
||||
/** Приёмка в изолированный каталог эталонов: рабочий репозиторий не трогаем. */
|
||||
function accept(from, { reason = '', expectFailure = false } = {}) {
|
||||
const HOST_TEST_ALLOWANCE = process.platform === 'linux'
|
||||
? ''
|
||||
: '#576: unit-тест приёмки из закреплённого Windows toolchain';
|
||||
|
||||
function accept(from, { reason = HOST_TEST_ALLOWANCE, expectFailure = false } = {}) {
|
||||
const sandbox = mkdtempSync(resolve(tmpdir(), 'hp-golden-571-baselines-'));
|
||||
execFileSync('cp', ['-r', BASELINES + '/.', sandbox]);
|
||||
cpSync(BASELINES, sandbox, { recursive: true });
|
||||
const env = { ...process.env };
|
||||
if (reason) env.HP_ALLOW_FOREIGN_CAPTURE = reason; else delete env.HP_ALLOW_FOREIGN_CAPTURE;
|
||||
try {
|
||||
@@ -108,14 +112,15 @@ test('#571 AC1: артефакт Linux принимается, обе сторо
|
||||
assert.equal(index.acceptedOn, process.platform, 'платформа приёмки — своя');
|
||||
assert.equal(indexCapturedOn(index), 'linux');
|
||||
assert.equal(index.capture.chromium, index.chromium);
|
||||
assert.equal(index.foreignCapture, null, 'канон обхода не требует');
|
||||
assert.deepEqual(index.foreignCapture, HOST_TEST_ALLOWANCE ? { reason: HOST_TEST_ALLOWANCE } : null,
|
||||
'не-Linux хост теста оставляет явный след осознанного обхода');
|
||||
rmSync(from, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('#571 AC2: чужая среда съёмки без причины — отказ до записи', () => {
|
||||
const from = fixture({ platform: 'win32' });
|
||||
const before = readFileSync(resolve(BASELINES, 'baselines-index.json'), 'utf8');
|
||||
const { error, index } = accept(from, { expectFailure: true });
|
||||
const { error, index } = accept(from, { reason: '', expectFailure: true });
|
||||
assert.match(error, /приёмка отказана/);
|
||||
assert.match(error, /win32/);
|
||||
assert.equal(index.capturedOn ?? null, JSON.parse(before).capturedOn ?? null,
|
||||
|
||||
Reference in New Issue
Block a user