Merge pull request #2 from avalon-vanguard/claude/project-development-ehm7mw
Give the repository a way to actually run itself
This commit is contained in:
@@ -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 <http://localhost:4300>. 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`. |
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user