Files
houseplan-card/scripts/e2e-gate.mjs
T
Claudeandclaude[bot] 1514899746 ci: имена job validate.yml — контракт, сверяемый с файлом; шарды из матрицы (#622)
- scripts/workflow-jobs.mjs: разбор job-уровня validate.yml (id, name,
  inline matrix) без зависимостей; незнакомые формы — громкая ошибка.
- ci-proof: JOB_RULES по id job с именем-контрактом; число и точные имена
  экземпляров — из матрицы YAML (константы count: 6 нет); resolveJobRules
  на каждом evaluateCiProof — переименование даёт failed с названной job;
  jobContractProblems — сверка в обе стороны, UNCONSUMED_JOBS для proof.
- validate-gate: MUTANT_JOB_PREFIX — из контракта ci-proof.
- e2e-gate: E2E_JOB_NAME — зеркало name: из houseplan-e2e e2e.yml
  (43899da5), распознавание выводится из шаблона; missing называет
  завершённые прогоны без job по контракту.
- тесты: фикстуры имён выводятся из validate.yml; новый
  test/workflow-jobs.test.mjs; 5 мутантов в реестре.

Issue: #622
User-Visible: no
2026-09-24 01:35:25 +00:00

198 lines
13 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* E2E на реальном Home Assistant как гейт стабильного релиза (#514).
*
* Стабильный релиз проходил Validate и Full Performance на точном SHA, но ни
* разу не запускался в настоящем HA. Репозиторий houseplan-e2e ставит House
* Plan и гоняет 13 сценариев Playwright. Этот скрипт запускает его workflow и
* ждёт зелёного; `release.yml` вызывает его для стабильных после Full Performance.
*
* #540: под тестом — коммит-кандидат (`--ref=<sha>`), а не публичный релиз.
* install-houseplan.mjs для ветки/коммита ставит `custom_components/houseplan`
* из tarball codeload — то же дерево, из которого `git archive` строит
* `houseplan.zip`. Так релиз проверяется ДО того, как станет публичным; раньше
* гейт качал ZIP из релиза, то есть требовал публикации до проверки. `--tag`
* при этом остаётся: он исключает выпускаемый тег из выбора `upgrade_from`.
*
* node scripts/e2e-gate.mjs --tag=<vX.Y.Z> [--ref=<sha>] [--repo=Matysh/houseplan-e2e] [--workflow=e2e.yml]
*
* Печатает `result=green|red|missing|error`, `url=…`, `note=…` (и в
* $GITHUB_OUTPUT), код выхода 0 только при green. Логика — чистая функция
* `e2eGate` поверх инъектируемых `ops` (образец — validate-gate.mjs, #510).
*/
import { spawnSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { VALIDATE_APPEAR_MS, VALIDATE_TOTAL_MS } from './merge-candidate.mjs';
import { isMainModule } from './spawn-portable.mjs';
export const POLL_MS = 20_000;
export const E2E_REPO = 'Matysh/houseplan-e2e';
export const E2E_WORKFLOW = 'e2e.yml';
/** Допуск на расхождение часов раннера и GitHub при отборе «свежих» прогонов. */
export const CLOCK_SKEW_MS = 60_000;
export const TOKEN_HINT = 'нужен секрет E2E_DISPATCH_TOKEN: Actions: write на houseplan-e2e И чтение релизов houseplan-card (fine-grained PAT — оба репозитория в списке)';
export const CARD_REPO = 'Matysh/houseplan-card';
/**
* Откуда обновляться в сьюте `upgrade`: предыдущий stable, не `stable`.
* К моменту события `release: published` новый тег — уже самый свежий
* не-пре-релиз, и `upgrade_from=stable` означало бы «обновиться с v1.74.0 на
* v1.74.0» — сьют честно краснеет (`Expected: not "1.73.0"`, живой прогон
* 09.09). Первый stable в истории обновляться неоткуда — тогда `stable`.
*/
export function previousStable(releases, tag) {
const prior = (Array.isArray(releases) ? releases : [])
.filter((r) => r && !r.isDraft && !r.isPrerelease && r.tagName && r.tagName !== tag);
return prior[0]?.tagName || 'stable';
}
/**
* Контракт имени job с houseplan-e2e (#622). Строка — дословная копия `name:`
* матричной job в `Matysh/houseplan-e2e` `.github/workflows/e2e.yml` (сверено
* на 43899da5, 24.09.2026). Другого способа узнать свой прогон нет: dispatch
* API не возвращает id запуска, а `inputs` в ответе run не видны. Имя живёт в
* чужом репозитории, поэтому юнит-тест здесь ловит только правку этой
* стороны; переименование там — правка обеих сторон в один день, а разрыв
* виден громко: `missing` называет завершённые прогоны без job по контракту.
*/
export const E2E_JOB_NAME = '${{ matrix.suite }} · HP ${{ matrix.ref }} · HA ${{ matrix.ha }}';
const PLACEHOLDER = /\$\{\{\s*matrix\.([A-Za-z0-9_-]+)\s*\}\}/g;
/** Имя экземпляра job e2e.yml для значений матрицы; незаданная ось — ошибка. */
export function e2eJobName(values) {
return E2E_JOB_NAME.replace(PLACEHOLDER, (whole, axis) => {
if (values?.[axis] === undefined) throw new Error(`e2e job name: no value for matrix.${axis}`);
return String(values[axis]);
});
}
// Любой экземпляр по контракту: литералы шаблона как есть, оси — непустые.
const escapeRe = (text) => text.replace(/[.*+?^$()|[\]{}\\]/g, '\\$&');
const NAMED_JOB = new RegExp(`^${E2E_JOB_NAME.split(PLACEHOLDER)
.map((part, index) => (index % 2 ? '.+' : escapeRe(part))).join('')}$`);
const CUT = '\u0000';
/**
* Прогон — наш, если сьют, ставящий сам кандидат, назван по нему: у
* `journeys`/`first-run` ось `ref` — это `houseplan_ref` (тег или SHA, #540).
* Сьют `upgrade` носит `upgrade_from` — тег ПРЕДЫДУЩЕГО stable, поэтому
* «любая job с HP <ref>» приняла бы прогон нового релиза за прогон старого
* (живой прогон 09.09: v1.72.0 ← run для v1.73.0). Сравнивается всё имя до
* оси `ha`: версия HA в выборе не участвует.
*/
export const TAG_SUITES = ['journeys', 'first-run'];
export function isOurRun(jobs, ref) {
const needles = TAG_SUITES.map((suite) => e2eJobName({ suite, ref, ha: CUT }).split(CUT)[0]);
return (Array.isArray(jobs) ? jobs : []).some((job) => needles.some((needle) => String(job?.name || '').startsWith(needle)));
}
/** Job, названная по контракту E2E_JOB_NAME (а не служебная «Матрица прогона»). */
export const isNamedE2eJob = (job) => NAMED_JOB.test(String(job?.name || ''));
/**
* Прогон, у которого ещё нет ни одной job по контракту, решать рано: e2e.yml
* сначала планирует матрицу отдельной job, и первые секунды виден только
* «Матрица прогона». Живой прогон 09.09 записал такой run в чужие навсегда.
*/
export function classifyRun(jobs, ref) {
const named = (Array.isArray(jobs) ? jobs : []).filter(isNamedE2eJob);
if (!named.length) return 'unknown';
return isOurRun(named, ref) ? 'ours' : 'foreign';
}
/**
* @param {object} p
* @param {string} p.tag выпускаемый тег — исключается из выбора `upgrade_from`
* @param {string} [p.ref] что ставить под тест (`houseplan_ref` для e2e.yml): SHA кандидата (#540); по умолчанию сам тег
* @param {object} p.ops { releases() → [{tagName,isDraft,isPrerelease}] новые первыми, dispatch(ref, upgradeFrom), listRuns() → [{databaseId,status,conclusion,url,createdAt}], jobs(runId) → [{name,conclusion}], sleep(ms), now() }
* @returns {Promise<{result:'green'|'red'|'missing'|'error', url:string|null, note:string}>}
*/
export async function e2eGate({ tag, ref = tag, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS }) {
const started = ops.now();
try {
// Список релизов читается ДО dispatch и обязан падать громко (ревью r3 M1):
// fine-grained токен «только houseplan-e2e» не видит houseplan-card, и
// тихий пустой список дал бы upgrade_from=stable — тег сам на себя.
await ops.dispatch(ref, previousStable(await ops.releases(), tag));
} catch (error) {
const message = String(error?.message || error);
const forbidden = /403|Resource not accessible|not accessible by/i.test(message);
return { result: 'error', url: null, note: `запуск e2e.yml не удался: ${message}${forbidden ? ` — ${TOKEN_HINT}` : ''}` };
}
const foreign = new Set(); // dispatch-прогоны без нашего тега в именах job
const offContract = new Set(); // завершились, а ни одной job по E2E_JOB_NAME (#622)
let tracked = null;
while (ops.now() - started < totalMs) {
const runs = (await ops.listRuns()).filter((run) => !foreign.has(run.databaseId));
let run = tracked ? runs.find((x) => x.databaseId === tracked) : null;
if (!run) {
// Опознание: свежий dispatch, чьи job носят наш тег.
for (const candidate of runs) {
const createdAt = Date.parse(candidate.createdAt || '') || 0;
if (createdAt < started - CLOCK_SKEW_MS) continue;
const jobs = await ops.jobs(candidate.databaseId);
const kind = classifyRun(jobs, ref);
if (kind === 'ours') { run = candidate; break; }
if (kind === 'unknown' && candidate.status === 'completed' && jobs.length) offContract.add(candidate.databaseId);
if (kind === 'foreign' || candidate.status === 'completed') foreign.add(candidate.databaseId);
}
}
if (run) {
tracked = run.databaseId;
if (run.status === 'completed') {
if (run.conclusion === 'success') return { result: 'green', url: run.url, note: `E2E на ${ref} зелёный` };
if (run.conclusion === 'cancelled') return { result: 'red', url: run.url, note: `E2E на ${ref} отменён вручную — перезапустите гейт` };
return { result: 'red', url: run.url, note: `E2E на ${ref} завершился: ${run.conclusion}` };
}
} else if (ops.now() - started > appearMs) {
const contract = offContract.size
? `; ${offContract.size} завершённых прогона без job по контракту E2E_JOB_NAME — имя job в e2e.yml разошлось с scripts/e2e-gate.mjs (#622)`
: '';
return { result: 'missing', url: null, note: `dispatch e2e.yml на ${ref} не появился за ${Math.round(appearMs / 60000)} мин${contract}` };
}
await ops.sleep(pollMs);
}
return { result: 'red', url: tracked ? `run ${tracked}` : null, note: `E2E на ${ref} не завершился за ${Math.round(totalMs / 60000)} мин` };
}
const sh = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8' });
export function realOps({ repo = E2E_REPO, workflow = E2E_WORKFLOW, cardRepo = CARD_REPO, exec = sh } = {}) {
const fields = 'databaseId,status,conclusion,url,createdAt';
const parse = (r) => (r.status === 0 && r.stdout ? JSON.parse(r.stdout) : []);
return {
releases: async () => {
const r = exec('gh', ['release', 'list', '--repo', cardRepo, '--json', 'tagName,isDraft,isPrerelease', '--limit', '30']);
if (r.status !== 0) throw new Error(`gh release list ${cardRepo}: ${(r.stderr || r.stdout || '').trim()}`);
return r.stdout ? JSON.parse(r.stdout) : [];
},
dispatch: async (ref, upgradeFrom = 'stable') => {
const r = exec('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', 'main',
'-f', `houseplan_ref=${ref}`, '-f', `upgrade_from=${upgradeFrom}`, '-f', 'ha_version=stable']);
if (r.status !== 0) throw new Error(`gh workflow run: ${(r.stderr || r.stdout || '').trim()}`);
},
listRuns: async () => parse(exec('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--event', 'workflow_dispatch', '--json', fields, '--limit', '10'])),
jobs: async (runId) => {
const r = exec('gh', ['run', 'view', String(runId), '--repo', repo, '--json', 'jobs']);
return r.status === 0 && r.stdout ? (JSON.parse(r.stdout).jobs || []).map((job) => ({ name: job.name, conclusion: job.conclusion })) : [];
},
sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
now: () => Date.now(),
};
}
if (isMainModule(import.meta.url)) { // #496: переносимо для Windows
const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
const tag = arg('tag');
if (!tag) {
console.error('usage: e2e-gate.mjs --tag=<vX.Y.Z> [--ref=<sha>] [--repo=Matysh/houseplan-e2e] [--workflow=e2e.yml] [--card-repo=Matysh/houseplan-card]');
process.exit(2);
}
const ref = arg('ref') || tag;
const outcome = await e2eGate({ tag, ref, ops: realOps({ repo: arg('repo') || E2E_REPO, workflow: arg('workflow') || E2E_WORKFLOW, cardRepo: arg('card-repo') || CARD_REPO }) });
const lines = [`result=${outcome.result}`, `url=${outcome.url || ''}`, `note=${outcome.note}`];
for (const line of lines) console.log(line);
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
process.exit(outcome.result === 'green' ? 0 : 1);
}