#!/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=`), а не публичный релиз. * install-houseplan.mjs для ветки/коммита ставит `custom_components/houseplan` * из tarball codeload — то же дерево, из которого `git archive` строит * `houseplan.zip`. Так релиз проверяется ДО того, как станет публичным; раньше * гейт качал ZIP из релиза, то есть требовал публикации до проверки. `--tag` * при этом остаётся: он исключает выпускаемый тег из выбора `upgrade_from`. * * node scripts/e2e-gate.mjs --tag= [--ref=] [--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 » приняла бы прогон нового релиза за прогон старого * (живой прогон 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= [--ref=] [--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); }