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:
Sergey Matyunin
2026-09-13 06:05:45 +00:00
committed by claude[bot]
parent eb77224e0c
commit c1c3743f5d
9 changed files with 444 additions and 68 deletions
+9 -3
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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 |
+18
View File
@@ -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 всех
+1
View File
@@ -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
View File
@@ -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'
+178
View File
@@ -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
View File
@@ -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"
+58 -1
View File
@@ -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/);
});