From 33892e4e9ca2a0e1edc43a8de8608ab70287ecb2 Mon Sep 17 00:00:00 2001 From: "christian@katzmann.dk" Date: Tue, 2 Jun 2026 05:37:22 +0200 Subject: [PATCH] test: behavioral fixture suite + fix warm-reattach descendant walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add scripts/test-fixtures.sh + scripts/fixtures/ — a hermetic, CI-gated suite that drives the real launcher scripts against tiny project shapes (Vite, Next, static-export, Vite+Express, hardcoded-port, Chrome-fallback, deep-tree) and asserts the headless-automatable rows of SKILL.md's Phase-4 checklist: build, bundle metadata, no placeholder leak, runtime port, server responds, server-belongs-to-the-launcher (descendant-walk ownership), warm-reattach, and clean teardown. Hermetic by design — stand-in $PORT servers, no framework installs, a sandboxed HOME, trap-based teardown — so it never touches the user's real ~/Applications / ~/Library state. A weekly fixtures-real CI lane runs one real `npm run dev -- --port $PORT` Vite app so framework drift can't silently rot the top recipe. Add a documented APP_IT_SMOKE seam to the launcher templates so CI (and SSH debugging) can verify the dev server comes up without opening the GUI window. Fix a real bug the suite caught on first contact with a real framework: the process-ownership descendant walk (the reattach gate in every run-template, and desktop:doctor) only reached the first child generation on macOS, because `pgrep -P` returns nothing for a space-joined PID list. Warm-reattach therefore silently failed for npm/pnpm/Next/Vite (listener at gen 2+), cold-starting a new server on a new port each click instead of reusing the warm one. The walk now expands one PID per call; the deep-tree fixture guards against regression. Adopt a recipe-governance rule in CONTRIBUTING: no new framework recipe merges without a fixture or reproducible smoke test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 20 ++ CHANGELOG.md | 4 + CONTRIBUTING.md | 16 +- docs/RELEASE_CHECKLIST.md | 11 +- .../templates/run-template-static-server.sh | 23 +- .../skills/app-it/templates/desktop-doctor.sh | 13 +- .../app-it/templates/run-template-chrome.sh | 12 +- .../templates/run-template-multiserver.sh | 22 +- .../skills/app-it/templates/run-template.sh | 24 +- scripts/fixtures/README.md | 61 ++++ scripts/fixtures/_shared/icon.png | Bin 0 -> 136 bytes .../chrome-fallback/app-it.config.json | 16 + scripts/fixtures/chrome-fallback/index.html | 10 + scripts/fixtures/chrome-fallback/package.json | 11 + .../fixtures/chrome-fallback/vite.config.ts | 3 + scripts/fixtures/deep-tree/app-it.config.json | 16 + scripts/fixtures/deep-tree/package.json | 5 + .../hardcoded-port/app-it.config.json | 16 + scripts/fixtures/hardcoded-port/package.json | 11 + .../fixtures/hardcoded-port/vite.config.ts | 9 + .../fixtures/next-basic/app-it.config.json | 16 + scripts/fixtures/next-basic/app/page.tsx | 3 + scripts/fixtures/next-basic/next.config.js | 4 + scripts/fixtures/next-basic/package.json | 11 + .../fixtures/static-export/app-it.config.json | 15 + scripts/fixtures/static-export/next.config.js | 5 + scripts/fixtures/static-export/out/index.html | 10 + scripts/fixtures/static-export/package.json | 11 + .../fixtures/vite-basic/app-it.config.json | 16 + scripts/fixtures/vite-basic/index.html | 10 + scripts/fixtures/vite-basic/package.json | 11 + scripts/fixtures/vite-basic/vite.config.ts | 6 + .../fixtures/vite-express/app-it.config.json | 16 + scripts/fixtures/vite-express/package.json | 12 + scripts/fixtures/vite-express/server/index.js | 7 + scripts/fixtures/vite-express/vite.config.ts | 8 + scripts/fixtures/vite-real/app-it.config.json | 16 + scripts/fixtures/vite-real/index.html | 11 + scripts/fixtures/vite-real/package.json | 11 + scripts/fixtures/vite-real/src/main.js | 1 + scripts/fixtures/vite-real/vite.config.ts | 6 + scripts/lib/stub-nested.js | 27 ++ scripts/lib/stub-server.js | 48 +++ scripts/test-fixtures.sh | 327 ++++++++++++++++++ scripts/validate.sh | 16 +- 45 files changed, 911 insertions(+), 16 deletions(-) create mode 100644 scripts/fixtures/README.md create mode 100644 scripts/fixtures/_shared/icon.png create mode 100644 scripts/fixtures/chrome-fallback/app-it.config.json create mode 100644 scripts/fixtures/chrome-fallback/index.html create mode 100644 scripts/fixtures/chrome-fallback/package.json create mode 100644 scripts/fixtures/chrome-fallback/vite.config.ts create mode 100644 scripts/fixtures/deep-tree/app-it.config.json create mode 100644 scripts/fixtures/deep-tree/package.json create mode 100644 scripts/fixtures/hardcoded-port/app-it.config.json create mode 100644 scripts/fixtures/hardcoded-port/package.json create mode 100644 scripts/fixtures/hardcoded-port/vite.config.ts create mode 100644 scripts/fixtures/next-basic/app-it.config.json create mode 100644 scripts/fixtures/next-basic/app/page.tsx create mode 100644 scripts/fixtures/next-basic/next.config.js create mode 100644 scripts/fixtures/next-basic/package.json create mode 100644 scripts/fixtures/static-export/app-it.config.json create mode 100644 scripts/fixtures/static-export/next.config.js create mode 100644 scripts/fixtures/static-export/out/index.html create mode 100644 scripts/fixtures/static-export/package.json create mode 100644 scripts/fixtures/vite-basic/app-it.config.json create mode 100644 scripts/fixtures/vite-basic/index.html create mode 100644 scripts/fixtures/vite-basic/package.json create mode 100644 scripts/fixtures/vite-basic/vite.config.ts create mode 100644 scripts/fixtures/vite-express/app-it.config.json create mode 100644 scripts/fixtures/vite-express/package.json create mode 100644 scripts/fixtures/vite-express/server/index.js create mode 100644 scripts/fixtures/vite-express/vite.config.ts create mode 100644 scripts/fixtures/vite-real/app-it.config.json create mode 100644 scripts/fixtures/vite-real/index.html create mode 100644 scripts/fixtures/vite-real/package.json create mode 100644 scripts/fixtures/vite-real/src/main.js create mode 100644 scripts/fixtures/vite-real/vite.config.ts create mode 100644 scripts/lib/stub-nested.js create mode 100755 scripts/lib/stub-server.js create mode 100755 scripts/test-fixtures.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30c034d..6e2febe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,9 @@ name: CI on: push: pull_request: + schedule: + - cron: "17 4 * * 1" # weekly (Mon 04:17 UTC) — real-framework drift check + workflow_dispatch: # Least privilege: validate.sh only reads the tree. permissions: @@ -16,6 +19,23 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Validate plugin run: ./scripts/validate.sh + # Behavioral gate: drive the real scripts against tiny fixtures (build, + # bundle metadata, runtime port, server ownership, teardown). Hermetic — + # stand-in $PORT servers, no installs. Real frameworks run in fixtures-real. + - name: Behavioral fixture suite + run: ./scripts/test-fixtures.sh + + # Real-framework drift lane: npm install + the real `npm run dev -- --port $PORT` + # Vite invocation, launched through a real bundle. Heavy and network-bound, so it + # runs weekly / on demand only — never gating per-PR. The hermetic stub-based + # suite in the `validate` job is what gates every push. + fixtures-real: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: macos-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Real-framework fixture lane (vite-real) + run: APP_IT_RUN_REAL=1 ./scripts/test-fixtures.sh # Required: this job gates merge so a maintainer PR cannot accidentally regress # the Windows scaffold. The macos-latest job validates the macOS plugin; this diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e9cb34..75ae493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +- Added: internal behavioral **fixture suite** (`scripts/test-fixtures.sh` + `scripts/fixtures/`) that proves app-it works across the project shapes it claims to support. On every push the macOS CI lane drives the *real* scripts — `inspect.sh`, `desktop-build.sh`, the generated launcher, `desktop-doctor.sh`, `desktop-quit.sh` — against tiny shape-fixtures (Vite, Next, static-export, Vite+Express multiserver, hardcoded-port, Chrome-fallback) and asserts the headless-automatable rows of SKILL.md's Phase-4 checklist: build, bundle metadata, no placeholder leak, **runtime port**, server responding, **the server belongs to the launcher** (descendant-walk ownership), warm-reattach, and clean teardown. Hermetic by design — stand-in `$PORT` servers, no framework installs, a sandboxed `HOME`, and trap-based teardown, so it never touches your real `~/Applications`/`~/Library` state. A separate weekly `fixtures-real` lane (and the release checklist) runs one **real** `npm run dev -- --port $PORT` Vite app so framework drift can't silently rot the top recipe. GUI-only checks (window content, Dock icon, Cmd+Q/red-X, LaunchServices) stay a documented manual release smoke. +- Added: a documented `APP_IT_SMOKE` seam in the launcher templates (`run-template.sh`, `run-template-multiserver.sh`, `run-template-static-server.sh`). When set, the launcher does everything a real Dock click does *except* open the GUI window — server up, daemonized, reachable, pid/port recorded — then prints the runtime URL and exits. No effect on a normal launch; it's what lets CI (and anyone debugging over SSH) verify the server actually comes up without a display. +- Changed: **recipe-governance rule** — no new framework recipe is merged unless it's backed by a fixture or a reproducible smoke test (see [CONTRIBUTING.md](CONTRIBUTING.md)). Keeps the support matrix honest and the repo free of untested example apps. +- Fixed: **warm-reattach now works for real frameworks.** The launcher's process-ownership descendant walk (the F38 reattach gate in the `run-template*.sh` launchers, and `desktop:doctor`) only ever reached the *first* child generation on macOS — `pgrep -P` returns nothing when handed a space-joined list of PIDs, which is what the walk built up after generation 1. So for the common case where the dev server's HTTP listener sits deeper than one level (`npm run dev` → node → vite; `pnpm dev` → node → node → next-server), the launcher could not recognize its own running server: every Dock click cold-started a *new* server on a *new* port instead of reattaching to the warm one, leaking processes. The walk now expands one PID at a time so each `pgrep -P` call gets a clean argument. Caught by the new `deep-tree` fixture, which fails if the walk regresses to gen-1. - Added: `desktop:doctor` — a self-diagnosing command for generated `app-it` launchers (`scripts/desktop-doctor.sh`). Run `npm run desktop:doctor` long after the build session to get a short, issue-ready report on one launcher: config + placeholder leakage, installed/build `.app`, Info.plist identity, ad-hoc signature, quarantine / iCloud signature-breaking xattrs, preferred-vs-runtime port, stale PID, **whether the process on the runtime port is actually in the recorded supervisor's descendant tree** (reuses the launcher's reattach gate), start-command binary resolution on the launcher's PATH, log/state paths, and **template drift** (feature-probes the installed `wrapper`/`run` against the current templates — no version stamp needed). `--tail[=N]` appends the launcher log. It is a diagnostic, not a fixer: read-only, deterministic, local (no network, no new dependencies), and it says "probably" when a check can't be certain. The opt-in `--fix-safe` flag touches **only app-it's own generated state** — stale pid/port files, this bundle's stale LaunchServices registration, the rebuilt icon, and quarantine on the generated `.app` — never the user's product code, dependencies, config, or anything outside app-it's artifacts. macOS `app-it` plugin only (the `app-it-static` companion has a different runtime model). Embodies Core principle #8 (*runtime truth beats build-time guess*) for end users. - Added: `app-it-static` companion plugin (`plugins/app-it-static/`) — a macOS sibling of `app-it` for **finished or buildable** apps. Builds once, then serves the built output (`dist/`/`build/`/`out/`/…) from a tiny zero-dependency static server (~15 MB) or directly via `file://` (~0 MB) — **no dev server**, instead of the 300–700 MB a dev server holds. Reuses `app-it`'s native Swift WebKit window, icon pipeline, and one-folder Dock install (the five shared templates are byte-identical and CI guards them against drift). The served output is a snapshot; `desktop:rebuild` refreshes it. Inspired by r/ClaudeAI launch feedback (see README → Community nudge) and recorded in [ADR 0006](docs/decisions/0006-static-companion-snapshot-model.md). - Added: Windows beta scaffold (`plugins/app-it-windows/`) — a sibling plugin mirroring the macOS contract with Windows primitives (WPF + WebView2 host, PowerShell lifecycle scripts, multi-resolution `.ico`, Start Menu `.lnk`). Build + lint gated by a required `windows-latest` CI job; **untested on real hardware, looking for a maintainer.** See [docs/WINDOWS.md](docs/WINDOWS.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df4f9c9..803b5ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,11 +21,25 @@ Please avoid broadening the macOS plugin into general desktop-app distribution, Before opening a PR: ```bash -./scripts/validate.sh +./scripts/validate.sh # fast static gate (lint, syntax, plist, manifests) +``` + +For changes to the launcher scripts, the build pipeline, or framework support, also run the behavioral suite — it drives the real scripts against tiny project fixtures (build → runtime port → server ownership → teardown): + +```bash +./scripts/test-fixtures.sh # macOS; hermetic, no installs ``` If your change updates user-visible behavior, add a short note to `CHANGELOG.md`. +## Framework recipes need a fixture + +app-it claims to work across a set of project shapes (Vite, Next, static export, multi-server, …). A *recipe* is a claim; a *fixture* is the proof. + +**No new framework recipe is merged unless it's backed by a fixture in [`scripts/fixtures/`](scripts/fixtures/) or a reproducible smoke test.** This keeps the support matrix honest — every shape we say we handle has a test that fails when we stop handling it — and it keeps the repo from drifting into a zoo of untested example apps. + +Adding one is usually small: a tiny project-shape directory under `scripts/fixtures//` plus one section in `scripts/test-fixtures.sh`. See [scripts/fixtures/README.md](scripts/fixtures/README.md) for what each fixture must guard and what the suite deliberately does not prove. The open `good first issue` recipe tickets (Vite + React, SvelteKit, Astro) are good starting points — each should land with its fixture. + ## Recognition Contributors show up in GitHub's own contributors list — and not just for code. Testing on real hardware, packaging, bug reports, and ideas all earn a mention in the README's community thanks. If a contribution doesn't already credit you, say so in your PR (or open an issue) and the maintainer will add you. diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index cceb42e..d18b101 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -2,7 +2,9 @@ Before publishing a release: -- [ ] `./scripts/validate.sh` passes locally. +- [ ] `./scripts/validate.sh` passes locally (fast static gate). +- [ ] `./scripts/test-fixtures.sh` passes locally (hermetic behavioral suite). +- [ ] `APP_IT_RUN_REAL=1 ./scripts/test-fixtures.sh` passes (real-Vite lane — confirms the current framework still launches end-to-end). - [ ] `claude plugin validate .` passes with the current Claude Code CLI. - [ ] `claude plugin validate plugins/app-it/.claude-plugin/plugin.json` passes with the current Claude Code CLI. - [ ] Codex install smoke passes in a temp home (both installable plugins): @@ -16,7 +18,12 @@ Before publishing a release: - [ ] Shared templates are byte-identical across `app-it` and `app-it-static` (validate.sh's drift guard passes). - [ ] The README install command matches the intended GitHub repo. - [ ] No local paths, private notes, generated bundles, or test artifacts are tracked. -- [ ] A real local project has been appified and opened from `~/Applications/App It/`. +- [ ] **Manual GUI smoke** (the rows CI can't see — SKILL.md Phase-4 rows 12–16). The headless suite proves build → server → port → ownership → teardown; this proves the window actually works. Appify a real project (the `scripts/fixtures/vite-basic` shape is enough — `npm install` it first, or use any local app), install it, open it from `~/Applications/App It/`, and confirm by eye: + - the window shows the app (not an error page); + - the Dock icon is *ours* (not Chrome's / Safari's); + - Cmd+Q kills the app **and** its dev server (`lsof -ti tcp:` is empty after); + - closing the window with the red X leaves the server warm (re-open is instant); + - the standard shortcuts respond (Cmd+R reload, Cmd+W close, zoom, fullscreen). For the first public GitHub setup: diff --git a/plugins/app-it-static/skills/app-it-static/templates/run-template-static-server.sh b/plugins/app-it-static/skills/app-it-static/templates/run-template-static-server.sh index 573a1e5..1916081 100755 --- a/plugins/app-it-static/skills/app-it-static/templates/run-template-static-server.sh +++ b/plugins/app-it-static/skills/app-it-static/templates/run-template-static-server.sh @@ -79,8 +79,16 @@ if [ -f "$PID_FILE" ] && [ -f "$PORT_FILE" ]; then DESCENDANTS="$EXPECTED_PID" CURRENT="$EXPECTED_PID" for _ in 1 2 3 4; do - NEXT_GEN="$(pgrep -P "$CURRENT" 2>/dev/null | tr '\n' ' ')" - [ -z "$NEXT_GEN" ] && break + # Expand one PID per pgrep call. macOS `pgrep -P` returns nothing + # for a space-joined / trailing-space argument, so passing the + # whole generation at once would silently halt the walk at the + # first level and miss deeper listeners. Walk per-pid so each + # call is clean. + NEXT_GEN="" + for _pid in $CURRENT; do + NEXT_GEN="$NEXT_GEN $(pgrep -P "$_pid" 2>/dev/null | tr '\n' ' ')" + done + [ -z "${NEXT_GEN// /}" ] && break DESCENDANTS="$DESCENDANTS $NEXT_GEN" CURRENT="$NEXT_GEN" done @@ -150,6 +158,17 @@ fi URL="http://localhost:$CHOSEN_PORT" +# --- Headless smoke seam (CI / SSH / --check) -------------------------- +# The static server is up, daemonized, reachable, and its pid/port are +# recorded. With APP_IT_SMOKE set, print the runtime URL and exit 0 instead +# of opening the GUI window, so a headless caller can probe it (curl, +# desktop:doctor) and then stop it (desktop:quit). Zero effect on a normal +# Dock launch (APP_IT_SMOKE unset). +if [ -n "${APP_IT_SMOKE:-}" ]; then + echo "app-it smoke: $APP_NAME ready at $URL (server pid $(cat "$PID_FILE" 2>/dev/null))" + exit 0 +fi + # --- Hand off to the native WebKit wrapper ----------------------------- # exec replaces this bash process with the Swift binary so the .app keeps its # own Dock icon and single-instance activation. No polyfill for finished apps. diff --git a/plugins/app-it/skills/app-it/templates/desktop-doctor.sh b/plugins/app-it/skills/app-it/templates/desktop-doctor.sh index 7c0d4d7..d5a47c4 100755 --- a/plugins/app-it/skills/app-it/templates/desktop-doctor.sh +++ b/plugins/app-it/skills/app-it/templates/desktop-doctor.sh @@ -165,10 +165,17 @@ LAUNCHER_PATH="$HOME/.bun/bin:$HOME/.deno/bin:$HOME/.volta/bin:$HOME/.local/shar # space-separated. Mirrors run-template.sh's reattach gate so "does the running # server belong to this launcher" uses the SAME ownership test the launcher does. walk_descendants() { - local root="$1" current="$1" tree="$1" gen + local root="$1" current="$1" tree="$1" gen _pid for _ in 1 2 3 4; do - gen="$(pgrep -P "$current" 2>/dev/null | tr '\n' ' ')" - [ -z "$gen" ] && break + # One PID per pgrep call: macOS `pgrep -P` returns nothing for a + # space-joined / trailing-space argument, so a multi-PID generation + # would halt the walk and miss deeper listeners (pnpm → node → + # next-server, npm → node-vite). Walk per-pid so each call is clean. + gen="" + for _pid in $current; do + gen="$gen $(pgrep -P "$_pid" 2>/dev/null | tr '\n' ' ')" + done + [ -z "${gen// /}" ] && break tree="$tree $gen"; current="$gen" done printf '%s' "$tree" diff --git a/plugins/app-it/skills/app-it/templates/run-template-chrome.sh b/plugins/app-it/skills/app-it/templates/run-template-chrome.sh index 14ec299..16bdee9 100755 --- a/plugins/app-it/skills/app-it/templates/run-template-chrome.sh +++ b/plugins/app-it/skills/app-it/templates/run-template-chrome.sh @@ -91,8 +91,16 @@ if [ -f "$PID_FILE" ] && [ -f "$PORT_FILE" ]; then DESCENDANTS="$EXPECTED_PID" CURRENT="$EXPECTED_PID" for _ in 1 2 3 4; do - NEXT_GEN="$(pgrep -P "$CURRENT" 2>/dev/null | tr '\n' ' ')" - [ -z "$NEXT_GEN" ] && break + # Expand one PID per pgrep call. macOS `pgrep -P` returns nothing + # for a space-joined / trailing-space argument, so passing the + # whole generation at once would silently halt the walk at the + # first level and miss deeper listeners (npm → node-vite, + # pnpm → node → next-server). Walk per-pid so each call is clean. + NEXT_GEN="" + for _pid in $CURRENT; do + NEXT_GEN="$NEXT_GEN $(pgrep -P "$_pid" 2>/dev/null | tr '\n' ' ')" + done + [ -z "${NEXT_GEN// /}" ] && break DESCENDANTS="$DESCENDANTS $NEXT_GEN" CURRENT="$NEXT_GEN" done diff --git a/plugins/app-it/skills/app-it/templates/run-template-multiserver.sh b/plugins/app-it/skills/app-it/templates/run-template-multiserver.sh index 91ebb0c..936b166 100755 --- a/plugins/app-it/skills/app-it/templates/run-template-multiserver.sh +++ b/plugins/app-it/skills/app-it/templates/run-template-multiserver.sh @@ -108,9 +108,14 @@ descendant_holds_port() { local descendants="$supervisor" local current="$supervisor" for _ in 1 2 3 4; do - local next_gen - next_gen="$(pgrep -P "$current" 2>/dev/null | tr '\n' ' ')" - [ -z "$next_gen" ] && break + # One PID per pgrep call — macOS `pgrep -P` returns nothing for a + # space-joined argument, so a multi-PID generation would otherwise halt + # the walk and miss deeper listeners. + local next_gen="" _pid + for _pid in $current; do + next_gen="$next_gen $(pgrep -P "$_pid" 2>/dev/null | tr '\n' ' ')" + done + [ -z "${next_gen// /}" ] && break descendants="$descendants $next_gen" current="$next_gen" done @@ -236,6 +241,17 @@ fi URL="http://localhost:$CHOSEN_FE_PORT" +# --- Headless smoke seam (CI / SSH / --check) -------------------------- +# Both servers are up, daemonized, and recorded (server.{pid,port} + +# backend.{pid,port}); the frontend is reachable. With APP_IT_SMOKE set, +# print the runtime URLs and exit 0 instead of opening the GUI window, so a +# headless caller can probe both ports (curl, desktop:doctor) and stop them +# (desktop:quit). Zero effect on a normal Dock launch (APP_IT_SMOKE unset). +if [ -n "${APP_IT_SMOKE:-}" ]; then + echo "app-it smoke: $APP_NAME ready at $URL (fe pid $(cat "$PID_FILE" 2>/dev/null) :$CHOSEN_FE_PORT, be pid $(cat "$BACKEND_PID_FILE" 2>/dev/null) :$CHOSEN_BE_PORT)" + exit 0 +fi + # --- Hand off to the native WebKit wrapper ----------------------------- WRAPPER="$HERE/wrapper" if [ ! -x "$WRAPPER" ]; then diff --git a/plugins/app-it/skills/app-it/templates/run-template.sh b/plugins/app-it/skills/app-it/templates/run-template.sh index 8d26584..be45405 100755 --- a/plugins/app-it/skills/app-it/templates/run-template.sh +++ b/plugins/app-it/skills/app-it/templates/run-template.sh @@ -134,8 +134,16 @@ if [ -f "$PID_FILE" ] && [ -f "$PORT_FILE" ]; then DESCENDANTS="$EXPECTED_PID" CURRENT="$EXPECTED_PID" for _ in 1 2 3 4; do - NEXT_GEN="$(pgrep -P "$CURRENT" 2>/dev/null | tr '\n' ' ')" - [ -z "$NEXT_GEN" ] && break + # Expand one PID per pgrep call. macOS `pgrep -P` returns nothing + # for a space-joined / trailing-space argument, so passing the + # whole generation at once would silently halt the walk at the + # first level and miss deeper listeners (npm → node-vite, + # pnpm → node → next-server). Walk per-pid so each call is clean. + NEXT_GEN="" + for _pid in $CURRENT; do + NEXT_GEN="$NEXT_GEN $(pgrep -P "$_pid" 2>/dev/null | tr '\n' ' ')" + done + [ -z "${NEXT_GEN// /}" ] && break DESCENDANTS="$DESCENDANTS $NEXT_GEN" CURRENT="$NEXT_GEN" done @@ -236,6 +244,18 @@ fi URL="http://localhost:$CHOSEN_PORT" +# --- Headless smoke seam (CI / SSH / --check) -------------------------- +# Everything a real Dock click does has now run EXCEPT opening the window: +# the dev server is up, daemonized, reachable, and its pid/port are recorded. +# With APP_IT_SMOKE set, print the runtime URL and exit 0 instead of handing +# off to the GUI wrapper. The server stays warm so the caller can probe it +# (curl, desktop:doctor) and then stop it (desktop:quit). Zero effect on a +# normal Dock launch (APP_IT_SMOKE unset). +if [ -n "${APP_IT_SMOKE:-}" ]; then + echo "app-it smoke: $APP_NAME ready at $URL (server pid $(cat "$PID_FILE" 2>/dev/null))" + exit 0 +fi + # --- Hand off to the native WebKit wrapper ----------------------------- # exec replaces this bash process with the Swift binary. The .app's identity # stays intact (CFBundleIdentifier from Info.plist), so the Dock keeps showing diff --git a/scripts/fixtures/README.md b/scripts/fixtures/README.md new file mode 100644 index 0000000..0983e16 --- /dev/null +++ b/scripts/fixtures/README.md @@ -0,0 +1,61 @@ +# app-it fixture suite + +Tiny, disposable project *shapes* that prove app-it actually works across the +kinds of project it claims to support. Driven by [`../test-fixtures.sh`](../test-fixtures.sh) +and run on every push by the macOS CI lane. + +These fixtures are **not** demo apps. Each one earns its place by guarding a +distinct class of regression in app-it's own machinery — not by showing off a +framework. Keep them minimal. + +## How a run works + +For each fixture, `test-fixtures.sh`: + +1. Copies the fixture's project shape into a throwaway temp dir, drops the + plugin's `templates/` into `scripts/`, relocates the fixture's + `app-it.config.json`, and (for runtime fixtures) copies in the `$PORT`-honoring + stand-ins [`../lib/stub-server.js`](../lib/stub-server.js) (and + [`../lib/stub-nested.js`](../lib/stub-nested.js), which spawns the stub a level + deeper for the deep-tree case) and the shared `_shared/icon.png`. +2. Runs the **real** scripts — `inspect.sh`, `desktop-build.sh`, the bundle's + `run` (with `APP_IT_SMOKE=1`, the headless seam), `desktop-doctor.sh`, + `desktop-quit.sh` — under a **sandboxed `HOME`**, so the real + `~/Applications/App It` and `~/Library/.../app-it` state is never touched. +3. Asserts the headless-automatable rows of SKILL.md's Phase-4 checklist + (build, bundle metadata, no placeholder leak, runtime port, server + responding, **server belongs to the launcher**) and tears everything down — + even on failure. + +## What each fixture guards + +| Fixture | Distinct regression it guards | +|---|---| +| `vite-basic` | Vite detection; single-server build → launch → port → ownership → warm reattach → teardown; PNG → `.icns` icon round-trip | +| `next-basic` | Next detection (PORT-env, no `--port`); bundle assembles for a Next shape | +| `static-export` | app-it-static: static-export detection + serving a prebuilt `out/` with the real stdlib `static-server.py` | +| `vite-express` | A3.2 multiserver template selected; dual-port + `API_PORT`; both ports owned and freed | +| `deep-tree` | the descendant-walk reaches a **gen-2** listener (bash → node → node, like real `npm`/`pnpm` dev) — so warm-reattach and `desktop:doctor` ownership work for real frameworks, not just gen-1 stubs | +| `hardcoded-port` | `inspect.sh` keeps warning about hardcoded port literals (the #1 reason a launcher silently ignores its chosen port) | +| `chrome-fallback` | `APP_IT_LAUNCHER_MODE=chrome` → Chrome `--app=` run script, no Swift wrapper binary | +| `vite-real` | The **real** `npm run dev -- --port $PORT` Vite invocation still launches (scheduled + release only — needs `APP_IT_RUN_REAL=1`) | + +## What this suite does NOT prove + +Honesty matters more than a green check that lies: + +- **Real frameworks** — except `vite-real`, the runtime fixtures launch a tiny + stand-in server, not the real framework. app-it's launcher is + framework-agnostic; the framework-specific knowledge it encodes (Vite needs + `--port`, Next reads `PORT`) is tested via `inspect.sh`'s output, and real + end-to-end launches live in `vite-real` + the manual release smoke. +- **The GUI lifecycle** — window content, the Dock icon being *ours*, Cmd+Q vs + red-X, LaunchServices registration (rows 5–16) need a real display. They stay + in the manual release smoke (see [docs/RELEASE_CHECKLIST.md](../../docs/RELEASE_CHECKLIST.md)). + +## Rule for new framework recipes + +**No new framework recipe is merged unless it's backed by a fixture here or a +reproducible smoke test.** A recipe is a claim that app-it works for a shape; a +fixture is the proof. Adding one is usually: a tiny shape dir + one row in +`test-fixtures.sh`. See [CONTRIBUTING.md](../../CONTRIBUTING.md). diff --git a/scripts/fixtures/_shared/icon.png b/scripts/fixtures/_shared/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..736e213ca020b3fa8e43339a07a646ec5b910e77 GIT binary patch literal 136 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLGH$e@_?3kcwMx&pUE5FmSMJI9)9G qM*a-PzSe_WI&b%gF_HutPc!Z5d{DW;Z1WwU(F~rhelF{r5}E)E2OBg1 literal 0 HcmV?d00001 diff --git a/scripts/fixtures/chrome-fallback/app-it.config.json b/scripts/fixtures/chrome-fallback/app-it.config.json new file mode 100644 index 0000000..4560423 --- /dev/null +++ b/scripts/fixtures/chrome-fallback/app-it.config.json @@ -0,0 +1,16 @@ +{ + "_comment": "Built with APP_IT_LAUNCHER_MODE=chrome. The suite asserts the chrome launch path: run script uses Chrome --app= and the bundle carries NO Swift wrapper binary. Chrome is not opened headlessly — this is a build-shape assertion only.", + "apps": [ + { + "name": "Chrome Fallback", + "slug": "chrome-fallback", + "port": 41050, + "start_command": "node stub-server.js --port \\$PORT", + "bundle_id": "com.user.chrome-fallback", + "version": "0.1.0", + "polyfill_path": "", + "backend_port": null, + "backend_start_command": null + } + ] +} diff --git a/scripts/fixtures/chrome-fallback/index.html b/scripts/fixtures/chrome-fallback/index.html new file mode 100644 index 0000000..01dcc32 --- /dev/null +++ b/scripts/fixtures/chrome-fallback/index.html @@ -0,0 +1,10 @@ + + + + + chrome-fallback fixture + + +
chrome-fallback fixture
+ + diff --git a/scripts/fixtures/chrome-fallback/package.json b/scripts/fixtures/chrome-fallback/package.json new file mode 100644 index 0000000..a69f898 --- /dev/null +++ b/scripts/fixtures/chrome-fallback/package.json @@ -0,0 +1,11 @@ +{ + "name": "chrome-fallback", + "private": true, + "version": "0.0.0", + "scripts": { + "dev": "vite" + }, + "devDependencies": { + "vite": "^5.0.0" + } +} diff --git a/scripts/fixtures/chrome-fallback/vite.config.ts b/scripts/fixtures/chrome-fallback/vite.config.ts new file mode 100644 index 0000000..6150f9a --- /dev/null +++ b/scripts/fixtures/chrome-fallback/vite.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from "vite"; + +export default defineConfig({}); diff --git a/scripts/fixtures/deep-tree/app-it.config.json b/scripts/fixtures/deep-tree/app-it.config.json new file mode 100644 index 0000000..829f9a7 --- /dev/null +++ b/scripts/fixtures/deep-tree/app-it.config.json @@ -0,0 +1,16 @@ +{ + "_comment": "Guards the descendant-walk fix. start_command launches stub-nested.js, which spawns stub-server.js as a child — so the HTTP listener sits at generation 2 (like real npm/pnpm dev). The suite asserts warm-reattach and desktop:doctor ownership both see it; a walk that stops at generation 1 (the macOS pgrep -P trap) would fail both.", + "apps": [ + { + "name": "Deep Tree", + "slug": "deep-tree", + "port": 41080, + "start_command": "node stub-nested.js --port \\$PORT", + "bundle_id": "com.user.deep-tree", + "version": "0.1.0", + "polyfill_path": "", + "backend_port": null, + "backend_start_command": null + } + ] +} diff --git a/scripts/fixtures/deep-tree/package.json b/scripts/fixtures/deep-tree/package.json new file mode 100644 index 0000000..004c267 --- /dev/null +++ b/scripts/fixtures/deep-tree/package.json @@ -0,0 +1,5 @@ +{ + "name": "deep-tree", + "private": true, + "version": "0.0.0" +} diff --git a/scripts/fixtures/hardcoded-port/app-it.config.json b/scripts/fixtures/hardcoded-port/app-it.config.json new file mode 100644 index 0000000..c3135d3 --- /dev/null +++ b/scripts/fixtures/hardcoded-port/app-it.config.json @@ -0,0 +1,16 @@ +{ + "_comment": "Inspect-only fixture (not built). Its job is to prove inspect.sh keeps warning about hardcoded port literals — the most common reason an appified launcher silently ignores its chosen port. This config is not exercised by the suite.", + "apps": [ + { + "name": "Hardcoded Port", + "slug": "hardcoded-port", + "port": 41070, + "start_command": "node stub-server.js --port \\$PORT", + "bundle_id": "com.user.hardcoded-port", + "version": "0.1.0", + "polyfill_path": "", + "backend_port": null, + "backend_start_command": null + } + ] +} diff --git a/scripts/fixtures/hardcoded-port/package.json b/scripts/fixtures/hardcoded-port/package.json new file mode 100644 index 0000000..0f86b59 --- /dev/null +++ b/scripts/fixtures/hardcoded-port/package.json @@ -0,0 +1,11 @@ +{ + "name": "hardcoded-port", + "private": true, + "version": "0.0.0", + "scripts": { + "dev": "vite --port 5173" + }, + "devDependencies": { + "vite": "^5.0.0" + } +} diff --git a/scripts/fixtures/hardcoded-port/vite.config.ts b/scripts/fixtures/hardcoded-port/vite.config.ts new file mode 100644 index 0000000..c5f2ff5 --- /dev/null +++ b/scripts/fixtures/hardcoded-port/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vite"; + +// The footgun this fixture guards: a hardcoded port literal makes the framework +// ignore the launcher's chosen PORT. Both inspect.sh signals must keep firing — +// the "dev" script's --port literal AND the server.port literal below. Kept on +// one line so inspect.sh's line-based server.port regex matches. +export default defineConfig({ + server: { port: 5173 }, +}); diff --git a/scripts/fixtures/next-basic/app-it.config.json b/scripts/fixtures/next-basic/app-it.config.json new file mode 100644 index 0000000..596e15d --- /dev/null +++ b/scripts/fixtures/next-basic/app-it.config.json @@ -0,0 +1,16 @@ +{ + "_comment": "Build-assert fixture (no runtime run). start_command honors the PORT env (Next-style); the suite only proves the bundle assembles for a Next-shaped project. Single-server runtime is covered by vite-basic.", + "apps": [ + { + "name": "Next Basic", + "slug": "next-basic", + "port": 41040, + "start_command": "node stub-server.js", + "bundle_id": "com.user.next-basic", + "version": "0.1.0", + "polyfill_path": "", + "backend_port": null, + "backend_start_command": null + } + ] +} diff --git a/scripts/fixtures/next-basic/app/page.tsx b/scripts/fixtures/next-basic/app/page.tsx new file mode 100644 index 0000000..8ab9e18 --- /dev/null +++ b/scripts/fixtures/next-basic/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
next-basic fixture
; +} diff --git a/scripts/fixtures/next-basic/next.config.js b/scripts/fixtures/next-basic/next.config.js new file mode 100644 index 0000000..2ecf0cf --- /dev/null +++ b/scripts/fixtures/next-basic/next.config.js @@ -0,0 +1,4 @@ +// Next.js (non-export) — needs a Node runtime. `next dev` reads the PORT env +// directly (no --port needed), and the "dev" script carries no -p literal, so +// inspect.sh must detect Next here and emit no hardcoded-port warning. +module.exports = {}; diff --git a/scripts/fixtures/next-basic/package.json b/scripts/fixtures/next-basic/package.json new file mode 100644 index 0000000..f7568a4 --- /dev/null +++ b/scripts/fixtures/next-basic/package.json @@ -0,0 +1,11 @@ +{ + "name": "next-basic", + "private": true, + "version": "0.0.0", + "scripts": { + "dev": "next dev" + }, + "dependencies": { + "next": "^15.0.0" + } +} diff --git a/scripts/fixtures/static-export/app-it.config.json b/scripts/fixtures/static-export/app-it.config.json new file mode 100644 index 0000000..c30614f --- /dev/null +++ b/scripts/fixtures/static-export/app-it.config.json @@ -0,0 +1,15 @@ +{ + "_comment": "app-it-static schema. serve_mode 'server' uses the bundled stdlib static-server.py (zero install) to serve the prebuilt out/ — a genuine end-to-end of the static launch path.", + "apps": [ + { + "name": "Static Export", + "slug": "static-export", + "serve_mode": "server", + "static_dir": "out", + "port": 41030, + "bundle_id": "com.user.static-export", + "version": "0.1.0", + "build_command": "next build" + } + ] +} diff --git a/scripts/fixtures/static-export/next.config.js b/scripts/fixtures/static-export/next.config.js new file mode 100644 index 0000000..19e7650 --- /dev/null +++ b/scripts/fixtures/static-export/next.config.js @@ -0,0 +1,5 @@ +// output: 'export' → Next emits a fully static site into out/. inspect-static.sh +// must detect this as "Next.js (static export)" with static_dir "out". The +// suite ships a prebuilt out/ so app-it-static can serve it with the real +// static-server.py — no `next build` (and no node_modules) needed. +module.exports = { output: "export" }; diff --git a/scripts/fixtures/static-export/out/index.html b/scripts/fixtures/static-export/out/index.html new file mode 100644 index 0000000..0cab709 --- /dev/null +++ b/scripts/fixtures/static-export/out/index.html @@ -0,0 +1,10 @@ + + + + + static-export fixture + + +
static-export fixture — served by static-server.py
+ + diff --git a/scripts/fixtures/static-export/package.json b/scripts/fixtures/static-export/package.json new file mode 100644 index 0000000..dd2f1ea --- /dev/null +++ b/scripts/fixtures/static-export/package.json @@ -0,0 +1,11 @@ +{ + "name": "static-export", + "private": true, + "version": "0.0.0", + "scripts": { + "build": "next build" + }, + "dependencies": { + "next": "^15.0.0" + } +} diff --git a/scripts/fixtures/vite-basic/app-it.config.json b/scripts/fixtures/vite-basic/app-it.config.json new file mode 100644 index 0000000..2f3f80a --- /dev/null +++ b/scripts/fixtures/vite-basic/app-it.config.json @@ -0,0 +1,16 @@ +{ + "_comment": "Fixture config. start_command is a $PORT-honoring stand-in (scripts/lib/stub-server.js, copied to the project root by test-fixtures.sh) — NOT real Vite. It mirrors Vite's CLI-port shape so the launcher's port handling is exercised without a node_modules install. Real Vite is the vite-real lane.", + "apps": [ + { + "name": "Vite Basic", + "slug": "vite-basic", + "port": 41000, + "start_command": "node stub-server.js --port \\$PORT", + "bundle_id": "com.user.vite-basic", + "version": "0.1.0", + "polyfill_path": "", + "backend_port": null, + "backend_start_command": null + } + ] +} diff --git a/scripts/fixtures/vite-basic/index.html b/scripts/fixtures/vite-basic/index.html new file mode 100644 index 0000000..b390b9b --- /dev/null +++ b/scripts/fixtures/vite-basic/index.html @@ -0,0 +1,10 @@ + + + + + vite-basic fixture + + +
vite-basic fixture
+ + diff --git a/scripts/fixtures/vite-basic/package.json b/scripts/fixtures/vite-basic/package.json new file mode 100644 index 0000000..4c91144 --- /dev/null +++ b/scripts/fixtures/vite-basic/package.json @@ -0,0 +1,11 @@ +{ + "name": "vite-basic", + "private": true, + "version": "0.0.0", + "scripts": { + "dev": "vite" + }, + "devDependencies": { + "vite": "^5.0.0" + } +} diff --git a/scripts/fixtures/vite-basic/vite.config.ts b/scripts/fixtures/vite-basic/vite.config.ts new file mode 100644 index 0000000..a1e3d56 --- /dev/null +++ b/scripts/fixtures/vite-basic/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; + +// Vanilla single-server Vite: no server.port literal, so inspect.sh must NOT +// emit a hardcoded-port warning for this shape, and the launcher's chosen PORT +// flows through via the CLI flag (npm run dev -- --port $PORT). +export default defineConfig({}); diff --git a/scripts/fixtures/vite-express/app-it.config.json b/scripts/fixtures/vite-express/app-it.config.json new file mode 100644 index 0000000..498b716 --- /dev/null +++ b/scripts/fixtures/vite-express/app-it.config.json @@ -0,0 +1,16 @@ +{ + "_comment": "A3.2 multi-server. backend_port + backend_start_command make desktop-build.sh select run-template-multiserver.sh. Both commands use the stub (frontend via --port $PORT, backend via the API_PORT env) so the dual-port launch path runs without an install.", + "apps": [ + { + "name": "Vite Express", + "slug": "vite-express", + "port": 41010, + "start_command": "node stub-server.js --port \\$PORT", + "bundle_id": "com.user.vite-express", + "version": "0.1.0", + "polyfill_path": "", + "backend_port": 41020, + "backend_start_command": "node stub-server.js" + } + ] +} diff --git a/scripts/fixtures/vite-express/package.json b/scripts/fixtures/vite-express/package.json new file mode 100644 index 0000000..817e3ad --- /dev/null +++ b/scripts/fixtures/vite-express/package.json @@ -0,0 +1,12 @@ +{ + "name": "vite-express", + "private": true, + "version": "0.0.0", + "scripts": { + "dev": "vite", + "server": "node server/index.js" + }, + "devDependencies": { + "vite": "^5.0.0" + } +} diff --git a/scripts/fixtures/vite-express/server/index.js b/scripts/fixtures/vite-express/server/index.js new file mode 100644 index 0000000..924bc90 --- /dev/null +++ b/scripts/fixtures/vite-express/server/index.js @@ -0,0 +1,7 @@ +// Stand-in backend. The real launcher exports API_PORT (and PORT) to this +// process; an Express entrypoint would read process.env.API_PORT first. The +// suite swaps this for scripts/lib/stub-server.js (which resolves API_PORT) so +// the multiserver launch path is exercised without an npm install. +const http = require("http"); +const port = process.env.API_PORT || process.env.PORT || 3001; +http.createServer((_req, res) => res.end("backend ok")).listen(Number(port), "127.0.0.1"); diff --git a/scripts/fixtures/vite-express/vite.config.ts b/scripts/fixtures/vite-express/vite.config.ts new file mode 100644 index 0000000..12c60ba --- /dev/null +++ b/scripts/fixtures/vite-express/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vite"; + +// Cohabiting frontend + backend: the proxy target points at a separate backend +// port, which is inspect.sh's "multi-server (A3) likely" signal. Kept on one +// line so inspect.sh's line-based proxy regex matches. +export default defineConfig({ + server: { proxy: { "/api": { target: "http://localhost:3001", changeOrigin: true } } }, +}); diff --git a/scripts/fixtures/vite-real/app-it.config.json b/scripts/fixtures/vite-real/app-it.config.json new file mode 100644 index 0000000..8ea80b9 --- /dev/null +++ b/scripts/fixtures/vite-real/app-it.config.json @@ -0,0 +1,16 @@ +{ + "_comment": "Real-framework lane (runs only with APP_IT_RUN_REAL=1 / the scheduled fixtures-real CI job). npm install + the real `npm run dev -- --port $PORT` Vite invocation, launched through the real bundle. Catches Vite CLI/port drift that the hermetic stubs cannot.", + "apps": [ + { + "name": "Vite Real", + "slug": "vite-real", + "port": 41060, + "start_command": "npm run dev -- --port \\$PORT", + "bundle_id": "com.user.vite-real", + "version": "0.1.0", + "polyfill_path": "", + "backend_port": null, + "backend_start_command": null + } + ] +} diff --git a/scripts/fixtures/vite-real/index.html b/scripts/fixtures/vite-real/index.html new file mode 100644 index 0000000..96b170d --- /dev/null +++ b/scripts/fixtures/vite-real/index.html @@ -0,0 +1,11 @@ + + + + + vite-real fixture + + +
vite-real fixture
+ + + diff --git a/scripts/fixtures/vite-real/package.json b/scripts/fixtures/vite-real/package.json new file mode 100644 index 0000000..954be3d --- /dev/null +++ b/scripts/fixtures/vite-real/package.json @@ -0,0 +1,11 @@ +{ + "name": "vite-real", + "private": true, + "version": "0.0.0", + "scripts": { + "dev": "vite" + }, + "devDependencies": { + "vite": "^5.4.0" + } +} diff --git a/scripts/fixtures/vite-real/src/main.js b/scripts/fixtures/vite-real/src/main.js new file mode 100644 index 0000000..b68d515 --- /dev/null +++ b/scripts/fixtures/vite-real/src/main.js @@ -0,0 +1 @@ +document.querySelector("main").dataset.ready = "true"; diff --git a/scripts/fixtures/vite-real/vite.config.ts b/scripts/fixtures/vite-real/vite.config.ts new file mode 100644 index 0000000..a0465aa --- /dev/null +++ b/scripts/fixtures/vite-real/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; + +// A genuinely real Vite app (installed + run for real in the scheduled/release +// lane). Vanilla single-server: the launcher passes its chosen port via the CLI +// flag, which is exactly the invocation app-it generates. +export default defineConfig({}); diff --git a/scripts/lib/stub-nested.js b/scripts/lib/stub-nested.js new file mode 100644 index 0000000..8058040 --- /dev/null +++ b/scripts/lib/stub-nested.js @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// Deep-tree stand-in for app-it's fixture suite. +// +// Spawns the real stub-server.js as a CHILD and stays alive, so the HTTP +// listener sits one generation deeper than this process. Launched by app-it +// that makes the tree: launcher-bash → node(this) → node(stub-server, listener) +// — i.e. the listener is at generation 2, mirroring real `npm run dev` +// (bash → npm → node-vite) and `pnpm dev` (bash → pnpm → node → next-server). +// +// This exists to guard the descendant-walk: a walk that stops at the first +// generation (the macOS `pgrep -P` space-joined-arg trap) cannot see this +// listener, so warm-reattach and `desktop:doctor` ownership would fail. The +// suite asserts they DON'T. +'use strict'; + +const { spawn } = require('child_process'); +const path = require('path'); + +// Forward our args (e.g. --port N) straight through to the real stub. +const child = spawn('node', [path.join(__dirname, 'stub-server.js'), ...process.argv.slice(2)], { + stdio: 'inherit', +}); +child.on('error', (err) => { + console.error(`stub-nested: failed to spawn node: ${err.message}`); + process.exit(1); +}); +child.on('exit', (code) => process.exit(code == null ? 0 : code)); diff --git a/scripts/lib/stub-server.js b/scripts/lib/stub-server.js new file mode 100755 index 0000000..a10148f --- /dev/null +++ b/scripts/lib/stub-server.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node +// Tiny $PORT-honoring stand-in dev server for app-it's fixture suite. +// +// app-it's launcher is framework-agnostic: it just runs whatever START_COMMAND +// honors the PORT it chose. This stand-in lets the suite exercise the REAL +// launcher machinery (port scan/fallback, daemonized spawn, two-stage readiness +// probe, descendant-walk reattach, ownership, teardown) WITHOUT installing a +// framework — so fixtures stay tiny and CI stays fast and deterministic. +// Proving the real frameworks still launch is the job of the `vite-real` lane +// and the manual release smoke, not this stub. +// +// Port resolution mirrors the command shapes app-it generates: +// 1. `--port N` Vite-style frontend (`npm run dev -- --port $PORT`) +// 2. process.env.API_PORT multiserver backend (the entrypoint reads API_PORT first) +// 3. process.env.PORT Express / Next-style (reads the PORT env) +'use strict'; + +const http = require('http'); + +function resolvePort() { + const i = process.argv.indexOf('--port'); + if (i !== -1 && process.argv[i + 1]) return process.argv[i + 1]; + return process.env.API_PORT || process.env.PORT; +} + +const port = Number(resolvePort()); +if (!Number.isInteger(port) || port < 0 || port > 65535) { + console.error('stub-server: invalid/missing port (pass --port N or set PORT / API_PORT)'); + process.exit(1); +} + +const label = process.env.STUB_LABEL || 'app-it fixture stub'; +const server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(`${label}` + + `

${label}

serving on port ${port}

`); +}); + +// A clean one-line message in the launcher log beats an unhandled 'error' stack. +server.on('error', (err) => { + console.error(`stub-server: ${err.code || err.message} binding 127.0.0.1:${port}`); + process.exit(1); +}); + +// Bind 127.0.0.1 only — a local stand-in, never a host. +server.listen(port, '127.0.0.1', () => { + console.log(`stub-server: ${label} listening on http://127.0.0.1:${port}`); +}); diff --git a/scripts/test-fixtures.sh b/scripts/test-fixtures.sh new file mode 100755 index 0000000..7b7c323 --- /dev/null +++ b/scripts/test-fixtures.sh @@ -0,0 +1,327 @@ +#!/usr/bin/env bash +# Behavioral fixture suite for app-it. +# +# validate.sh is the fast, portable, STATIC gate (lint, syntax, plist, manifests). +# THIS script is the BEHAVIORAL gate: it drives app-it's real scripts against the +# project shapes under scripts/fixtures/ and asserts the headless-automatable rows +# of SKILL.md's Phase-4 verification checklist — build, bundle metadata, no +# placeholder leak, runtime port, server responding, "server belongs to the +# launcher" — then tears everything down. The GUI rows (window content, Dock +# icon, Cmd+Q/red-X, lsregister) stay in the manual release smoke. +# +# Safety: everything runs under a sandboxed HOME and a throwaway temp tree, so the +# real ~/Applications/App It and ~/Library/.../app-it state is never touched. A +# trap tears down servers and the temp tree even on failure. +# +# Usage: +# ./scripts/test-fixtures.sh # hermetic suite (no network, no installs) +# APP_IT_RUN_REAL=1 ./scripts/test-fixtures.sh # also run the real-Vite lane +# +# macOS only (compiles the Swift wrapper, uses sips/iconutil/plutil/codesign). + +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FIX="$REPO/scripts/fixtures" +ARCH="$(uname -m)" # build the host arch only — faster, no x86_64 SDK flakiness +# Fixture port range, far from common dev ports. PORT_HI covers the highest +# fixture's full [preferred..preferred+50] launcher scan window, so the cleanup +# sweep below can always reach a server that fell back to a higher port. +PORT_LO=41000; PORT_HI=41199 + +# --- Sandbox: never touch the user's real launcher state --------------------- +WORK="$(mktemp -d "${TMPDIR:-/tmp}/app-it-fixtures.XXXXXX")" +export HOME="$WORK/home"; mkdir -p "$HOME" + +cleanup() { + # Stop anything still bound in the fixture port range (belt-and-suspenders; + # each fixture also runs desktop:quit inline as part of its test). One ranged + # lsof covers the whole window in a single call. + local pids + pids="$(lsof -ti tcp:"$PORT_LO"-"$PORT_HI" 2>/dev/null || true)" + [ -n "$pids" ] && kill -TERM $pids 2>/dev/null || true + rm -rf "$WORK" 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +# --- Output vocabulary ------------------------------------------------------- +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + G=$'\033[32m'; R=$'\033[31m'; Y=$'\033[33m'; B=$'\033[1m'; D=$'\033[2m'; O=$'\033[0m' +else G=""; R=""; Y=""; B=""; D=""; O=""; fi +PASS=0; FAIL=0 +ok() { printf ' %sok%s %s\n' "$G" "$O" "$1"; PASS=$((PASS+1)); } +bad() { printf ' %sFAIL%s %s\n' "$R" "$O" "$1"; FAIL=$((FAIL+1)); } +section() { printf '\n%s== %s ==%s\n' "$B" "$1" "$O"; } +note() { printf ' %s%s%s\n' "$D" "$1" "$O"; } + +# haystack/needle assertions (line-based; needles are single-line) +has() { if printf '%s\n' "$2" | grep -qF -- "$3"; then ok "$1"; else bad "$1 — missing: $3"; fi; } +has_re() { if printf '%s\n' "$2" | grep -qE -- "$3"; then ok "$1"; else bad "$1 — no match: $3"; fi; } +lacks() { if printf '%s\n' "$2" | grep -qF -- "$3"; then bad "$1 — unexpected: $3"; else ok "$1"; fi; } +is_file(){ if [ -f "$2" ]; then ok "$1"; else bad "$1 — no file: $2"; fi; } +no_path(){ if [ -e "$2" ]; then bad "$1 — exists: $2"; else ok "$1"; fi; } + +# Unresolved-placeholder regex, assembled in pieces so validate.sh's own +# literal-placeholder grep over scripts/ does not false-trip on this file. +PH_RE="__[A-Z_]$(printf '%s' '+__')" + +# --- Per-fixture setup: assemble a throwaway project, as a user would --------- +# setup_proj → sets global PROJ +setup_proj() { + local name="$1" plugin="$2" slug="$3" + PROJ="$WORK/proj-$name" + rm -rf "$PROJ"; mkdir -p "$PROJ/scripts" "$PROJ/assets" + cp -R "$FIX/$name/." "$PROJ/" # the project shape + cp -R "$REPO/plugins/$plugin/skills/$plugin/templates/." "$PROJ/scripts/" # real templates + mv "$PROJ/app-it.config.json" "$PROJ/scripts/app-it.config.json" # config lives in scripts/ + cp "$REPO/scripts/lib/"*.js "$PROJ/" # $PORT-honoring stand-ins (incl. nested) + cp "$FIX/_shared/icon.png" "$PROJ/assets/$slug-icon.png" # icon round-trip source +} + +# desktop-build.sh in the project, host-arch wrapper, captured to build.log. +build() { # build + local proj="$PROJ" + if ( cd "$proj" && env APP_IT_PROJECT_ROOT="$proj" APP_IT_SWIFT_ARCHS="$ARCH" "$@" \ + bash scripts/desktop-build.sh ) >"$proj/build.log" 2>&1; then + ok "desktop-build.sh succeeded" + else + bad "desktop-build.sh failed"; sed 's/^/ /' "$proj/build.log" + fi +} + +# Run a command with a wall-clock cap (macOS has no coreutils `timeout`). +# Poll-based, NOT a background sleep watchdog: a backgrounded `sleep` can be +# orphaned if the child exits before the watchdog forks it, leaving a stray +# process. Here every `sleep 1` is a foreground child, fully reaped each tick. +run_capped() { # run_capped + local secs="$1" log="$2"; shift 2 + "$@" >"$log" 2>&1 & + local pid=$! waited=0 + while kill -0 "$pid" 2>/dev/null; do + sleep 1; waited=$((waited + 1)) + if [ "$waited" -ge "$secs" ]; then kill -TERM "$pid" 2>/dev/null; break; fi + done + local rc=0; wait "$pid" 2>/dev/null || rc=$? + return "$rc" +} + +state_dir() { printf '%s/Library/Application Support/app-it/%s' "$HOME" "$1"; } + +# Does a listener on belong to 's descendant tree? +# This is an INDEPENDENT ownership oracle, so it must walk correctly: macOS +# `pgrep -P` rejects a space-joined / trailing-space argument, so we expand each +# generation one pid at a time (feeding pgrep a single clean pid). Walks up to +# 4 generations (e.g. bash → npm → node-vite → esbuild). +listener_owned_by() { # listener_owned_by + local sup="$1" port="$2" listeners desc cur gen pid p kids + listeners="$(lsof -ti tcp:"$port" 2>/dev/null || true)" + [ -z "$listeners" ] && return 1 + desc="$sup"; cur="$sup" + for _ in 1 2 3 4; do + gen="" + for p in $cur; do + kids="$(pgrep -P "$p" 2>/dev/null || true)" + [ -n "$kids" ] && gen="$gen $kids" + done + [ -z "$gen" ] && break + desc="$desc $gen"; cur="$gen" + done + for pid in $listeners; do case " $desc " in *" $pid "*) return 0 ;; esac; done + return 1 +} + +# Launch the built bundle in the headless smoke seam; assert server up + owned. +# Echoes the chosen runtime port on success. +seam_up() { # seam_up ; sets RUNTIME_PORT + local app="$1" slug="$2" sd port pid + sd="$(state_dir "$slug")" + # `env APP_IT_SMOKE=1 …` (not a bare prefix) so the var is exported to the + # launcher's child processes, not just set in this function's scope. + if run_capped 60 "$PROJ/seam.log" \ + env APP_IT_SMOKE=1 "$PROJ/desktop/$app.app/Contents/MacOS/run"; then + ok "launcher ran in smoke mode (server up, no GUI)" + else + bad "launcher smoke run failed"; sed 's/^/ /' "$PROJ/seam.log"; RUNTIME_PORT=""; return + fi + port="$(cat "$sd/server.port" 2>/dev/null || true)" + pid="$(cat "$sd/server.pid" 2>/dev/null || true)" + RUNTIME_PORT="$port" + if [ -n "$port" ]; then ok "runtime port recorded (:$port)"; else bad "no server.port recorded"; return; fi + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then ok "supervisor pid $pid is alive"; else bad "supervisor pid dead/missing"; fi + if listener_owned_by "$pid" "$port"; then ok "server on :$port belongs to this launcher"; else bad "server on :$port not in launcher's process tree"; fi + local code; code="$(curl -sS -o /dev/null --max-time 2 -w '%{http_code}' "http://localhost:$port" 2>/dev/null || true)" + if [ -n "$code" ] && [ "$code" != "000" ]; then ok "server responds (HTTP $code)"; else bad "server not responding on :$port"; fi +} + +quit_clean() { # quit_clean [more-ports...] + local slug="$1"; shift + APP_IT_PROJECT_ROOT="$PROJ" bash "$PROJ/scripts/desktop-quit.sh" >"$PROJ/quit.log" 2>&1 || true + sleep 1 + local p still="" + for p in "$@"; do [ -n "$(lsof -ti tcp:"$p" 2>/dev/null || true)" ] && still="$still $p"; done + if [ -z "$still" ]; then ok "desktop-quit.sh freed all ports ($*)"; else bad "ports still held after quit:$still"; fi +} + +# A 2nd smoke launch must REATTACH to the warm server (same pid + port), not +# cold-start a duplicate. The 2nd run must actually succeed first — otherwise a +# crashed run would leave the recorded pid/port unchanged and read back equal, +# falsely reporting a reattach that never happened. +warm_reattach() { # warm_reattach + local app="$1" slug="$2" p1="$3" port1="$4" sd p2 port2 + sd="$(state_dir "$slug")" + if ! run_capped 60 "$PROJ/seam2.log" env APP_IT_SMOKE=1 "$PROJ/desktop/$app.app/Contents/MacOS/run"; then + bad "warm re-launch failed to run"; sed 's/^/ /' "$PROJ/seam2.log"; return + fi + p2="$(cat "$sd/server.pid" 2>/dev/null || true)" + port2="$(cat "$sd/server.port" 2>/dev/null || true)" + if [ -n "$p1" ] && [ "$p1" = "$p2" ] && [ "$port1" = "$port2" ]; then + ok "warm re-launch reattached to the same server (pid $p1, :$port1)" + else + bad "warm re-launch did NOT reattach (pid $p1→$p2, port $port1→$port2) — descendant walk stops too early" + fi +} + +# Assert a freshly built bundle's structure (SKILL.md rows 1–2). $1=app $2=slug $3=bundle-id $4=swift|chrome +assert_bundle() { + local app="$1" slug="$2" bid="$3" mode="$4" + local appdir="$PROJ/desktop/$app.app" plist="$PROJ/desktop/$app.app/Contents/Info.plist" + local run="$appdir/Contents/MacOS/run" wrap="$appdir/Contents/MacOS/wrapper" icns="$appdir/Contents/Resources/AppIcon.icns" + is_file "$app.app exists" "$appdir/Contents/Info.plist" + is_file "launcher script present (MacOS/run)" "$run" + if plutil -lint "$plist" >/dev/null 2>&1; then ok "Info.plist passes plutil -lint"; else bad "Info.plist fails plutil -lint"; fi + local got; got="$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$plist" 2>/dev/null || true)" + if [ "$got" = "$bid" ]; then ok "Info.plist bundle id matches config ($got)"; else bad "bundle id '$got' != '$bid'"; fi + if [ -f "$plist" ] && [ -f "$run" ]; then + if grep -Eq "$PH_RE" "$plist" "$run"; then bad "unresolved placeholder leaked into built artifacts"; else ok "no placeholder leak in built artifacts"; fi + else + bad "cannot check placeholder leak — built plist/run missing" + fi + if [ -f "$icns" ] && file "$icns" 2>/dev/null | grep -qi 'icon'; then ok "AppIcon.icns is a real icon file"; else bad "AppIcon.icns missing or not an icon"; fi + if [ "$mode" = "swift" ]; then + if [ -f "$wrap" ] && file "$wrap" 2>/dev/null | grep -q 'Mach-O'; then ok "Swift wrapper is a Mach-O executable"; else bad "Swift wrapper missing/not Mach-O"; fi + else + no_path "no Swift wrapper in chrome-fallback bundle" "$wrap" + if grep -q -- '--app=' "$run" 2>/dev/null; then ok "run script uses Chrome --app= launcher"; else bad "chrome run script missing --app="; fi + fi +} + +printf '%sapp-it behavioral fixture suite%s (HOME sandboxed at %s)\n' "$B" "$O" "$HOME" +note "arch=$ARCH ports=$PORT_LO-$PORT_HI real-lane=${APP_IT_RUN_REAL:-0}" + +# ============================================================================= +section "vite-basic — Vite detection + full single-server launch lifecycle" +setup_proj vite-basic app-it vite-basic +INSPECT="$(APP_IT_PROJECT_ROOT="$PROJ" bash "$PROJ/scripts/inspect.sh" 2>&1 || true)" +has "inspect detects vite.config.ts" "$INSPECT" "vite.config.ts" +has "inspect lists the dev script" "$INSPECT" "dev" +lacks "inspect emits no hardcoded-port warning" "$INSPECT" "hardcoded port literal" +build +assert_bundle "Vite Basic" vite-basic com.user.vite-basic swift +seam_up "Vite Basic" vite-basic +FIRST_PORT="$RUNTIME_PORT" +FIRST_PID="$(cat "$(state_dir vite-basic)/server.pid" 2>/dev/null || true)" +# desktop-doctor.sh — gives that (currently untested) script its first CI coverage. +APP_IT_PROJECT_ROOT="$PROJ" bash "$PROJ/scripts/desktop-doctor.sh" vite-basic >"$PROJ/doctor.log" 2>&1 || true +has "desktop-doctor confirms launcher owns the server" "$(cat "$PROJ/doctor.log")" "belongs to this launcher" +warm_reattach "Vite Basic" vite-basic "$FIRST_PID" "$FIRST_PORT" +quit_clean vite-basic "$FIRST_PORT" + +# ============================================================================= +section "next-basic — Next detection + bundle assembly" +setup_proj next-basic app-it next-basic +INSPECT="$(APP_IT_PROJECT_ROOT="$PROJ" bash "$PROJ/scripts/inspect.sh" 2>&1 || true)" +has "inspect detects next.config.js" "$INSPECT" "next.config.js" +has "inspect lists the dev script" "$INSPECT" "next dev" +lacks "inspect emits no hardcoded-port warning" "$INSPECT" "hardcoded port literal" +build +assert_bundle "Next Basic" next-basic com.user.next-basic swift + +# ============================================================================= +section "static-export — app-it-static export detection + real static-server serve" +setup_proj static-export app-it-static static-export +INSPECT="$(APP_IT_PROJECT_ROOT="$PROJ" bash "$PROJ/scripts/inspect-static.sh" 2>&1 || true)" +has "inspect-static detects Next static export" "$INSPECT" "Next.js (static export)" +has "inspect-static finds the prebuilt out/" "$INSPECT" "out/" +build +assert_bundle "Static Export" static-export com.user.static-export swift +is_file "static-server.py copied into the bundle" "$PROJ/desktop/Static Export.app/Contents/MacOS/static-server.py" +seam_up "Static Export" static-export +quit_clean static-export "$RUNTIME_PORT" + +# ============================================================================= +section "vite-express — A3.2 multiserver: dual-port launch + ownership" +setup_proj vite-express app-it vite-express +INSPECT="$(APP_IT_PROJECT_ROOT="$PROJ" bash "$PROJ/scripts/inspect.sh" 2>&1 || true)" +has "inspect flags a multi-server (A3) proxy target" "$INSPECT" "Multi-server cohabiting (A3) likely" +build +assert_bundle "Vite Express" vite-express com.user.vite-express swift +has "multiserver template selected (run script exports API_PORT)" "$(cat "$PROJ/desktop/Vite Express.app/Contents/MacOS/run")" "API_PORT" +seam_up "Vite Express" vite-express +FE_PORT="$RUNTIME_PORT"; BE_PORT="$(cat "$(state_dir vite-express)/backend.port" 2>/dev/null || true)" +if [ -n "$BE_PORT" ] && [ -n "$(lsof -ti tcp:"$BE_PORT" 2>/dev/null || true)" ]; then ok "backend listening on :$BE_PORT"; else bad "backend not listening (backend.port=$BE_PORT)"; fi +quit_clean vite-express "$FE_PORT" "$BE_PORT" + +# ============================================================================= +section "deep-tree — descendant-walk: ownership + warm-reattach across a gen-2 tree" +setup_proj deep-tree app-it deep-tree +build +assert_bundle "Deep Tree" deep-tree com.user.deep-tree swift +seam_up "Deep Tree" deep-tree # the oracle confirms the gen-2 listener is genuinely owned +DT_PID="$(cat "$(state_dir deep-tree)/server.pid" 2>/dev/null || true)"; DT_PORT="$RUNTIME_PORT" +# desktop-doctor must confirm ownership ACROSS the gen-2 tree (guards its walk). +APP_IT_PROJECT_ROOT="$PROJ" bash "$PROJ/scripts/desktop-doctor.sh" deep-tree >"$PROJ/doctor-dt.log" 2>&1 || true +has "desktop-doctor confirms ownership across a gen-2 process tree" "$(cat "$PROJ/doctor-dt.log")" "belongs to this launcher" +# Warm re-launch must reattach across the gen-2 tree — not cold-start a 2nd +# server on a new port. THIS is the assertion that fails if the descendant walk +# regresses to generation 1 (the macOS pgrep -P trap this fixture guards). +warm_reattach "Deep Tree" deep-tree "$DT_PID" "$DT_PORT" +DT_PORT2="$(cat "$(state_dir deep-tree)/server.port" 2>/dev/null || true)" +quit_clean deep-tree "$DT_PORT" "$DT_PORT2" + +# ============================================================================= +section "hardcoded-port — inspect must keep warning about port literals" +setup_proj hardcoded-port app-it hardcoded-port +INSPECT="$(APP_IT_PROJECT_ROOT="$PROJ" bash "$PROJ/scripts/inspect.sh" 2>&1 || true)" +has "inspect warns about the dev script's --port literal" "$INSPECT" "hardcoded port literal" +has "inspect warns about the vite.config server.port literal" "$INSPECT" "hardcoded server.port literal" + +# ============================================================================= +section "chrome-fallback — APP_IT_LAUNCHER_MODE=chrome build shape" +setup_proj chrome-fallback app-it chrome-fallback +build APP_IT_LAUNCHER_MODE=chrome +assert_bundle "Chrome Fallback" chrome-fallback com.user.chrome-fallback chrome + +# ============================================================================= +section "placeholder-icon-gen.sh emits a valid SVG (no rasterizer needed)" +ICONTMP="$WORK/iconcheck"; mkdir -p "$ICONTMP" +APP_NAME="Probe App" APP_SLUG="probe-app" APP_IT_PROJECT_ROOT="$ICONTMP" \ + bash "$REPO/plugins/app-it/skills/app-it/templates/placeholder-icon-gen.sh" >/dev/null 2>&1 || true +if [ -f "$ICONTMP/assets/probe-app-icon.svg" ] && grep -q '"$PROJ/npm.log" 2>&1; then + ok "npm install succeeded" + build + assert_bundle "Vite Real" vite-real com.user.vite-real swift + seam_up "Vite Real" vite-real + quit_clean vite-real "$RUNTIME_PORT" + else + bad "npm install failed"; tail -20 "$PROJ/npm.log" | sed 's/^/ /' + fi +else + section "vite-real — SKIPPED (set APP_IT_RUN_REAL=1 to run the real-framework lane)" + note "covered weekly by the fixtures-real CI job and at release" +fi + +# ============================================================================= +section "Summary" +printf ' %s%d passed%s · %s%d failed%s\n' "$G" "$PASS" "$O" "$R" "$FAIL" "$O" +if [ "$FAIL" -gt 0 ]; then exit 1; fi +echo "app-it fixture suite passed" diff --git a/scripts/validate.sh b/scripts/validate.sh index 66b736e..1aa60f2 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -114,11 +114,21 @@ else fi for file in install.sh \ + scripts/test-fixtures.sh \ plugins/app-it/skills/app-it/templates/*.sh \ plugins/app-it-static/skills/app-it-static/templates/*.sh; do bash -n "$file" done +# Fixture-suite stand-in servers (JS) — syntax-check when node is available. +if command -v node >/dev/null 2>&1; then + for js in scripts/lib/*.js; do + node --check "$js" + done +else + echo "note: node not found; skipping scripts/lib/*.js syntax check" +fi + # Python static server (app-it-static): syntax-check, then clean the bytecode # cache py_compile leaves behind so it never shows up as an untracked artifact. python3 -m py_compile plugins/app-it-static/skills/app-it-static/templates/static-server.py @@ -180,4 +190,8 @@ grep -qx 'name: app-it-windows' plugins/app-it-windows/skills/app-it-windows/SKI echo "" echo "Windows plugin present (beta) — validated in CI, not on macOS — see docs/WINDOWS.md" -echo "app-it validation passed" +# This script is the fast, static gate. The behavioral gate — which drives the +# real launcher scripts against tiny project fixtures (build, runtime port, +# server ownership, teardown) — is scripts/test-fixtures.sh (run by CI; run it +# locally for launcher/recipe changes). +echo "app-it validation passed (behavioral suite: ./scripts/test-fixtures.sh)"