diff --git a/README.md b/README.md index aab7e55..2b64e98 100755 --- a/README.md +++ b/README.md @@ -24,6 +24,27 @@ cd redefined-designs Commands below are shown for **PowerShell** (Windows). A bash equivalent is noted wherever the syntax differs. +### The short way + +`scripts/start-local.ps1` does everything in this section — Postgres, migrations, the backend and the dev server — and `scripts/run-tests.ps1` runs the suites. The step-by-step instructions below are still accurate, and are what to reach for when something needs doing differently. + +```powershell +.\scripts\start-local.ps1 # bring the whole stack up +.\scripts\start-local.ps1 -Fresh # ...from an empty database +.\scripts\start-local.ps1 -Stop # stop everything + +.\scripts un-tests.ps1 -Suite unit +.\scripts un-tests.ps1 -Suite integration +.\scripts un-tests.ps1 -Suite e2e +.\scripts un-tests.ps1 -Suite all +``` + +Both scripts run `nvm use latest` first and verify the result is Node 20 or newer, then put the machine back to 18.16.1 when they finish — including when they fail partway, so an interrupted run does not leave the version switched. **`nvm use` rewrites a machine-global symlink, so this changes the Node version for every terminal on the machine while a script is running, not only the one you ran it in.** Both scripts say so as they do it. + +The Node 20 floor is not arbitrary: `node-pg-migrate` pulls in an `lru-cache` that calls `diagnostics_channel.tracingChannel()`, which does not exist before Node 19.9. On Node 18 migrations die inside minified library code with `(0 , U.tracingChannel) is not a function`, which says nothing about versions. + +`run-tests.ps1` brings up whatever a suite needs: the integration suite gets its own throwaway Postgres, started and stopped around the run (`-KeepTestDb` leaves it up, `-TestDbPort` moves it if the default is taken or Hyper-V has reserved it). The e2e suite needs the app stack, so start it with `start-local.ps1` first — the script checks and says so rather than letting every spec fail on a refused connection. `-Filter` passes through to the runner to select tests by file or name. + ### 1. Start a local Postgres instance The backend needs Postgres to talk to during local development. `backend/docker-compose.test.yml` spins up a throwaway, tmpfs-backed instance — no data persists between restarts, which is fine for local dev and tests. @@ -155,7 +176,7 @@ npm run db:test:down # when finished ### Frontend Playwright e2e tests -Needs the backend running against a database with the schema loaded (steps 1–4 above), since these tests drive real registration/login/purchase flows through a live API. +Needs the backend running against a database with the schema loaded (steps 1–4 above, or `.\scripts\start-local.ps1`), since these tests drive real registration/login/purchase flows through a live API. ```powershell cd frontend diff --git a/scripts/NodeVersion.ps1 b/scripts/NodeVersion.ps1 new file mode 100644 index 0000000..819ac5e --- /dev/null +++ b/scripts/NodeVersion.ps1 @@ -0,0 +1,140 @@ +<# +.SYNOPSIS + Switching the machine's Node version, shared by start-local.ps1 and + run-tests.ps1. + +.DESCRIPTION + Both scripts need Node 20 or newer for the same reason: node-pg-migrate + pulls in an lru-cache that calls diagnostics_channel.tracingChannel(), which + does not exist before Node 19.9. On Node 18 the migration dies inside + minified library code with "(0 , U.tracingChannel) is not a function", a + message that says nothing about versions. ts-jest and Playwright are subject + to the same floor. + + Kept in one file because two copies of a version switch would drift, and the + half that drifts is the half nobody runs. + + nvm-windows rewrites a machine-global symlink (NVM_SYMLINK, typically + C:\nvm4w\nodejs) rather than changing one shell, so switching here changes + the Node version for every terminal on the machine. That is intended — the + point is to work in whatever shell is already open — but it is announced + rather than done quietly, and it is put back afterwards. +#> + +function Get-NodeVersionString { + return (node --version 2>$null) +} + +function Get-NodeMajor { + $raw = Get-NodeVersionString + if (-not $raw) { return 0 } + return [int](($raw -replace '^v', '') -split '\.')[0] +} + +<# + Switches, then checks what is actually running. + + nvm-windows exits 0 for switches that did not take: a version it cannot + find, a symlink it cannot rewrite without elevation, and — seen on this + machine — a rewrite immediately after another one, where the directory + symlink is briefly still the old target. So the result is verified rather + than trusted, and retried once, because reporting a version that is not the + one running is worse than not switching at all. + + nvm's own output is captured rather than discarded. Suppressing it hid the + only message that explained a failed switch. +#> +function Use-Node { + param( + [Parameter(Mandatory)][string]$Version, + [Parameter(Mandatory)][string]$Why, + [scriptblock]$Step, + [scriptblock]$Note + ) + + if ($Step) { & $Step "Switching Node to $Version ($Why)" } + if ($Note) { & $Note 'nvm changes the version for every shell on this machine, not just this one' } + + $output = $null + foreach ($attempt in 1..2) { + $output = (nvm use $Version 2>&1 | Out-String).Trim() + + $raw = Get-NodeVersionString + if ($raw) { + # `latest` is whatever nvm decided, so there is nothing to compare a + # version string against — the caller checks the major instead. + if ($Version -eq 'latest' -or $raw.TrimStart('v') -eq $Version.TrimStart('v')) { + if ($Note) { & $Note "node $raw" } + return $raw + } + } + + if ($attempt -eq 1) { + if ($Note) { & $Note 'the switch has not taken yet; retrying' } + Start-Sleep -Milliseconds 750 + } + } + + $running = Get-NodeVersionString + if (-not $running) { + throw "Node is not on PATH after 'nvm use $Version'. Check that nvm-windows is installed.`n`nnvm said:`n$output" + } + + throw @" +Asked nvm for Node $Version, but $running is still what runs. + +nvm said: +$output + +nvm-windows rewrites a symlink at $env:NVM_SYMLINK, and can report success +without having rewritten it. Check the version is installed, and that this shell +can write that link: + + nvm list +"@ +} + +<# + Switches to the newest installed version and insists it is new enough. + `nvm use latest` picks the highest version nvm has installed locally, which + can still be older than this project needs. +#> +function Use-NodeLatest { + param([scriptblock]$Step, [scriptblock]$Note) + + Use-Node -Version 'latest' -Why 'this project needs Node 20 or newer' -Step $Step -Note $Note | Out-Null + + $major = Get-NodeMajor + if ($major -lt 20) { + throw @" +Node is still v$major after 'nvm use latest'. This project needs Node 20 or newer. + +The newest version nvm has installed is too old. Install a newer one: + + nvm install 24.13.1 + +Installed versions: nvm list +"@ + } +} + +<# + Best effort by design. A failure to switch back must not mask the error that + got us here, nor turn a passing run into a failing one — but it must still + say so, because leaving the machine on the wrong version silently is how the + next confusing failure starts. +#> +function Restore-Node { + param( + [Parameter(Mandatory)][string]$Version, + [scriptblock]$Step, + [scriptblock]$Note + ) + + try { + Use-Node -Version $Version -Why 'restoring the machine default' -Step $Step -Note $Note | Out-Null + } + catch { + if ($Note) { & $Note "could not restore Node ${Version}: $($_.Exception.Message)" } + } +} diff --git a/scripts/run-tests.ps1 b/scripts/run-tests.ps1 new file mode 100644 index 0000000..1b735a9 --- /dev/null +++ b/scripts/run-tests.ps1 @@ -0,0 +1,184 @@ +#Requires -Version 7 +<# +.SYNOPSIS + Runs the project's test suites. + +.DESCRIPTION + One script rather than three, because the Node version switch, the test + database bring-up and the TEST_PGPORT handling are shared by more than one + suite and would otherwise be copied around and drift apart. + + Switches Node to the latest installed version for the run and puts the + machine default back afterwards, the same way start-local.ps1 does — and for + the same reason, since ts-jest and Playwright are subject to the same Node + 20 floor as the migrations. + +.PARAMETER Suite + unit Backend Jest unit tests. Needs nothing running. + integration Backend Jest integration tests against a disposable Postgres. + Brings the container up itself. + e2e Frontend Playwright tests. Needs the app stack up; start it + with .\scripts\start-local.ps1 first. + all All three, in that order — cheapest and most isolated first, + so a failure that a later suite would also show up in is + reported by the suite that localises it best. + +.PARAMETER TestDbPort + Host port for the integration suite's Postgres. Change it if something else + holds the default, or if Hyper-V has reserved it. + +.PARAMETER KeepTestDb + Leave the integration database running afterwards. Useful when re-running + the same suite repeatedly; it starts faster. + +.PARAMETER Filter + Passed through to the test runner to select tests by file or name. + +.EXAMPLE + .\scripts\run-tests.ps1 -Suite unit + .\scripts\run-tests.ps1 -Suite integration -TestDbPort 55600 + .\scripts\run-tests.ps1 -Suite e2e -Filter email-templates + .\scripts\run-tests.ps1 -Suite all +#> +[CmdletBinding()] +param( + [ValidateSet('unit', 'integration', 'e2e', 'all')] + [string]$Suite = 'all', + [int]$TestDbPort = 55432, + [switch]$KeepTestDb, + [string]$Filter, + [string]$DefaultNodeVersion = '18.16.1' +) + +$ErrorActionPreference = 'Stop' + +$RepoRoot = Split-Path -Parent $PSScriptRoot +$Backend = Join-Path $RepoRoot 'backend' +$Frontend = Join-Path $RepoRoot 'frontend' +$WebPort = 5173 + +function Write-Step { param([string]$Message) Write-Host "==> $Message" -ForegroundColor Cyan } +function Write-Note { param([string]$Message) Write-Host " $Message" -ForegroundColor DarkGray } +function Write-Good { param([string]$Message) Write-Host " $Message" -ForegroundColor Green } + +# $ErrorActionPreference = 'Stop' does not stop the script when a native +# executable exits non-zero, only when a cmdlet throws — and every runner here +# is npm. Without this a failing suite prints red and the script reports success. +function Invoke-Checked { + param([scriptblock]$Command, [string]$What) + & $Command + if ($LASTEXITCODE -ne 0) { + throw "$What failed (exit code $LASTEXITCODE)." + } +} + +. (Join-Path $PSScriptRoot 'NodeVersion.ps1') + +# Bound once so the shared switcher reports in this script's output style. +$NodeOut = @{ Step = ${function:Write-Step}; Note = ${function:Write-Note} } + +function Assert-Docker { + docker info *>$null + if ($LASTEXITCODE -ne 0) { + throw 'Docker is not running. Start Docker Desktop and try again.' + } +} + +function Invoke-UnitSuite { + Write-Step 'Backend unit tests' + Push-Location $Backend + try { + if ($Filter) { Invoke-Checked { npm run test:unit -- $Filter } 'Unit tests' } + else { Invoke-Checked { npm run test:unit } 'Unit tests' } + } + finally { Pop-Location } +} + +function Invoke-IntegrationSuite { + Assert-Docker + + # The suite's globalSetup connects on TEST_PGPORT and fails with a clear + # message if nothing answers, so the container has to be up first. Reusing a + # running one is safe: every test truncates in beforeEach. + Write-Step "Starting the test database on port $TestDbPort" + $env:TEST_PGPORT = "$TestDbPort" + + Push-Location $Backend + try { + Invoke-Checked { npm run db:test:up } 'Test database' + + Write-Step 'Backend integration tests' + try { + if ($Filter) { Invoke-Checked { npm run test:integration -- $Filter } 'Integration tests' } + else { Invoke-Checked { npm run test:integration } 'Integration tests' } + } + finally { + if ($KeepTestDb) { + Write-Note 'leaving the test database up (-KeepTestDb)' + } + else { + # In a finally so a failing suite still tidies up. The data is on + # tmpfs, so there is nothing to preserve for a post-mortem. + Write-Step 'Stopping the test database' + npm run db:test:down *>$null + } + } + } + finally { Pop-Location } +} + +function Invoke-E2eSuite { + # Playwright's own webServer starts the dev server, but the backend it talks + # to is not its job. Checked here rather than left to twenty-five specs + # failing on a connection refused, which does not say what is missing. + $backendUp = $false + try { + $null = Invoke-WebRequest 'http://localhost:3000/api/config' -TimeoutSec 2 -UseBasicParsing + $backendUp = $true + } + catch { $backendUp = $false } + + if (-not $backendUp) { + throw @" +No backend answering on http://localhost:3000/api/config. + +The end-to-end suite drives real registration, cart and checkout flows against a +running stack. Start it first: + + .\scripts\start-local.ps1 +"@ + } + Write-Good 'backend is up' + Write-Note "dev server on $WebPort is started by Playwright if it is not already running" + + Write-Step 'Frontend end-to-end tests' + Push-Location $Frontend + try { + if ($Filter) { Invoke-Checked { npx playwright test $Filter --project=chromium } 'End-to-end tests' } + else { Invoke-Checked { npx playwright test --project=chromium } 'End-to-end tests' } + } + finally { Pop-Location } +} + +Use-NodeLatest @NodeOut + +try { + switch ($Suite) { + 'unit' { Invoke-UnitSuite } + 'integration' { Invoke-IntegrationSuite } + 'e2e' { Invoke-E2eSuite } + 'all' { + # Cheapest and most isolated first, so a break that several suites + # would show is reported by the one that localises it best. + Invoke-UnitSuite + Invoke-IntegrationSuite + Invoke-E2eSuite + } + } + + Write-Host '' + Write-Host "Passed ($Suite)." -ForegroundColor Green +} +finally { + Restore-Node -Version $DefaultNodeVersion @NodeOut +} diff --git a/scripts/start-local.ps1 b/scripts/start-local.ps1 index 6aac5dd..738dc98 100644 --- a/scripts/start-local.ps1 +++ b/scripts/start-local.ps1 @@ -35,7 +35,11 @@ param( [int]$ApiPort = 3000, [int]$WebPort = 5173, [switch]$Fresh, - [switch]$Stop + [switch]$Stop, + # What -Stop puts the machine back to. nvm's default here is 18.16.1, which + # is too old to run this project's tooling but is what everything else on + # the machine expects. + [string]$DefaultNodeVersion = '18.16.1' ) $ErrorActionPreference = 'Stop' @@ -72,26 +76,11 @@ function Assert-Docker { } } -# node-pg-migrate pulls in an lru-cache that calls -# diagnostics_channel.tracingChannel, which does not exist before Node 20. On -# Node 18 the migration dies in minified library code with "(0 , U.tracingChannel) -# is not a function", which says nothing about versions. CI runs Node 20. -function Assert-NodeVersion { - $raw = (node --version) - $major = [int](($raw -replace '^v', '') -split '\.')[0] - if ($major -lt 20) { - throw @" -Node $raw is too old. This needs Node 20 or newer. +. (Join-Path $PSScriptRoot 'NodeVersion.ps1') -If you use nvm-windows: - - nvm use 24.13.1 - -Then run this script again in a new shell. -"@ - } - Write-Note "node $raw" -} +# Bound once so the shared switcher reports through this script's own output +# style rather than printing in a voice of its own. +$NodeOut = @{ Step = ${function:Write-Step}; Note = ${function:Write-Note} } # Reads back only the ids this script wrote. Killing by port would be shorter # and would also kill whatever else happened to be listening. @@ -277,16 +266,27 @@ New-Item -ItemType Directory -Force -Path $StateDir *>$null if ($Stop) { Stop-Environment + Restore-Node -Version $DefaultNodeVersion @NodeOut return } -Assert-NodeVersion -Assert-Docker -Start-Database -Install-IfMissing (Join-Path $RepoRoot 'backend') -Install-IfMissing (Join-Path $RepoRoot 'frontend') -Start-Backend -Start-Frontend +Use-NodeLatest @NodeOut + +# Anything after the switch reverts on the way out of a failure. Without this a +# run that dies in migrations leaves the machine on the new version with nothing +# started, and the -Stop that would put it back is never reached. +try { + Assert-Docker + Start-Database + Install-IfMissing (Join-Path $RepoRoot 'backend') + Install-IfMissing (Join-Path $RepoRoot 'frontend') + Start-Backend + Start-Frontend +} +catch { + Restore-Node -Version $DefaultNodeVersion @NodeOut + throw +} Write-Host '' Write-Host 'Running.' -ForegroundColor Green @@ -296,5 +296,5 @@ Write-Host " API http://localhost:$ApiPort/api/config" Write-Host " Postgres localhost:$DbPort ($DbUser / $DbPassword / $DbName)" Write-Host '' Write-Host " Logs $StateDir" -Write-Host " Stop .\scripts\start-local.ps1 -Stop" +Write-Host " Stop .\scripts\start-local.ps1 -Stop (also restores Node $DefaultNodeVersion)" Write-Host ''