From c5ec7529614b9629e670d4c9b7d1b95e84e0ea27 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 15:45:03 +0000 Subject: [PATCH] Give the repository a way to actually run itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in the repo explains how to launch this app in a headless container, and three separate things stop it from starting — none of them discoverable without hitting each in turn. The container's default node is v22.22.2, one patch below the floor `engines` declares. A satisfying build sits at /opt/node (v22.23.2), but PATH finds /opt/node22 first, and the names invert what you would guess. `ng serve` reports only the version and exits. The pinned Playwright wants a Chromium revision this image does not carry, under a directory layout the older build does not use, so `npm run e2e` cannot launch a browser at all until the expected path is shimmed. Downloads are blocked, so `playwright install` is not the answer. And the scene is software-rasterized here: entering a system takes a camera flight that has to be waited on rather than slept through, a single canvas click lands before picking is wired, and a screenshot taken the moment a DOM assertion passes catches a half-drawn frame. driver.mjs handles all of it — picks a node satisfying `engines`, owns the dev server, drives the camera to each scale, and either screenshots or dumps the HUD's labels as JSON for checking a change without eyeballing a picture. SKILL.md documents only commands that were run here, and records the traps in Gotchas, including that no `pkill -f` is safe for stopping the server: it matches your own command line and kills the shell. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G --- .claude/skills/run-star-map/SKILL.md | 190 ++++++++++++++++++ .claude/skills/run-star-map/driver.mjs | 261 +++++++++++++++++++++++++ 2 files changed, 451 insertions(+) create mode 100644 .claude/skills/run-star-map/SKILL.md create mode 100644 .claude/skills/run-star-map/driver.mjs diff --git a/.claude/skills/run-star-map/SKILL.md b/.claude/skills/run-star-map/SKILL.md new file mode 100644 index 0000000..7419616 --- /dev/null +++ b/.claude/skills/run-star-map/SKILL.md @@ -0,0 +1,190 @@ +--- +name: run-star-map +description: Build, launch, run, drive, and screenshot the star-map Angular app in this container. Use when asked to run or start the app, take a screenshot of the map or HUD, check a UI change in a real browser, or run the unit/e2e test suites. +--- + +# Running star-map + +An Angular 3D star map (three.js, WebGPU renderer falling back to WebGL2). There is no GPU here, +so the scene is software-rasterized and slow — but it does run, and it screenshots. + +Drive it with the committed driver, which owns the dev server, the browser, and the camera flights: + +``` +.claude/skills/run-star-map/driver.mjs +``` + +All paths below are relative to the repo root. + +## Prerequisites + +Two container quirks have to be handled first. **Both are one-liners, and both are silent +footguns if skipped** — see Gotchas for why. + +**1. Node.** The default `node` on `PATH` is *below* the floor the Angular CLI enforces. A +satisfying build is at `/opt/node`. The driver picks it automatically; anything you run by hand +needs the prefix: + +```bash +node -v # v22.22.2 ← too old, ng refuses to start +/opt/node/bin/node -v # v22.23.2 ← use this +export PATH=/opt/node/bin:$PATH +``` + +**2. Playwright browser.** The pinned `@playwright/test` (1.62.1) wants Chromium revision 1234; +this container ships 1194, under a different internal layout. Do **not** run +`npx playwright install` (the environment blocks browser downloads). Shim the expected path +instead — needed only for `npm run e2e`, not for the driver: + +```bash +mkdir -p /opt/pw-browsers/chromium_headless_shell-1234/chrome-headless-shell-linux64 +ln -sfn /opt/pw-browsers/chromium_headless_shell-1194/chrome-linux/headless_shell \ + /opt/pw-browsers/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell +touch /opt/pw-browsers/chromium_headless_shell-1234/INSTALLATION_COMPLETE +``` + +## Install + +```bash +PATH=/opt/node/bin:$PATH npm ci # ~25s +``` + +## Run (agent path) — the driver + +Takes screenshots of the app at each scale. Starts the dev server on port 4300 if one isn't +already up, and stops it on exit. + +```bash +node .claude/skills/run-star-map/driver.mjs tour +``` + +``` +starting dev server on http://localhost:4300 (node v22.23.2) +dev server ready +field: + /tmp/star-map-shots/1-star-field.png (level=Solar Neighbourhood) +galaxy: + /tmp/star-map-shots/2-galactic.png (level=Milky Way) +system: + /tmp/star-map-shots/3-system-sol.png (level=System) +inner: + zoomed to 5.53 AU + /tmp/star-map-shots/4-system-inner.png (level=System) +``` + +`tour` takes **~3m20s** — most of it camera flights and software rasterization. For one view: + +```bash +node .claude/skills/run-star-map/driver.mjs shot galaxy # ~1m +node .claude/skills/run-star-map/driver.mjs shot field system +``` + +| View | What it shows | +|---|---| +| `field` | Solar-neighbourhood star field — the default landing view | +| `galaxy` | Galactic scale: arms, bar, Sol's position | +| `system` | Sol at ~120 AU, outer planets labelled | +| `inner` | Sol zoomed to <6 AU, inner four planets labelled | + +**Look at the PNG you produced.** A black frame means the scene never initialized — that is a +failure, not a dark theme. + +Flags: `--out=DIR` (default `/tmp/star-map-shots`), `--stars=N` (default 12000), `--port=N` +(default 4300), `--keep-server`, `--headed`. + +### Checking HUD state without screenshots + +Faster than eyeballing a picture, and the right tool for label/HUD changes: + +```bash +node .claude/skills/run-star-map/driver.mjs probe inner +``` + +```json +{ + "title": "Sol", + "level": "System", + "labelCount": 4, + "labels": [ + { "name": "Mars", "kind": "Planet" }, + { "name": "Earth", "kind": "Planet" }, + { "name": "Mercury", "kind": "Planet" }, + { "name": "Venus", "kind": "Planet" } + ] +} +``` + +`probe` accepts the same four view names. Labels are read from the CSS2D layer +(`.map-label` > `.map-label-name` + `.map-label-kind`). + +## Run (human path) + +```bash +PATH=/opt/node/bin:$PATH npm start -- --port=4300 +``` + +Serves on . Useless headless on its own — there is no display to look at. +Use it only when you want a long-lived server for the driver to attach to (the driver reuses an +already-running server and leaves it alone on exit). + +## Test + +```bash +PATH=/opt/node/bin:$PATH npm test # 496 tests, 28 files, ~5s +PATH=/opt/node/bin:$PATH npm run build # ~9s +PATH=/opt/node/bin:$PATH npm run etl:typecheck +PATH=/opt/node/bin:$PATH npm run e2e:typecheck +PATH=/opt/node/bin:$PATH npm run e2e -- --workers=1 # 6 tests, ~2.3m +``` + +`--workers=1` on the e2e suite is **not optional here** — see Gotchas. + +## Gotchas + +- **The wrong Node is first on `PATH`.** `/opt/node22` is v22.22.2; `/opt/node` is v22.23.2. + `PATH` finds the *older* one, and `package.json#engines` requires `^22.22.3`. `ng serve` then + dies with "Node.js version v22.22.2 detected" and nothing else. The names invert what you'd + guess — `/opt/node` is newer than `/opt/node22`. +- **`npm run e2e` fails at full parallelism**, passing 4 of 6. The two camera-flight specs time + out when four browsers share a software rasterizer. The same specs pass alone and pass with + `--workers=1`. It is CPU contention, not a broken test — don't "fix" the specs. +- **Never stop the dev server with `pkill -f`.** It matches against every process's full command + line — including the shell running your own `pkill`, which then kills itself (exit 144). Narrowing + the pattern does not save you, and neither does the `[n]g` bracket trick: the match is against the + *whole* command line, so a literal `ng serve` anywhere else in it — inside an `echo`, a comment, a + later `pgrep` — re-arms the self-match. Kill by port instead, which does no pattern matching at all: + + ```bash + fuser -k 4300/tcp + ``` + + Or just let the driver clean up after itself, which it does unless you pass `--keep-server`. +- **The star count must be cut for anything interactive.** The full catalogue is 68 388 stars and + runs at roughly 1 fps here. `?stars=N` overrides it; the driver defaults to 12 000. +- **One canvas click is not enough to enter a system.** Bootstrap (data fetch + raycaster wiring) + finishes asynchronously after load, so an early click hits nothing. The driver polls + click-until-`hud-current-level`-reads-`System`. Sol is at the origin, dead centre of the default + camera, so the centre click is what reliably works. +- **Screenshots need a settle delay.** A DOM assertion passing does not mean the frame is drawn; + the driver waits 2.5s before every capture. Without it you get half-rendered scenes. +- **At 120 AU the inner planets have no labels.** They are inside the centre reticle, collapsed to + a few pixels, and correctly suppressed. This is not a regression — zoom below ~6 AU (the `inner` + view) to see Mercury/Venus/Earth/Mars labelled. +- **Port 4300, not Angular's usual 4200** — the project's own convention, set in + `playwright.config.ts` so it never collides with an unrelated `ng serve`. +- **Don't write driver scripts in `.ts` outside the repo.** `tsx` compiles a bare `.ts` as CJS + ("Top-level await is currently not supported with the cjs output format"), and a script outside + the repo can't resolve `@playwright/test` at all. The driver is `.mjs` inside the repo for both + reasons. + +## Troubleshooting + +| Symptom | Fix | +|---|---| +| `Node.js version v22.22.2 detected. The Angular CLI requires...` | `export PATH=/opt/node/bin:$PATH` | +| `browserType.launch: Executable doesn't exist at .../chromium_headless_shell-1234/...` | Apply the browser shim in Prerequisites. Do **not** run `npx playwright install`. | +| e2e: 2 specs time out on camera flights | Add `-- --workers=1` | +| Your shell exits with code 144 while cleaning up | A `pkill -f` matched your own command line. Use `fuser -k 4300/tcp`. | +| Screenshot is entirely black | Scene never initialized — check the driver's `[pageerror]` lines. | +| `Top-level await is currently not supported with the "cjs" output format` | Name the script `.mjs` (or `.mts`) and keep it inside the repo. | +| Driver hangs at "starting dev server" | Another server holds port 4300: `fuser -k 4300/tcp`, or pass `--port=4301`. | diff --git a/.claude/skills/run-star-map/driver.mjs b/.claude/skills/run-star-map/driver.mjs new file mode 100644 index 0000000..21b6263 --- /dev/null +++ b/.claude/skills/run-star-map/driver.mjs @@ -0,0 +1,261 @@ +#!/usr/bin/env node +/** + * driver.mjs — launch the star-map dev server and drive the running app in a real browser. + * + * node .claude/skills/run-star-map/driver.mjs tour + * node .claude/skills/run-star-map/driver.mjs shot galaxy system + * node .claude/skills/run-star-map/driver.mjs probe + * + * Flags: --out=DIR (default /tmp/star-map-shots) --stars=N (default 12000) + * --port=N (default 4300) --keep-server --headed + * + * Why this exists rather than `npm start` + look at a window: the container is headless, the + * scene is WebGL rendered by a software rasterizer, and the app only reaches its interesting + * states (system view, galactic scale) after multi-second camera flights that have to be waited + * on rather than slept through. See SKILL.md. + */ +import { execFileSync, spawn } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium, expect } from '@playwright/test'; + +const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); + +const argv = process.argv.slice(2); +const flag = (name, fallback) => { + const hit = argv.find((a) => a.startsWith(`--${name}=`)); + return hit ? hit.slice(name.length + 3) : fallback; +}; +const has = (name) => argv.includes(`--${name}`); + +const OUT = flag('out', '/tmp/star-map-shots'); +const STARS = flag('stars', '12000'); +const PORT = Number(flag('port', '4300')); +const BASE = `http://localhost:${PORT}`; +const command = argv.find((a) => !a.startsWith('--')) ?? 'tour'; +const requested = argv.filter((a) => !a.startsWith('--')).slice(1); + +/* ── Node selection ────────────────────────────────────────────────────────────────────────── + * The container's default `node` is a hair below the floor the Angular CLI enforces, and the + * satisfying build is NOT the one PATH finds first. Pick a binary that actually satisfies + * package.json#engines rather than trusting `node`. */ + +const satisfies = (version, range) => { + const [maj, min, pat] = version.replace(/^v/, '').split('.').map(Number); + return range.split('||').some((clause) => { + const m = clause.trim().match(/^(\^|>=)?(\d+)\.(\d+)\.(\d+)$/); + if (!m) return false; + const [, op, cMaj, cMin, cPat] = [m[0], m[1], Number(m[2]), Number(m[3]), Number(m[4])]; + const atLeast = maj > cMaj || (maj === cMaj && (min > cMin || (min === cMin && pat >= cPat))); + return op === '^' ? maj === cMaj && atLeast : atLeast; + }); +}; + +const pickNode = () => { + const range = JSON.parse(readFileSync(`${REPO}/package.json`, 'utf8')).engines?.node ?? ''; + const candidates = [process.execPath, '/opt/node/bin/node', '/usr/local/bin/node', 'node']; + for (const bin of candidates) { + try { + const v = execFileSync(bin, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); + if (v && satisfies(v.trim(), range)) return { bin, version: v.trim() }; + } catch { + /* candidate absent — try the next */ + } + } + throw new Error(`no node satisfying engines "${range}" found; tried ${candidates.join(', ')}`); +}; + +/* ── Dev server ─────────────────────────────────────────────────────────────────────────────*/ + +const up = async () => { + try { + const res = await fetch(BASE, { signal: AbortSignal.timeout(2000) }); + return res.ok; + } catch { + return false; + } +}; + +async function startServer() { + if (await up()) { + console.log(`dev server already up on ${BASE}`); + return null; + } + const { bin, version } = pickNode(); + console.log(`starting dev server on ${BASE} (node ${version})`); + const proc = spawn(bin, [`${REPO}/node_modules/.bin/ng`, 'serve', `--port=${PORT}`], { + cwd: REPO, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, PATH: `${dirname(bin)}:${process.env.PATH}` } + }); + let log = ''; + proc.stdout.on('data', (d) => (log += d)); + proc.stderr.on('data', (d) => (log += d)); + proc.on('exit', (code) => { + if (code !== 0 && code !== null) { + console.error(`dev server exited with ${code}:\n${log.split('\n').slice(-12).join('\n')}`); + } + }); + + const deadline = Date.now() + 180_000; + while (Date.now() < deadline) { + if (await up()) { + console.log('dev server ready'); + return proc; + } + if (proc.exitCode !== null) throw new Error(`dev server died:\n${log.slice(-2000)}`); + await new Promise((r) => setTimeout(r, 1500)); + } + throw new Error(`dev server did not come up within 180s:\n${log.slice(-2000)}`); +} + +/* ── Browser ────────────────────────────────────────────────────────────────────────────────*/ + +// The pinned @playwright/test does not necessarily match the browser revision baked into the +// container, so prefer the one that is actually on disk over the one Playwright expects. +const executablePath = existsSync('/opt/pw-browsers/chromium') ? '/opt/pw-browsers/chromium' : undefined; + +const level = (page) => page.getByTestId('hud-current-level'); +const title = (page) => page.getByTestId('hud-title'); +const canvasOf = (page) => page.getByTestId('scene-canvas'); + +async function boot(page) { + await page.goto(`${BASE}/?stars=${STARS}`); + await expect(canvasOf(page)).toBeVisible({ timeout: 90_000 }); + await expect(title(page)).toHaveText('Local Stars', { timeout: 90_000 }); +} + +/** Screenshots are taken after a settle delay: a software-rasterized frame is usually still in + * flight when the DOM assertion that got us here has already passed. */ +async function capture(page, name) { + mkdirSync(OUT, { recursive: true }); + await page.waitForTimeout(2500); + const path = `${OUT}/${name}.png`; + await page.screenshot({ path }); + console.log(` ${path} (level=${(await level(page).textContent())?.trim()})`); +} + +/** Bootstrap (data fetch + raycaster wiring) finishes asynchronously, so clicking the canvas + * once usually lands before picking is live. Sol sits at the origin, dead centre of the + * default camera, so click-until-entered reliably enters the solar system. */ +async function enterSystem(page) { + const canvas = canvasOf(page); + await expect + .poll( + async () => { + if ((await level(page).textContent())?.trim() === 'System') return true; + await canvas.click(); + return false; + }, + { timeout: 90_000, intervals: [400] } + ) + .toBe(true); + await page.waitForTimeout(1500); +} + +/** Zoom until the range readout drops to `auTarget`, so the inner planets fill the frame. */ +async function zoomTo(page, auTarget) { + const range = page.getByText(/\d+(\.\d+)? AU/); + await page.mouse.move(800, 450); + for (let i = 0; i < 40; i++) { + await page.mouse.wheel(0, -240); + await page.waitForTimeout(320); + const au = Number(((await range.textContent().catch(() => null)) ?? '').replace(/[^\d.]/g, '')); + if (Number.isFinite(au) && au > 0 && au <= auTarget) return au; + } + return null; +} + +const VIEWS = { + field: async (page) => { + await boot(page); + await capture(page, '1-star-field'); + }, + galaxy: async (page) => { + await boot(page); + await page.getByRole('button', { name: 'Milky Way' }).click(); + await expect(page.getByText('Galactic Scale')).toBeVisible({ timeout: 90_000 }); + await capture(page, '2-galactic'); + }, + system: async (page) => { + await boot(page); + await enterSystem(page); + await capture(page, '3-system-sol'); + }, + inner: async (page) => { + await boot(page); + await enterSystem(page); + const au = await zoomTo(page, 6); + console.log(` zoomed to ${au ?? '?'} AU`); + await capture(page, '4-system-inner'); + } +}; + +/* ── Commands ───────────────────────────────────────────────────────────────────────────────*/ + +/** + * Dump the HUD's state as JSON instead of a picture — the fast way to check a label/HUD change + * without eyeballing a screenshot. `probe system` reports the labels the overlay actually + * placed, which is what most HUD regressions show up in. + */ +async function probe(page, view) { + await boot(page); + if (view === 'system' || view === 'inner') await enterSystem(page); + if (view === 'inner') await zoomTo(page, 6); + if (view === 'galaxy') { + await page.getByRole('button', { name: 'Milky Way' }).click(); + await expect(page.getByText('Galactic Scale')).toBeVisible({ timeout: 90_000 }); + } + await page.waitForTimeout(2000); + + // Labels are plain DOM in a CSS2D layer: .map-label > .map-label-name + .map-label-kind. + const labels = await page.locator('.map-label').evaluateAll((nodes) => + nodes + .filter((n) => n.offsetParent !== null) + .map((n) => ({ + name: n.querySelector('.map-label-name')?.textContent?.trim() ?? '', + kind: n.querySelector('.map-label-kind')?.textContent?.trim() ?? null + })) + ); + console.log( + JSON.stringify( + { + title: (await title(page).textContent())?.trim(), + level: (await level(page).textContent())?.trim(), + labelCount: labels.length, + labels + }, + null, + 2 + ) + ); +} + +let server = null; +let failed = false; +try { + server = await startServer(); + const browser = await chromium.launch({ executablePath, headless: !has('headed') }); + const page = await browser.newPage({ viewport: { width: 1600, height: 900 } }); + page.on('pageerror', (e) => console.log(` [pageerror] ${e.message}`)); + + if (command === 'probe') { + await probe(page, requested[0] ?? 'field'); + } else { + const names = command === 'shot' && requested.length ? requested : Object.keys(VIEWS); + for (const name of names) { + if (!VIEWS[name]) throw new Error(`unknown view "${name}" (have: ${Object.keys(VIEWS).join(', ')})`); + console.log(`${name}:`); + await VIEWS[name](page); + } + } + await browser.close(); +} catch (err) { + failed = true; + console.error(`FAILED: ${err.message}`); +} finally { + if (server && !has('keep-server')) server.kill('SIGTERM'); +} +process.exit(failed ? 1 : 0);