Merge pull request 'feat(scripts): switch Node automatically, and add a test runner (#140)' (#144) from feature/140-node-scripts into main
Linting / lint (push) Successful in 1m53s
SonarQube Analysis / sonarqube (push) Successful in 17m35s

Reviewed-on: #144
This commit was merged in pull request #144.
This commit is contained in:
2026-08-23 09:36:40 -05:00
4 changed files with 374 additions and 29 deletions
+22 -1
View File
@@ -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
.\scriptsun-tests.ps1 -Suite unit
.\scriptsun-tests.ps1 -Suite integration
.\scriptsun-tests.ps1 -Suite e2e
.\scriptsun-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 14 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 14 above, or `.\scripts\start-local.ps1`), since these tests drive real registration/login/purchase flows through a live API.
```powershell
cd frontend
+140
View File
@@ -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)" }
}
}
+184
View File
@@ -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
}
+28 -28
View File
@@ -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 ''