Skip to content

Commit 79e1007

Browse files
frenchie4111claude
andauthored
ci: headless server smoke test on every PR (#70)
* ci: headless server smoke test on every PR Launches dist-headless/main/index.js on an ephemeral port, parses the [web-client] URL out of stdout, then verifies four things: 1. web client root serves 200 with the expected HTML marker 2. auth gate rejects requests without ?token= 3. a WebSocket client can connect with the token 4. SIGTERM exits cleanly within 5s (no zombies) Catches the entire "the tarball can't even boot" class of bug — the recent agentjesus module-resolution regression and the earlier web-client-bundle-missing regression would both have failed here. Builds against dist-headless/, not the tarball; full tarball validation is a follow-up. Also fixes a related bug: the user-facing [web-client] / [ws-transport] log lines printed port 0 verbatim when --port 0 was passed, even though the server was actually listening on the OS-assigned ephemeral port. The smoke test depends on the printed URL being correct, so resolve the real port via webHttpServer.address() in the listen callback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: extract smoke test into scripts/smoke-headless.sh The smoke logic was an 85-line shell block inlined in ci.yml — a pain to edit (no syntax highlighting, awkward to run locally, hard to diff) and overlapped with the existing scripts/web-smoke.mjs + scripts/ws-smoke.mjs which already do HTTP + WS validation. Move it into scripts/smoke-headless.sh as a thin orchestrator: launch the server, parse the URL out of the log, delegate to the existing .mjs smokes for HTTP + WS, then SIGTERM + clean-shutdown check. CI step shrinks to `bash scripts/smoke-headless.sh`. Local repro is now a one-liner: npm run build:headless && bash scripts/smoke-headless.sh Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bed4608 commit 79e1007

4 files changed

Lines changed: 110 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,20 @@ jobs:
4040

4141
- name: Tests
4242
run: npx vitest run
43+
44+
# The headless server bundle is built separately from the Electron
45+
# build above (different vite config, different output dir). Builds
46+
# both `dist-headless/main` and `dist-headless/web-client`.
47+
- name: Build headless server
48+
run: npm run build:headless
49+
50+
# End-to-end smoke test: launches the freshly-built headless server
51+
# on an ephemeral port, verifies it serves the web client, rejects
52+
# unauthenticated requests, accepts a WebSocket client with the
53+
# token, and exits cleanly on SIGTERM. Catches the class of bugs
54+
# where the tarball's module layout / chunk resolution / auth gate
55+
# is broken — bugs that previously only surfaced after a tag-push
56+
# release. Logic lives in scripts/smoke-headless.sh so it's
57+
# runnable locally and not buried in YAML.
58+
- name: Headless smoke test
59+
run: bash scripts/smoke-headless.sh

CLAUDE.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,19 @@ Linux release builds now produce both `.deb` (Ubuntu/Debian) and
510510
`.AppImage` (every distro) — both attached to the GitHub release
511511
automatically by `.github/workflows/build-linux.yml` on tag push.
512512

513+
### Headless smoke test on every PR
514+
515+
PR CI (`.github/workflows/ci.yml`) runs `scripts/smoke-headless.sh`
516+
after the typecheck / build / tests block. The script launches
517+
`dist-headless/main/index.js` on an ephemeral port, parses the
518+
`[web-client] open ...` URL out of its stdout, delegates HTTP
519+
validation to `scripts/web-smoke.mjs` (auth gate + HTML + asset
520+
reach) and WS validation to `scripts/ws-smoke.mjs` (upgrade +
521+
snapshot round-trip), then SIGTERMs and confirms clean shutdown.
522+
Catches tarball-layout / module-resolution / boot-time regressions
523+
before they ride a tag push to release. Run locally:
524+
`npm run build:headless && bash scripts/smoke-headless.sh`.
525+
513526
### Headless tarballs
514527

515528
The `Headless Release` workflow (`.github/workflows/headless-release.yml`)

scripts/smoke-headless.sh

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
#!/usr/bin/env bash
2+
# End-to-end smoke test for the headless server.
3+
#
4+
# Launches dist-headless/main/index.js on an ephemeral port + localhost,
5+
# parses the [web-client] URL out of stdout, then delegates to
6+
# scripts/web-smoke.mjs and scripts/ws-smoke.mjs for HTTP + WS
7+
# validation. Finally SIGTERMs the server and confirms it exits within
8+
# 5s (no zombies).
9+
#
10+
# Run locally: npm run build:headless && bash scripts/smoke-headless.sh
11+
# Run in CI: same — invoked from .github/workflows/ci.yml.
12+
#
13+
# Exit codes: 0 = all checks passed; non-zero = a check failed (the
14+
# server log is dumped to stderr on URL-parse failure).
15+
16+
set -euo pipefail
17+
18+
# Isolated data dir so we don't touch ~/.harness on a dev box or the
19+
# runner's $HOME in CI.
20+
LOG="${HARNESS_SMOKE_LOG:-/tmp/harness-server.log}"
21+
HARNESS_DATA_DIR="$(mktemp -d)"
22+
export HARNESS_DATA_DIR
23+
24+
node dist-headless/main/index.js --port 0 --host 127.0.0.1 > "$LOG" 2>&1 &
25+
SERVER_PID=$!
26+
trap 'kill "$SERVER_PID" 2>/dev/null || true; wait "$SERVER_PID" 2>/dev/null || true' EXIT
27+
28+
# Wait up to 15s for the URL line. Format is
29+
# "[web-client] open http://127.0.0.1:<port>/?token=<token>"
30+
# emitted from src/main/index.ts in the webHttpServer.listen callback.
31+
# If the log shape changes, this fails loudly (which is what we want,
32+
# since the Settings UI and other tooling read the same line).
33+
URL=""
34+
for _ in $(seq 1 75); do
35+
URL="$(grep -oE 'http://127\.0\.0\.1:[0-9]+/\?token=[a-f0-9]+' "$LOG" | head -1 || true)"
36+
if [ -n "$URL" ]; then break; fi
37+
sleep 0.2
38+
done
39+
if [ -z "$URL" ]; then
40+
echo "::error::headless server did not advertise a URL within 15s" >&2
41+
echo "--- server log ---" >&2
42+
cat "$LOG" >&2
43+
exit 1
44+
fi
45+
echo "server up at $URL"
46+
47+
# Split URL into host:port + token for the existing smoke scripts.
48+
# URL shape is fixed (http://127.0.0.1:<port>/?token=<hex>), no need
49+
# for a real URL parser.
50+
HOST_PORT="${URL#http://}"
51+
HOST_PORT="${HOST_PORT%%/*}"
52+
TOKEN="${URL##*token=}"
53+
PORT="${HOST_PORT##*:}"
54+
55+
# 1+2. web-client HTTP: auth gate + HTML + asset reachability.
56+
node scripts/web-smoke.mjs "$HOST_PORT" "$TOKEN"
57+
58+
# 3. WS upgrade + snapshot round-trip.
59+
node scripts/ws-smoke.mjs "$TOKEN" "$PORT"
60+
61+
# 4. Clean shutdown — SIGTERM should exit within 5s. Catches "server
62+
# hangs on SIGTERM" bugs that would leave zombies in CI.
63+
kill -TERM "$SERVER_PID"
64+
for _ in $(seq 1 25); do
65+
if ! kill -0 "$SERVER_PID" 2>/dev/null; then break; fi
66+
sleep 0.2
67+
done
68+
if kill -0 "$SERVER_PID" 2>/dev/null; then
69+
echo "::error::server did not exit on SIGTERM within 5s" >&2
70+
kill -9 "$SERVER_PID" || true
71+
exit 1
72+
fi
73+
echo "clean shutdown OK"
74+
trap - EXIT

src/main/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -415,16 +415,20 @@ if (webHttpServer && wsTransport) {
415415
})
416416
webHttpServer.listen(wsPort, wsHost, () => {
417417
const displayHost = wsHost === '0.0.0.0' ? getLanHost() : wsHost
418+
// Resolve the actual listening port — when wsPort is 0 (ephemeral),
419+
// address() is the only place the real port surfaces.
420+
const addr = webHttpServer.address()
421+
const boundPort = addr && typeof addr === 'object' ? addr.port : wsPort
418422
// Log to stdout so the user can paste the URL into another browser
419423
// without digging through the debug log. TODO(production): expose
420424
// through a Settings UI screen with a copy button + regenerate action.
421425
// eslint-disable-next-line no-console
422426
console.log(
423-
`[ws-transport] enabled on ws://${displayHost}:${wsPort}?token=${wsTransport.getToken()} (bind=${wsHost})`
427+
`[ws-transport] enabled on ws://${displayHost}:${boundPort}?token=${wsTransport.getToken()} (bind=${wsHost})`
424428
)
425429
// eslint-disable-next-line no-console
426430
console.log(
427-
`[web-client] open http://${displayHost}:${wsPort}/?token=${wsTransport.getToken()}`
431+
`[web-client] open http://${displayHost}:${boundPort}/?token=${wsTransport.getToken()}`
428432
)
429433
})
430434
}

0 commit comments

Comments
 (0)