mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 23:19:14 +00:00
infra: закрепить локальный toolchain Windows и WSL (#557)
Добавлены безопасные pinned entrypoints, вывод фактических путей, идемпотентный Windows setup и WSL verification с настоящим HA subset и Linux capture. Issue: #557 User-Visible: no
This commit is contained in:
committed by
claude[bot]
parent
eb77224e0c
commit
c1c3743f5d
@@ -483,9 +483,15 @@ a draft first (#540).
|
||||
**Local Windows checkout** is the day-to-day environment: Node 22 and Python 3.14
|
||||
as in CI (`npm run toolchain:check` compares the machine with the pins CI actually
|
||||
uses — `.nvmrc` and `.python-version` are derived from the same sources, #496),
|
||||
`gh` authenticated. `.venv-backend` does **not** exist there — it is
|
||||
provisioned only by cloud agent startup scripts, which also run `npm ci` and install
|
||||
Playwright Chromium.
|
||||
`gh` authenticated. On the owner's machine do not trust the ambient PATH:
|
||||
`.\scripts\windows-toolchain.ps1 setup|check` owns a verified portable Node and
|
||||
dedicated `.venv-ci`, and its `npm`/`node`/`python`/`playwright` actions are the
|
||||
explicit pinned entrypoints (#557). It changes no persistent PATH and never
|
||||
deletes a mismatched venv. In WSL, work from an ext4 clone and use
|
||||
`bash scripts/wsl-setup.sh --verify` for the real HA subset plus one Linux visual
|
||||
capture; exact-SHA Linux CI remains authoritative. `.venv-backend` does **not**
|
||||
exist there — it is provisioned only by cloud agent startup scripts, which also
|
||||
run `npm ci` and install Playwright Chromium.
|
||||
|
||||
Known environment-sensitive smoke: `demo/smoke_opening_measure.mjs` fails two
|
||||
sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned
|
||||
|
||||
+54
-31
@@ -48,38 +48,62 @@ them from `validate.yml`, `tests_backend/requirements.txt` and the lockfile, and
|
||||
`npm run toolchain:check` compares the machine with them (#496). `.nvmrc` and
|
||||
`.python-version` carry the same values for nvm/uv/pyenv; a test keeps them equal.
|
||||
|
||||
Minimal native setup (PowerShell):
|
||||
The supported native setup is repository-scoped and does not change the
|
||||
machine's default Node, Python or persistent `PATH` (#557):
|
||||
|
||||
```powershell
|
||||
winget install --id OpenJS.NodeJS.22 --source winget
|
||||
winget install --id GitHub.cli --source winget
|
||||
gh auth login
|
||||
Set-Location 'C:\Users\Sergey\Downloads\dev\houseplan-dev\houseplan-card-src'
|
||||
uv python install 3.14
|
||||
uv venv --python 3.14 .venv
|
||||
uv pip install --python '.venv\Scripts\python.exe' pytest voluptuous pytest-asyncio
|
||||
npm ci
|
||||
npx playwright install chromium
|
||||
# One-time/idempotent setup. Requires uv; installs a verified portable Node 22
|
||||
# under %LOCALAPPDATA% and Python 3.14 in the dedicated .venv-ci.
|
||||
.\scripts\windows-toolchain.ps1 setup
|
||||
|
||||
# Read-only proof: actual versions and executable/package/browser paths.
|
||||
.\scripts\windows-toolchain.ps1 check
|
||||
|
||||
# Explicit pinned entrypoints for ordinary commands; no accidental PATH tools.
|
||||
.\scripts\windows-toolchain.ps1 npm run gate:small
|
||||
.\scripts\windows-toolchain.ps1 python -Arguments @(
|
||||
'-m', 'pytest', '-p', 'pytest_asyncio.plugin',
|
||||
'tests_backend/test_validation.py', 'tests_backend/test_trails.py',
|
||||
'tests_backend/test_trail_recorder.py', '-q'
|
||||
)
|
||||
.\scripts\windows-toolchain.ps1 playwright install chromium
|
||||
```
|
||||
|
||||
Open a new Windows Terminal after installing Node/GitHub CLI so their PATH
|
||||
changes are visible. Keep the Playwright browser in its normal shared Windows
|
||||
cache; downloading it inside every repository wastes time and disk space.
|
||||
The Node archive is selected from the official release index for the major in
|
||||
`.nvmrc` and checked against Node's `SHASUMS256.txt`. The script prepends that
|
||||
directory to `PATH` only for its child process. It never removes an existing
|
||||
venv: if the requested `-VenvPath` contains another Python minor, setup stops
|
||||
and asks for another path. Playwright remains in its normal shared Windows
|
||||
cache. Install `uv` once with `winget install --id astral-sh.uv --source winget`
|
||||
if it is absent; GitHub access still uses the separately installed `gh`.
|
||||
|
||||
WSL2 is optional for the ordinary frontend and pure-backend loop. `bash
|
||||
scripts/wsl-setup.sh` provisions it with the CI pins (nvm → Node, uv → Python and
|
||||
the HA test stack from `tests_backend/requirements.txt`, Playwright Chromium from
|
||||
the lockfile) and ends with the same `toolchain:check`; it is idempotent. The
|
||||
canonical proof still lives in Linux CI at the exact SHA — WSL is early feedback.
|
||||
It is required only when running the full HA harness locally: current Home Assistant imports
|
||||
the Unix-only `fcntl` module and cannot start its pytest plugin on native
|
||||
Windows. Keep a WSL clone inside the Linux ext4 filesystem rather than under
|
||||
`/mnt/c`, otherwise dependency installs become slower. The release CI always
|
||||
runs this harness on Ubuntu and gates the exact tagged commit. Docker Desktop is
|
||||
not currently required. Do not install the full Home Assistant pytest stack
|
||||
natively just for this repository: its pinned `lru-dict==1.3.0` first requires
|
||||
Visual Studio Build Tools to compile, but the resulting plugin still cannot run
|
||||
without `fcntl`.
|
||||
WSL2 is optional for the ordinary frontend and pure-backend loop. Keep its clone
|
||||
inside Linux ext4, not under `/mnt/c`; on a fresh checkout run:
|
||||
|
||||
```bash
|
||||
cd ~/houseplan-card
|
||||
bash scripts/wsl-setup.sh # idempotent setup in dedicated .venv-ci
|
||||
bash scripts/wsl-setup.sh --check # no installation; paths + versions only
|
||||
bash scripts/wsl-setup.sh --verify # setup, real HA subset and one golden capture
|
||||
```
|
||||
|
||||
The script provisions the CI pins (nvm → Node, uv → Python and the HA test stack
|
||||
from `tests_backend/requirements.txt`, Playwright Chromium from the lockfile).
|
||||
`--verify` imports Unix-only `fcntl` and the pinned Home Assistant, runs
|
||||
`tests_backend/test_ha_setup.py`, builds the card and captures
|
||||
`panel-wide-view-light-en` under `artifacts/golden/`; it records elapsed time and
|
||||
the resulting PNG path. `HOUSEPLAN_VENV` selects another dedicated venv without
|
||||
deleting or rewriting an existing one. The canonical proof still lives in Linux
|
||||
CI at the exact SHA — WSL is early feedback.
|
||||
It is required only when running the full HA harness locally: current Home
|
||||
Assistant imports the Unix-only `fcntl` module and cannot start its pytest plugin
|
||||
on native Windows. Keep a WSL clone inside the Linux ext4 filesystem rather than
|
||||
under `/mnt/c`, otherwise dependency installs become slower. The release CI
|
||||
always runs this harness on Ubuntu and gates the exact tagged commit. Docker
|
||||
Desktop is not currently required. Do not install the full Home Assistant pytest
|
||||
stack natively just for this repository: its pinned `lru-dict==1.3.0` first
|
||||
requires Visual Studio Build Tools to compile, but the resulting plugin still
|
||||
cannot run without `fcntl`.
|
||||
|
||||
Useful repo-local Git settings on NTFS (optional for this small repository):
|
||||
|
||||
@@ -101,10 +125,9 @@ git config core.untrackedCache true
|
||||
|
||||
- Frontend: `npm test` — compiles src/logic.ts+rules.ts (tsconfig.test.json) and runs node:test
|
||||
(test/*.test.mjs). Strict typing: `npm run typecheck` (tsc --noEmit, part of `npm run build`).
|
||||
- Pure backend on native Windows (with no HA plugin autoload):
|
||||
`$env:PYTEST_DISABLE_PLUGIN_AUTOLOAD='1'; .\.venv\Scripts\python.exe -m pytest
|
||||
-p pytest_asyncio.plugin tests_backend/test_validation.py
|
||||
tests_backend/test_trails.py tests_backend/test_trail_recorder.py -q`.
|
||||
- Pure backend on native Windows (with no HA plugin autoload): use the explicit
|
||||
`python -Arguments @(...)` invocation above after setting
|
||||
`$env:PYTEST_DISABLE_PLUGIN_AUTOLOAD='1'`.
|
||||
- Full backend (including `test_ha_*.py`): `python -m pytest tests_backend/ -q`
|
||||
in CI or WSL/Linux only.
|
||||
- IMPORTANT (audit lesson): the rollup typescript plugin reports a syntax error as a WARNING and still
|
||||
|
||||
@@ -27,6 +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 | Prerelease publication requires a green exact-SHA Validate: frontend/backend, smoke (including the #73 rAF frame sampler), golden, HACS/Hassfest and a short absolute-ceiling performance smoke. Obsolete same-ref Validate runs are cancelled. Full seven-sample base/candidate performance moved to `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 Node 22 and a dedicated Python 3.14 `.venv-ci` without changing system defaults; `toolchain:check` reports 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. |
|
||||
| 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 |
|
||||
|
||||
@@ -4069,6 +4069,24 @@ require hands on real hardware — they remain for the human pass.
|
||||
покраснеть. Перед бетой selected summary-panel smoke проверяет ротацию
|
||||
touch/kiosk viewport по общему release-процессу.
|
||||
|
||||
## Локальный CI-совместимый toolchain (#557)
|
||||
|
||||
- [ ] `test/toolchain-pins.test.mjs` проверяет, что явно выбранный Python
|
||||
используется и для version probe, и для `pip show`, без fallback к
|
||||
`python`/`python3`/`py` из PATH; строки результата содержат пути Node,
|
||||
Python, Playwright package и Chromium executable.
|
||||
- [ ] Тот же unit запрещает Windows setup менять persistent PATH или удалять
|
||||
существующий venv, требует SHA-256 проверки portable Node и подтверждает,
|
||||
что WSL verify запускает настоящий HA subset и одну Linux golden-съёмку.
|
||||
- [ ] На Windows два последовательных
|
||||
`pwsh -File scripts/windows-toolchain.ps1 setup` проходят: первый ставит
|
||||
изолированные runtimes, второй переиспользует их; `check` после каждого
|
||||
зелёный и печатает фактические версии/пути.
|
||||
- [ ] Из свежего ext4 checkout WSL команда
|
||||
`bash scripts/wsl-setup.sh --verify` проходит `test_ha_setup.py` без skip,
|
||||
создаёт непустой `panel-wide-view-light-en.png` и печатает длительность.
|
||||
Это ранняя обратная связь; независимый exact-SHA Validate остаётся каноном.
|
||||
|
||||
## Полнота источников радара (#545)
|
||||
|
||||
- [ ] `tests_backend/test_radar_validation.py` проверяет точный inventory всех
|
||||
|
||||
@@ -60,6 +60,7 @@ export const NOT_AN_INPUT = [
|
||||
['scripts/sh3d-convert/make-fixtures.mjs', 'генератор фикстур конвертера, ручной'],
|
||||
['scripts/support-relay/deploy/**', 'деплой relay на стенд'],
|
||||
['scripts/wsl-setup.sh', 'установка локального Linux/WSL-контура с пинами CI (#496), ручной запуск'],
|
||||
['scripts/windows-toolchain.ps1', 'изолированная установка и запуск Windows toolchain с пинами CI (#557), ручной запуск'],
|
||||
['.github/workflows/*.yml', 'другие workflow: у каждого свой запуск; validate.yml — вход toolchain всех проверок, объявлен явно'],
|
||||
['.github/ISSUE_TEMPLATE/**', 'шаблоны issue GitHub, не исполняются'],
|
||||
['.githooks/**', 'локальные хуки'],
|
||||
|
||||
+52
-12
@@ -52,27 +52,52 @@ export function pinsFromSources({
|
||||
}
|
||||
|
||||
function run(cmd, args) {
|
||||
const r = spawnSync(cmd, args, { encoding: 'utf8' });
|
||||
const r = spawnSync(cmd, args, { cwd: ROOT, encoding: 'utf8' });
|
||||
if (r.error || r.status !== 0) return null;
|
||||
return `${r.stdout || ''}${r.stderr || ''}`.trim();
|
||||
}
|
||||
|
||||
function pythonProbe(exec, explicitCommand = null) {
|
||||
const candidates = explicitCommand
|
||||
? [[explicitCommand, []]]
|
||||
: [['python', []], ['python3', []], ['py', ['-3']]];
|
||||
for (const [command, prefix] of candidates) {
|
||||
const out = exec(command, [...prefix, '-c',
|
||||
'import sys; print(sys.version.split()[0]); print(sys.executable)']);
|
||||
if (!out) continue;
|
||||
const [version, executable] = out.split(/\r?\n/).map((line) => line.trim());
|
||||
if (/^\d+\.\d+(?:\.\d+)?$/.test(version) && executable) {
|
||||
return { command, prefix, version, executable };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Что установлено локально; `null` — не найдено. */
|
||||
export function localToolchain({ exec = run } = {}) {
|
||||
export function localToolchain({ exec = run, pythonCommand = null } = {}) {
|
||||
const node = process.versions.node;
|
||||
const pyOut = exec('python', ['--version']) || exec('python3', ['--version']) || exec('py', ['-3', '--version']);
|
||||
const python = pyOut ? (pyOut.match(/(\d+\.\d+(?:\.\d+)?)/) || [])[1] || null : null;
|
||||
const pythonRuntime = pythonProbe(exec, pythonCommand);
|
||||
const pipShow = (name) => {
|
||||
const out = exec('python', ['-m', 'pip', 'show', name]) || exec('python3', ['-m', 'pip', 'show', name]);
|
||||
if (!pythonRuntime) return null;
|
||||
const out = exec(pythonRuntime.command, [...pythonRuntime.prefix, '-m', 'pip', 'show', name]);
|
||||
return out ? (out.match(/^Version:\s*(\S+)/m) || [])[1] || null : null;
|
||||
};
|
||||
let playwright = null;
|
||||
try { playwright = JSON.parse(read('node_modules/playwright/package.json')).version; } catch { /* нет */ }
|
||||
const playwrightPath = resolve(ROOT, 'node_modules/playwright/package.json');
|
||||
try { playwright = JSON.parse(readFileSync(playwrightPath, 'utf8')).version; } catch { /* нет */ }
|
||||
const chromiumPath = exec(process.execPath, ['-e',
|
||||
'const { chromium } = require("playwright"); process.stdout.write(chromium.executablePath())']);
|
||||
return {
|
||||
node, python,
|
||||
node,
|
||||
nodePath: process.execPath,
|
||||
python: pythonRuntime?.version || null,
|
||||
pythonPath: pythonRuntime?.executable || null,
|
||||
homeassistant: pipShow('homeassistant'),
|
||||
pytestHomeAssistant: pipShow('pytest-homeassistant-custom-component'),
|
||||
playwright,
|
||||
playwrightPath: existsSync(playwrightPath) ? playwrightPath : null,
|
||||
chromiumPath,
|
||||
chromiumExists: !!chromiumPath && existsSync(chromiumPath),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,28 +113,43 @@ export function compareToolchain(pins, local) {
|
||||
if (!ok) failures.push(name);
|
||||
};
|
||||
row('node', pins.node, local.node, String(local.node).split('.')[0] === String(pins.node),
|
||||
' (сравнение по мажору; .nvmrc)');
|
||||
` (major; ${local.nodePath || 'path unknown'})`);
|
||||
const pyMinor = (v) => (v ? v.split('.').slice(0, 2).join('.') : null);
|
||||
row('python', pins.python, local.python, pyMinor(local.python) === pyMinor(pins.python),
|
||||
' (сравнение по minor; .python-version)');
|
||||
` (minor; ${local.pythonPath || 'path unknown'})`);
|
||||
for (const [key, name] of [['homeassistant', 'homeassistant'], ['pytestHomeAssistant', 'pytest-ha-plugin']]) {
|
||||
if (local[key] == null) {
|
||||
lines.push(`warn ${name.padEnd(14)} пин ${String(pins[key]).padEnd(12)} локально — (не установлен: полный HA-харнесс — Linux/WSL)`);
|
||||
lines.push(`warn ${name.padEnd(14)} пин ${String(pins[key]).padEnd(12)} локально — `
|
||||
+ `(не установлен в ${local.pythonPath || 'selected Python'}: полный HA-харнесс — Linux/WSL)`);
|
||||
continue;
|
||||
}
|
||||
row(name, pins[key], local[key], local[key] === pins[key]);
|
||||
}
|
||||
row('playwright', pins.playwright, local.playwright, local.playwright === pins.playwright, ' (npm ci ставит из lockfile)');
|
||||
row('playwright', pins.playwright, local.playwright, local.playwright === pins.playwright,
|
||||
` (${local.playwrightPath || 'package path unknown'})`);
|
||||
if (local.chromiumExists !== undefined || local.chromiumPath !== undefined) {
|
||||
const chromiumPin = `${pins.chromium?.version || '?'} rev ${pins.chromium?.revision || '?'}`;
|
||||
row('chromium', chromiumPin, local.chromiumExists ? 'installed' : 'missing',
|
||||
local.chromiumExists === true, ` (${local.chromiumPath || 'executable path unavailable'})`);
|
||||
}
|
||||
return { ok: failures.length === 0, lines, failures };
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
const argv = process.argv.slice(2);
|
||||
const pins = pinsFromSources();
|
||||
const pythonEquals = argv.find((arg) => arg.startsWith('--python='));
|
||||
const pythonIndex = argv.indexOf('--python');
|
||||
const pythonCommand = pythonEquals?.slice('--python='.length)
|
||||
|| (pythonIndex >= 0 ? argv[pythonIndex + 1] : null);
|
||||
if ((pythonEquals && !pythonEquals.slice('--python='.length))
|
||||
|| (pythonIndex >= 0 && (!pythonCommand || pythonCommand.startsWith('--')))) {
|
||||
throw new Error('--python requires an executable path');
|
||||
}
|
||||
if (argv.includes('--json')) {
|
||||
process.stdout.write(`${JSON.stringify(pins, null, 2)}\n`);
|
||||
} else if (argv.includes('--check')) {
|
||||
const result = compareToolchain(pins, localToolchain());
|
||||
const result = compareToolchain(pins, localToolchain({ pythonCommand }));
|
||||
for (const line of result.lines) console.log(line);
|
||||
console.log(result.ok
|
||||
? 'toolchain совпадает с CI'
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Reproducible Windows entrypoint for House Plan's CI-compatible local tools (#557).
|
||||
# Nothing is added to the persistent PATH and no existing venv is removed.
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Position = 0)]
|
||||
[ValidateSet('setup', 'check', 'npm', 'node', 'python', 'playwright')]
|
||||
[string]$Action = 'check',
|
||||
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$Arguments = @(),
|
||||
|
||||
[string]$ToolRoot = (Join-Path $env:LOCALAPPDATA 'houseplan-toolchain'),
|
||||
[string]$VenvPath = '.venv-ci'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$RepoRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path
|
||||
$NodeMajor = (Get-Content -LiteralPath (Join-Path $RepoRoot '.nvmrc') -Raw).Trim()
|
||||
$PythonPin = (Get-Content -LiteralPath (Join-Path $RepoRoot '.python-version') -Raw).Trim()
|
||||
$ResolvedToolRoot = [IO.Path]::GetFullPath($ToolRoot)
|
||||
$ResolvedVenv = if ([IO.Path]::IsPathRooted($VenvPath)) {
|
||||
[IO.Path]::GetFullPath($VenvPath)
|
||||
} else {
|
||||
[IO.Path]::GetFullPath((Join-Path $RepoRoot $VenvPath))
|
||||
}
|
||||
|
||||
function Get-InstalledNode {
|
||||
if (-not (Test-Path -LiteralPath $ResolvedToolRoot -PathType Container)) { return $null }
|
||||
$candidates = Get-ChildItem -LiteralPath $ResolvedToolRoot -Directory | ForEach-Object {
|
||||
if ($_.Name -match "^node-v($NodeMajor[.]\d+[.]\d+)-win-x64$" -and
|
||||
(Test-Path -LiteralPath (Join-Path $_.FullName 'node.exe') -PathType Leaf)) {
|
||||
[pscustomobject]@{ Version = [version]$Matches[1]; Directory = $_.FullName }
|
||||
}
|
||||
}
|
||||
return $candidates | Sort-Object Version -Descending | Select-Object -First 1
|
||||
}
|
||||
|
||||
function Install-PortableNode {
|
||||
New-Item -ItemType Directory -Path $ResolvedToolRoot -Force | Out-Null
|
||||
$releases = Invoke-RestMethod -Uri 'https://nodejs.org/dist/index.json'
|
||||
$release = $releases | Where-Object {
|
||||
$_.version -match "^v$NodeMajor[.]" -and $_.files -contains 'win-x64-zip'
|
||||
} | Select-Object -First 1
|
||||
if (-not $release) { throw "Node $NodeMajor win-x64-zip was not found in the official release index" }
|
||||
|
||||
$archiveName = "node-$($release.version)-win-x64.zip"
|
||||
$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ("houseplan-node-" + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
|
||||
try {
|
||||
$archive = Join-Path $temporaryRoot $archiveName
|
||||
$baseUri = "https://nodejs.org/dist/$($release.version)"
|
||||
Invoke-WebRequest -Uri "$baseUri/$archiveName" -OutFile $archive
|
||||
$sums = (Invoke-WebRequest -Uri "$baseUri/SHASUMS256.txt").Content
|
||||
$sumLine = $sums -split "`n" | Where-Object {
|
||||
$_ -match ("\s" + [regex]::Escape($archiveName) + "\s*$")
|
||||
} | Select-Object -First 1
|
||||
if (-not $sumLine) { throw "SHA-256 for $archiveName was not found" }
|
||||
$expected = ($sumLine.Trim() -split '\s+')[0].ToLowerInvariant()
|
||||
$actual = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actual -ne $expected) { throw "SHA-256 mismatch for $archiveName" }
|
||||
|
||||
$expandedRoot = Join-Path $temporaryRoot 'expanded'
|
||||
Expand-Archive -LiteralPath $archive -DestinationPath $expandedRoot
|
||||
$expandedNode = Join-Path $expandedRoot "node-$($release.version)-win-x64"
|
||||
$destination = Join-Path $ResolvedToolRoot "node-$($release.version)-win-x64"
|
||||
if (-not (Test-Path -LiteralPath $destination)) {
|
||||
Move-Item -LiteralPath $expandedNode -Destination $destination
|
||||
}
|
||||
Write-Host "Node $($release.version) installed at $destination"
|
||||
} finally {
|
||||
$safeTemp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
|
||||
$resolvedTemporary = [IO.Path]::GetFullPath($temporaryRoot)
|
||||
if ($resolvedTemporary.StartsWith($safeTemp, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
Remove-Item -LiteralPath $resolvedTemporary -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-Runtimes([bool]$AllowInstall) {
|
||||
$node = Get-InstalledNode
|
||||
if (-not $node -and $AllowInstall) {
|
||||
Install-PortableNode
|
||||
$node = Get-InstalledNode
|
||||
}
|
||||
if (-not $node) {
|
||||
throw "Portable Node $NodeMajor is absent. Run: .\scripts\windows-toolchain.ps1 setup"
|
||||
}
|
||||
$nodeExe = Join-Path $node.Directory 'node.exe'
|
||||
$npmCmd = Join-Path $node.Directory 'npm.cmd'
|
||||
$npxCmd = Join-Path $node.Directory 'npx.cmd'
|
||||
|
||||
$pythonExe = Join-Path $ResolvedVenv 'Scripts\python.exe'
|
||||
$uv = $null
|
||||
if ($AllowInstall) {
|
||||
$uv = (Get-Command uv -ErrorAction SilentlyContinue).Source
|
||||
if (-not $uv) {
|
||||
throw 'uv is required. Install it once with: winget install --id astral-sh.uv --source winget'
|
||||
}
|
||||
& $uv python install $PythonPin
|
||||
if ($LASTEXITCODE -ne 0) { throw "uv could not install Python $PythonPin" }
|
||||
if (-not (Test-Path -LiteralPath $pythonExe)) {
|
||||
& $uv venv --python $PythonPin $ResolvedVenv
|
||||
if ($LASTEXITCODE -ne 0) { throw "uv could not create $ResolvedVenv" }
|
||||
}
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $pythonExe)) {
|
||||
throw "Pinned venv is absent. Run: .\scripts\windows-toolchain.ps1 setup"
|
||||
}
|
||||
$pythonVersion = (& $pythonExe -c 'import sys; print(".".join(map(str, sys.version_info[:2])))').Trim()
|
||||
if ($pythonVersion -ne $PythonPin) {
|
||||
throw "$ResolvedVenv uses Python $pythonVersion, expected $PythonPin. It was not removed; choose another -VenvPath."
|
||||
}
|
||||
if ($AllowInstall) {
|
||||
$voluptuous = (Select-String -LiteralPath (Join-Path $RepoRoot 'tests_backend\requirements.txt') `
|
||||
-Pattern '^voluptuous==\S+$').Line
|
||||
& $uv pip install --python $pythonExe pytest pytest-asyncio $voluptuous
|
||||
if ($LASTEXITCODE -ne 0) { throw 'uv could not install the native-Windows pure-test dependencies' }
|
||||
}
|
||||
return [pscustomobject]@{
|
||||
NodeDirectory = $node.Directory
|
||||
Node = $nodeExe
|
||||
Npm = $npmCmd
|
||||
Npx = $npxCmd
|
||||
Python = $pythonExe
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Check($runtime) {
|
||||
$oldPath = $env:PATH
|
||||
try {
|
||||
$env:PATH = "$($runtime.NodeDirectory);$oldPath"
|
||||
& $runtime.Node (Join-Path $RepoRoot 'scripts\toolchain-pins.mjs') --check "--python=$($runtime.Python)"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Local toolchain does not match the CI pins' }
|
||||
} finally {
|
||||
$env:PATH = $oldPath
|
||||
}
|
||||
}
|
||||
|
||||
function Normalized-Arguments {
|
||||
if ($Arguments.Count -gt 0 -and $Arguments[0] -eq '--') {
|
||||
if ($Arguments.Count -eq 1) { return @() }
|
||||
return $Arguments[1..($Arguments.Count - 1)]
|
||||
}
|
||||
return $Arguments
|
||||
}
|
||||
|
||||
Push-Location $RepoRoot
|
||||
try {
|
||||
$timer = [Diagnostics.Stopwatch]::StartNew()
|
||||
$runtime = Resolve-Runtimes ($Action -eq 'setup')
|
||||
$oldPath = $env:PATH
|
||||
$env:PATH = "$($runtime.NodeDirectory);$oldPath"
|
||||
try {
|
||||
if ($Action -eq 'setup') {
|
||||
& $runtime.Npm ci --no-audit --no-fund
|
||||
if ($LASTEXITCODE -ne 0) { throw 'npm ci failed' }
|
||||
& $runtime.Npx playwright install chromium
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Playwright Chromium installation failed' }
|
||||
Invoke-Check $runtime
|
||||
} elseif ($Action -eq 'check') {
|
||||
Invoke-Check $runtime
|
||||
} else {
|
||||
$forward = @(Normalized-Arguments)
|
||||
if ($Action -eq 'npm') { & $runtime.Npm @forward }
|
||||
elseif ($Action -eq 'node') { & $runtime.Node @forward }
|
||||
elseif ($Action -eq 'python') { & $runtime.Python @forward }
|
||||
else { & $runtime.Npx playwright @forward }
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
}
|
||||
} finally {
|
||||
$env:PATH = $oldPath
|
||||
}
|
||||
$timer.Stop()
|
||||
Write-Host ("House Plan {0}: {1:n1}s" -f $Action, $timer.Elapsed.TotalSeconds)
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
+73
-21
@@ -7,45 +7,97 @@
|
||||
# Linux CI на точном SHA; этот контур — ранняя обратная связь.
|
||||
#
|
||||
# bash scripts/wsl-setup.sh # установить/обновить окружение
|
||||
# bash scripts/wsl-setup.sh --check # только сверить с пинами
|
||||
# bash scripts/wsl-setup.sh --check # только сверить dedicated entrypoints
|
||||
# bash scripts/wsl-setup.sh --verify # setup + HA subset + one visual capture
|
||||
#
|
||||
# Требует: git, curl. Node ставится через nvm (в ~/.nvm), Python — через uv
|
||||
# (в .venv репозитория), Chromium — Playwright той версии, что в lockfile.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if [ "${1:-}" = "--check" ]; then
|
||||
exec node scripts/toolchain-pins.mjs --check
|
||||
fi
|
||||
MODE=${1:-setup}
|
||||
case "$MODE" in
|
||||
setup|--check|--verify) ;;
|
||||
*) echo "usage: bash scripts/wsl-setup.sh [--check|--verify]" >&2; exit 2 ;;
|
||||
esac
|
||||
START_SECONDS=$SECONDS
|
||||
|
||||
# Node нужен уже для чтения пинов; bootstrap — из .nvmrc (тест держит его равным CI).
|
||||
NODE_PIN=$(tr -d '[:space:]' < .nvmrc)
|
||||
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||
if [ ! -s "$NVM_DIR/nvm.sh" ]; then
|
||||
if [ "$MODE" = "--check" ]; then
|
||||
echo "nvm не найден в $NVM_DIR; сначала: bash scripts/wsl-setup.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "nvm не найден — устанавливаю в $NVM_DIR"
|
||||
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
|
||||
# Не менять shell profile: nvm нужен только этому явному entrypoint.
|
||||
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | PROFILE=/dev/null bash
|
||||
fi
|
||||
# shellcheck disable=SC1091
|
||||
. "$NVM_DIR/nvm.sh"
|
||||
nvm install "$NODE_PIN" >/dev/null
|
||||
if [ "$MODE" != "--check" ]; then nvm install "$NODE_PIN" >/dev/null; fi
|
||||
nvm use "$NODE_PIN" >/dev/null
|
||||
echo "node $(node --version) (пин $NODE_PIN)"
|
||||
echo "node $(node --version): $(command -v node) (пин $NODE_PIN)"
|
||||
|
||||
npm ci --no-audit --no-fund
|
||||
|
||||
PY_PIN=$(node scripts/toolchain-pins.mjs --json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).python))')
|
||||
if ! command -v uv >/dev/null 2>&1; then
|
||||
echo "uv не найден — устанавливаю"
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
# До npm ci полного `toolchain-pins --json` ещё нет: Chromium pin живёт в
|
||||
# node_modules/playwright-core/browsers.json. Bootstrap берёт стандартный
|
||||
# Python pin-файл; тест гарантирует его равенство каноническому CI workflow.
|
||||
PY_PIN=$(tr -d '[:space:]' < .python-version)
|
||||
UV_BIN=""
|
||||
if [ -x "$HOME/.local/bin/uv" ]; then
|
||||
UV_BIN="$HOME/.local/bin/uv"
|
||||
elif command -v uv >/dev/null 2>&1; then
|
||||
UV_BIN=$(command -v uv)
|
||||
fi
|
||||
uv python install "$PY_PIN" >/dev/null
|
||||
[ -d .venv ] || uv venv --python "$PY_PIN" .venv >/dev/null
|
||||
uv pip install --python .venv/bin/python -r tests_backend/requirements.txt >/dev/null
|
||||
echo "python $(.venv/bin/python --version) + HA-стек из tests_backend/requirements.txt"
|
||||
if [ -z "$UV_BIN" ] && [ "$MODE" != "--check" ]; then
|
||||
echo "uv не найден — устанавливаю"
|
||||
# Как и nvm, не прописывать локальный helper в пользовательский shell profile.
|
||||
curl -LsSf https://astral.sh/uv/install.sh | UV_NO_MODIFY_PATH=1 sh
|
||||
UV_BIN="$HOME/.local/bin/uv"
|
||||
fi
|
||||
VENV=${HOUSEPLAN_VENV:-.venv-ci}
|
||||
PYTHON_EXE="$PWD/$VENV/bin/python"
|
||||
if [ "$MODE" != "--check" ]; then
|
||||
"$UV_BIN" python install "$PY_PIN" >/dev/null
|
||||
if [ -x "$PYTHON_EXE" ]; then
|
||||
EXISTING_MINOR=$($PYTHON_EXE -c 'import sys; print(".".join(map(str, sys.version_info[:2])))')
|
||||
if [ "$EXISTING_MINOR" != "$PY_PIN" ]; then
|
||||
echo "$VENV использует Python $EXISTING_MINOR, нужен $PY_PIN; окружение не удалено, выберите другое HOUSEPLAN_VENV" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
"$UV_BIN" venv --python "$PY_PIN" "$VENV" >/dev/null
|
||||
fi
|
||||
"$UV_BIN" pip install --python "$PYTHON_EXE" -r tests_backend/requirements.txt >/dev/null
|
||||
fi
|
||||
if [ ! -x "$PYTHON_EXE" ]; then
|
||||
echo "$PYTHON_EXE не найден; сначала: bash scripts/wsl-setup.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "python $($PYTHON_EXE --version): $PYTHON_EXE + HA-стек из tests_backend/requirements.txt"
|
||||
|
||||
npx playwright install chromium >/dev/null
|
||||
if [ "$MODE" != "--check" ]; then
|
||||
npm ci --no-audit --no-fund
|
||||
npx playwright install chromium >/dev/null
|
||||
fi
|
||||
echo "chromium: $(node -e 'console.log(require("playwright").chromium.executablePath())')"
|
||||
|
||||
# Сверка тем же скриптом, что и на Windows; python берётся из .venv.
|
||||
PATH="$PWD/.venv/bin:$PATH" node scripts/toolchain-pins.mjs --check
|
||||
# Сверка тем же скриптом, что и на Windows; Python передаётся явно и не зависит от PATH.
|
||||
node scripts/toolchain-pins.mjs --check --python "$PYTHON_EXE"
|
||||
|
||||
if [ "$MODE" = "--verify" ]; then
|
||||
echo "HA subset: tests_backend/test_ha_setup.py"
|
||||
"$PYTHON_EXE" -c 'import fcntl; from importlib.metadata import version; print("HA import: " + version("homeassistant"))'
|
||||
"$PYTHON_EXE" -m pytest tests_backend/test_ha_setup.py -q
|
||||
|
||||
echo "visual capture: panel-wide-view-light-en"
|
||||
npm run build
|
||||
node scripts/bundle-sync.mjs
|
||||
node demo/golden/run.mjs --mode=capture --scenario=panel-wide-view-light-en
|
||||
CAPTURE="$PWD/artifacts/golden/actual/panel-wide-view-light-en.png"
|
||||
test -s "$CAPTURE"
|
||||
echo "capture: $CAPTURE ($(wc -c < "$CAPTURE") bytes)"
|
||||
fi
|
||||
|
||||
echo "WSL toolchain $MODE: $((SECONDS - START_SECONDS))s"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { compareToolchain, pinsFromSources } from '../scripts/toolchain-pins.mjs';
|
||||
import { compareToolchain, localToolchain, pinsFromSources } from '../scripts/toolchain-pins.mjs';
|
||||
|
||||
// #496: пины toolchain читаются из файлов CI, а не объявляются вторым словарём.
|
||||
// .nvmrc/.python-version — производные и обязаны совпадать.
|
||||
@@ -36,3 +36,60 @@ test('сравнение: мажор Node, minor Python, HA-стек точно
|
||||
assert.equal(bad.ok, false);
|
||||
assert.deepEqual(bad.failures, ['node', 'python', 'homeassistant']);
|
||||
});
|
||||
|
||||
test('#557 explicit Python owns both version and package probes; paths are visible', () => {
|
||||
const python = 'C:\\tools\\houseplan\\python.exe';
|
||||
const chromium = 'C:\\browser\\chrome.exe';
|
||||
const calls = [];
|
||||
const exec = (command, args) => {
|
||||
calls.push([command, args]);
|
||||
if (command === python && args.includes('-c')) return `3.14.7\n${python}`;
|
||||
if (command === python && args.at(-1) === 'homeassistant') return 'Name: homeassistant\nVersion: 2026.8.3';
|
||||
if (command === python && args.at(-1) === 'pytest-homeassistant-custom-component') {
|
||||
return 'Name: pytest-homeassistant-custom-component\nVersion: 0.13.357';
|
||||
}
|
||||
if (command === process.execPath) return chromium;
|
||||
return null;
|
||||
};
|
||||
|
||||
const local = localToolchain({ exec, pythonCommand: python });
|
||||
assert.equal(local.python, '3.14.7');
|
||||
assert.equal(local.pythonPath, python);
|
||||
assert.equal(local.homeassistant, '2026.8.3');
|
||||
assert.equal(local.pytestHomeAssistant, '0.13.357');
|
||||
assert.ok(calls.filter(([command]) => command === python).every(([, args]) => !args.includes('--version')));
|
||||
assert.equal(calls.some(([command]) => ['python', 'python3', 'py'].includes(command)), false,
|
||||
'an explicit venv must never fall back to an accidental PATH Python');
|
||||
|
||||
const pins = {
|
||||
node: process.versions.node.split('.')[0], python: '3.14',
|
||||
homeassistant: '2026.8.3', pytestHomeAssistant: '0.13.357', playwright: local.playwright,
|
||||
chromium: { version: '140.0.0.0', revision: '1234' },
|
||||
};
|
||||
const compared = compareToolchain(pins, { ...local, chromiumExists: true });
|
||||
assert.equal(compared.ok, true);
|
||||
assert.ok(compared.lines.some((line) => line.includes(process.execPath)));
|
||||
assert.ok(compared.lines.some((line) => line.includes(python)));
|
||||
assert.ok(compared.lines.some((line) => line.includes(local.playwrightPath)));
|
||||
assert.ok(compared.lines.some((line) => line.includes(chromium)));
|
||||
});
|
||||
|
||||
test('#557 setup entrypoints are pinned, non-destructive and exercise Linux HA plus capture', () => {
|
||||
const windows = readFileSync(new URL('../scripts/windows-toolchain.ps1', import.meta.url), 'utf8');
|
||||
const linux = readFileSync(new URL('../scripts/wsl-setup.sh', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(windows, /Get-Content[^\n]+[.]nvmrc/);
|
||||
assert.match(windows, /Get-Content[^\n]+[.]python-version/);
|
||||
assert.match(windows, /Get-FileHash[^\n]+SHA256/);
|
||||
assert.match(windows, /\$env:PATH = "\$\(\$runtime[.]NodeDirectory\);\$oldPath"/);
|
||||
assert.doesNotMatch(windows, /setx|SetEnvironmentVariable|Remove-Item[^\n]+ResolvedVenv/i);
|
||||
assert.match(linux, /HOUSEPLAN_VENV:-[.]venv-ci/);
|
||||
assert.match(linux, /PY_PIN=.*[.]python-version/);
|
||||
assert.match(linux, /PROFILE=\/dev\/null bash/);
|
||||
assert.match(linux, /UV_NO_MODIFY_PATH=1 sh/);
|
||||
assert.doesNotMatch(linux, /export PATH=.*[.]local\/bin/);
|
||||
assert.match(linux, /toolchain-pins[.]mjs --check --python/);
|
||||
assert.match(linux, /tests_backend\/test_ha_setup[.]py/);
|
||||
assert.match(linux, /--scenario=panel-wide-view-light-en/);
|
||||
assert.doesNotMatch(linux, /NODE_PIN=22|PY_PIN=3[.]14/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user