From 182e487734f886c1c1d352f1a57aa52116185966 Mon Sep 17 00:00:00 2001 From: QA Date: Sat, 14 Nov 2026 00:43:05 +0000 Subject: [PATCH] Add verify-luinbytes-dev project-local verification skill --- .cursor/skills/verify-luinbytes-dev/SKILL.md | 90 +++++++ .../verify-luinbytes-dev/bin/_common.sh | 12 + .../verify-luinbytes-dev/bin/cleanup.sh | 28 ++ .../skills/verify-luinbytes-dev/bin/doctor.sh | 37 +++ .../skills/verify-luinbytes-dev/bin/drive.py | 255 ++++++++++++++++++ .../skills/verify-luinbytes-dev/bin/drive.sh | 14 + .../skills/verify-luinbytes-dev/bin/launch.sh | 57 ++++ .../skills/verify-luinbytes-dev/bin/setup.sh | 10 + .../verify-luinbytes-dev/features/README.md | 34 +++ .../features/contact-email.md | 29 ++ .../features/landing-nav.md | 30 +++ .../features/pond-feeding.md | 30 +++ .../features/pond-view.md | 27 ++ .../features/privacy-404.md | 30 +++ 14 files changed, 683 insertions(+) create mode 100644 .cursor/skills/verify-luinbytes-dev/SKILL.md create mode 100755 .cursor/skills/verify-luinbytes-dev/bin/_common.sh create mode 100755 .cursor/skills/verify-luinbytes-dev/bin/cleanup.sh create mode 100755 .cursor/skills/verify-luinbytes-dev/bin/doctor.sh create mode 100755 .cursor/skills/verify-luinbytes-dev/bin/drive.py create mode 100755 .cursor/skills/verify-luinbytes-dev/bin/drive.sh create mode 100755 .cursor/skills/verify-luinbytes-dev/bin/launch.sh create mode 100755 .cursor/skills/verify-luinbytes-dev/bin/setup.sh create mode 100644 .cursor/skills/verify-luinbytes-dev/features/README.md create mode 100644 .cursor/skills/verify-luinbytes-dev/features/contact-email.md create mode 100644 .cursor/skills/verify-luinbytes-dev/features/landing-nav.md create mode 100644 .cursor/skills/verify-luinbytes-dev/features/pond-feeding.md create mode 100644 .cursor/skills/verify-luinbytes-dev/features/pond-view.md create mode 100644 .cursor/skills/verify-luinbytes-dev/features/privacy-404.md diff --git a/.cursor/skills/verify-luinbytes-dev/SKILL.md b/.cursor/skills/verify-luinbytes-dev/SKILL.md new file mode 100644 index 0000000..757b77d --- /dev/null +++ b/.cursor/skills/verify-luinbytes-dev/SKILL.md @@ -0,0 +1,90 @@ +--- +name: verify-luinbytes-dev +description: Launch, drive, and prove the luinbytes.dev personal portfolio (Vite 8 + React 19 static export with an interactive koi pond) in headless Chromium. Use when you need to verify a change to its routes (/, /privacy, 404), nav, pond feeding or ASCII/Pixel view switch, contact copy-email button, or to capture screenshot/ARIA evidence of the real site running locally. +--- + +# Verify luinbytes.dev + +**Surface:** a static website visitors use in a browser. Routes: `/` (portfolio + live pond), `/privacy`, and a pond-themed 404 (`out/404.html`). No backend, no auth, no database. The only state is the browser page. +**What gets verified:** the production export (`npm run build` -> `out/`) served by the repo's own `npm run preview` (`scripts/serve-preview.mjs`, loopback only). This is the same artifact CI tests and GitHub Pages ships. `npm run dev` (vite, port 5173) exists for authoring but is not what this skill drives. +**Never** deploy, push, or drive the live https://luinbytes.dev from this skill; every journey runs against `127.0.0.1`. + +All paths below are relative to the repo root; helpers are in `.cursor/skills/verify-luinbytes-dev/bin/` (abbreviated `$S`): + +```bash +S=.cursor/skills/verify-luinbytes-dev/bin +``` + +Read `features/README.md` first, then the feature file for what you are verifying. + +## Launch + +```bash +$S/setup.sh # once per checkout: npm ci (if no node_modules), .venv + Python Playwright 1.61, Chromium. Idempotent. +$S/launch.sh # builds, starts preview on a free 127.0.0.1 port, prints BASE_URL=http://127.0.0.1: +``` + +- `launch.sh` runs `npm run build` (tsc + vite client + SSR + prerender; ~5 s warm), then `npm run preview -- --host 127.0.0.1 --port ` under `setsid` and records pid/port/HEAD in `/tmp/verify-luinbytes-dev-/state.env`. +- **Ready** means: server log shows `Ready in`, `GET /` contains the hero copy, and `GET /privacy` contains `Privacy Policy`. Not just a listening socket. +- It starts from an empty state every time (nothing to seed; the site has no data store). +- `SKIP_BUILD=1 $S/launch.sh` reuses an existing `out/` (doctor warns if HEAD moved). Do not use it after editing source. +- **Isolation:** one instance per checkout, because `out/` is a single shared build directory. `launch.sh` exits 2 if this checkout's instance is still alive. Two checkouts get separate state dirs and ports and can run side by side. Never drive an instance this run did not start. +- `npm run build` rewrites `public/sitemap.xml` (`prebuild` stamps `lastmod` with the last commit date). That shows up as a modified file in `git status`; `cleanup.sh` restores it. Do not commit it. + +## Doctor + +```bash +$S/doctor.sh # read-only; exit 0 + "DOCTOR: PASS" means worth driving +``` + +Checks: server pid alive, port owned by our process group, build is from current HEAD, `/` serves hero, `/privacy` serves the policy, unknown route returns 404, `.venv` Playwright imports, headless Chromium launches, evidence dir writable. Run it first whenever anything looks off. + +## Drive + +```bash +$S/drive.sh all [run-id] # every mapped journey +$S/drive.sh pond-feeding [run-id] # one journey +``` + +Journey ids = feature ids: `landing-nav`, `pond-feeding`, `pond-view`, `contact-email`, `privacy-404`. Each is a headless Chromium (1440x900) context driven by ARIA role/name locators (`get_by_role("navigation", name="Portfolio navigation")`, `get_by_role("radio", name="Pixel")`, `get_by_role("button", name="Copy email address")`, ...) with real mouse/keyboard input. Exit 0 only if every selected journey passed **and** logged no console errors or page errors. The pond journey additionally reads `data-*` attributes on `[data-pond-state]` (food and fish counters) that the site exposes when `window.__POND_TEST_DIAGNOSTICS__` is true (set by the harness; read-only, same hook the repo's own e2e uses). The click that feeds fish is a real mouse click. + +For ad-hoc exploration, reuse `drive.py`'s helpers or write a throwaway Playwright script against `BASE_URL` from `state.env`; use `?pond-seed=` on `/` for a deterministic pond. Prefer role locators; the pond is a canvas, so water points are found by hovering until `data-food-affordance="true"`. + +## Evidence + +Written to `/evidence/luinbytes.dev//` (override with `VERIFY_EVIDENCE_ROOT`; `` defaults to a timestamp). For this checkout that is `/workspace/qa/evidence/luinbytes.dev//`. It is outside the repo and outside the `/tmp` state dir, so `cleanup.sh` never removes it. + +Per journey `/`: numbered screenshots (`NN-.png`: the action and the resulting state), `*.aria.txt` ARIA snapshots, `result.json` (checks, console/page errors, base URL, HEAD sha), `traceback.txt` + `NN-FAILURE.png` on failure. Run-level `summary.json`. + +Proof standards: +- Exercise the real user path: clicks on the real nav links, real water, real button. No internal setters, no direct calls to app state. +- Capture the action *and* the resulting state, not just the final screen (e.g. before-click pond, food-dropped, fish-fed). +- Verify side effects alongside what is visible: pellet counters in the pond model, clipboard contents after Copy, the URL hash and `aria-current` after nav, HTTP status for 404s. +- The site makes no network calls beyond its own origin (strict CSP in `scripts/security-policy.mjs`), so there is nothing to mock. External links (GitHub, cal.com, X, mailto) are asserted by `href` only; never click them out. +- A skipped entry point is reported as skipped, never as verified through another path. + +## Cleanup + +```bash +$S/cleanup.sh # idempotent +``` + +Kills only the process group `launch.sh` created (pid recorded in state.env; refuses if the pid is not our group leader), removes `out/` and `.prerender/` when launch built them, restores `public/sitemap.xml` (`git checkout --`; any manual edit to that file is lost), and deletes the `/tmp` state dir. **Evidence is not touched.** Run it after every attempt, including failed ones. After cleanup, `git status --short` should show only your intended changes, and `ss -ltn` should not list the recorded port. + +## Helpers + +| Script | Invocation | Purpose | +|---|---|---| +| `bin/setup.sh` | `$S/setup.sh` | one-time deps: node_modules, `.venv` (gitignored), Playwright Chromium | +| `bin/launch.sh` | `$S/launch.sh` (`SKIP_BUILD=1` optional) | build + start preview, print `BASE_URL` | +| `bin/doctor.sh` | `$S/doctor.sh` | read-only health gate | +| `bin/drive.sh` | `$S/drive.sh [run-id]` | run journeys, write evidence | +| `bin/drive.py` | called by `drive.sh` only | Playwright journeys; add a function + `JOURNEYS` entry for a new feature | +| `bin/cleanup.sh` | `$S/cleanup.sh` | tear down what launch started; keep evidence | +| `bin/_common.sh` | sourced by the others | repo root, state dir, evidence root | + +## Existing checks (run these too; they are what CI runs) + +`npm ci`, `npm audit --audit-level=high`, `npm run lint`, `npm run test:pond` (54 node tests), `npm run check` (tsc), then `PATH="$PWD/.venv/bin:$PATH" npm run test:e2e` (builds, then 49 Python Playwright tests, ~3.5 min; it owns its own preview server and rebuilds `out/`, so **never run it while `launch.sh`'s instance is up**). CI is `.github/workflows/checks.yml`. + +Keeping this map honest as the site changes: `/maintain-verification-skill`. diff --git a/.cursor/skills/verify-luinbytes-dev/bin/_common.sh b/.cursor/skills/verify-luinbytes-dev/bin/_common.sh new file mode 100755 index 0000000..e2ae839 --- /dev/null +++ b/.cursor/skills/verify-luinbytes-dev/bin/_common.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Shared by launch.sh / doctor.sh / cleanup.sh. Source it; do not run it. +set -euo pipefail +SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(cd "$SKILL_DIR/../../.." && pwd)" +KEY="$(printf '%s' "$REPO_ROOT" | md5sum | cut -c1-12)" +# One live instance per checkout: `out/` is a single shared build directory. +STATE_DIR="/tmp/verify-luinbytes-dev-$KEY" +STATE_ENV="$STATE_DIR/state.env" +# Evidence lives OUTSIDE the repo and OUTSIDE STATE_DIR so cleanup never touches it. +EVIDENCE_ROOT="${VERIFY_EVIDENCE_ROOT:-$(dirname "$REPO_ROOT")/evidence/luinbytes.dev}" +VENV_PY="$REPO_ROOT/.venv/bin/python" diff --git a/.cursor/skills/verify-luinbytes-dev/bin/cleanup.sh b/.cursor/skills/verify-luinbytes-dev/bin/cleanup.sh new file mode 100755 index 0000000..e7498c3 --- /dev/null +++ b/.cursor/skills/verify-luinbytes-dev/bin/cleanup.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Tear down ONLY what launch.sh started. Never touches evidence. Idempotent. +# Usage: .cursor/skills/verify-luinbytes-dev/bin/cleanup.sh +source "$(dirname "$0")/_common.sh" +cd "$REPO_ROOT" +[ -f "$STATE_ENV" ] || { echo "nothing to clean (no state)"; exit 0; } +# shellcheck disable=SC1090 +source "$STATE_ENV" + +if kill -0 "$SERVER_PID" 2>/dev/null; then + # Kill the process group we created with setsid (pid == pgid), not anything by name. + if [ "$(ps -o pgid= -p "$SERVER_PID" | tr -d ' ')" = "$SERVER_PID" ]; then + kill -TERM -- "-$SERVER_PID" 2>/dev/null || true + for _ in $(seq 1 20); do kill -0 "$SERVER_PID" 2>/dev/null || break; sleep 0.25; done + kill -0 "$SERVER_PID" 2>/dev/null && kill -KILL -- "-$SERVER_PID" 2>/dev/null || true + else + echo "pid $SERVER_PID is not our process-group leader; refusing to kill it" >&2 + fi +fi +ss -ltnH "sport = :$PORT" 2>/dev/null | grep -q . && echo "WARN port $PORT still listening" >&2 + +if [ "$BUILT" = 1 ]; then + # Build scaffolding: gitignored dirs plus the sitemap lastmod that `prebuild` rewrites. + rm -rf "$REPO_ROOT/out" "$REPO_ROOT/.prerender" + git checkout -- public/sitemap.xml +fi +rm -rf "$STATE_DIR" +echo "cleanup ok (evidence under $EVIDENCE_ROOT untouched)" diff --git a/.cursor/skills/verify-luinbytes-dev/bin/doctor.sh b/.cursor/skills/verify-luinbytes-dev/bin/doctor.sh new file mode 100755 index 0000000..019e1b2 --- /dev/null +++ b/.cursor/skills/verify-luinbytes-dev/bin/doctor.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Read-only: is the instance started by launch.sh worth driving? Exit 0 = yes. +# Usage: .cursor/skills/verify-luinbytes-dev/bin/doctor.sh +source "$(dirname "$0")/_common.sh" +cd "$REPO_ROOT" +fail=0 +ok() { echo "ok $*"; } +bad() { echo "FAIL $*"; fail=1; } +warn() { echo "warn $*"; } + +[ -f "$STATE_ENV" ] || { bad "no state at $STATE_ENV (run launch.sh)"; exit 1; } +# shellcheck disable=SC1090 +source "$STATE_ENV" + +kill -0 "$SERVER_PID" 2>/dev/null && ok "server pid $SERVER_PID alive" || bad "server pid $SERVER_PID dead" +# Port must be owned by a process in OUR process group (setsid made pid == pgid). +owner="$(ss -ltnpH "sport = :$PORT" 2>/dev/null | grep -o 'pid=[0-9]*' | head -1 | cut -d= -f2 || true)" +if [ -n "$owner" ] && [ "$(ps -o pgid= -p "$owner" | tr -d ' ')" = "$SERVER_PID" ]; then ok "port $PORT owned by our process group (pid $owner)"; else bad "port $PORT not owned by our server (owner pid: ${owner:-none})"; fi + +[ "$(git rev-parse HEAD)" = "$HEAD_SHA" ] && ok "build is from HEAD ${HEAD_SHA:0:9}" || warn "HEAD moved since launch (${HEAD_SHA:0:9} -> $(git rev-parse --short=9 HEAD)); rebuild via cleanup + launch" +if [ -n "$(git status --porcelain -- app components lib public/images index.html 2>/dev/null)" ]; then warn "uncommitted app changes; served build may be stale unless launched after them"; fi + +body="$(curl -fsS "$BASE_URL/" || true)" +echo "$body" | grep -q "Hi, I" && echo "$body" | grep -q "stubborn" && ok "GET / serves the hero" || bad "GET / missing hero copy" +curl -fsS "$BASE_URL/privacy" | grep -q "Privacy Policy" && ok "GET /privacy serves the policy" || bad "GET /privacy broken" +code="$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/definitely-not-a-route")" +[ "$code" = 404 ] && ok "unknown route -> 404" || bad "unknown route returned $code (want 404)" + +"$VENV_PY" -c 'import playwright' 2>/dev/null && ok "python playwright importable" || bad ".venv lacks playwright (run setup.sh)" +"$VENV_PY" - <<'PY' && ok "headless chromium launches" || bad "chromium cannot launch (run setup.sh)" +from playwright.sync_api import sync_playwright +with sync_playwright() as p: + b = p.chromium.launch(); b.close() +PY +mkdir -p "$EVIDENCE_ROOT" 2>/dev/null && [ -w "$EVIDENCE_ROOT" ] && ok "evidence root writable: $EVIDENCE_ROOT" || bad "evidence root not writable: $EVIDENCE_ROOT" +[ $fail = 0 ] && echo "DOCTOR: PASS ($BASE_URL)" || echo "DOCTOR: FAIL" +exit $fail diff --git a/.cursor/skills/verify-luinbytes-dev/bin/drive.py b/.cursor/skills/verify-luinbytes-dev/bin/drive.py new file mode 100755 index 0000000..d8dca87 --- /dev/null +++ b/.cursor/skills/verify-luinbytes-dev/bin/drive.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +"""Journeys for verify-luinbytes-dev. Run via drive.sh, not directly. + +Every journey: real user input (mouse/keyboard/role locators), a screenshot of the action +and of the resulting state, an ARIA snapshot, and assertions on what the visitor sees. +""" +import json +import os +import sys +import time +import traceback +from pathlib import Path + +from playwright.sync_api import TimeoutError as PWTimeout, expect, sync_playwright + +BASE = os.environ["BASE_URL"] +OUT = Path(os.environ["EVIDENCE_DIR"]) + + +class Journey: + def __init__(self, feature, browser, **ctx): + self.feature, self.n, self.checks = feature, 0, [] + self.dir = OUT / feature + self.dir.mkdir(parents=True, exist_ok=True) + self.context = browser.new_context(**ctx) + self.page = self.context.new_page() + self.errors = [] + self.page.on("pageerror", lambda e: self.errors.append(f"pageerror: {e}")) + self.page.on("console", lambda m: self.errors.append(f"console.{m.type}: {m.text}") + if m.type == "error" else None) + # Read-only observation hook the site itself ships (exposes data-* state; changes no behaviour). + self.page.add_init_script("window.__POND_TEST_DIAGNOSTICS__ = true") + + def shot(self, name): + self.n += 1 + path = self.dir / f"{self.n:02d}-{name}.png" + self.page.screenshot(path=str(path)) + return path.name + + def aria(self, name): + (self.dir / f"{name}.aria.txt").write_text(self.page.locator("body").aria_snapshot()) + + def check(self, label, ok, detail=""): + self.checks.append({"check": label, "ok": bool(ok), "detail": str(detail)}) + if not ok: + raise AssertionError(f"{label}: {detail}") + + def finish(self, status, error=None): + self.page.close() + self.context.close() + result = {"feature": self.feature, "status": status, "base_url": BASE, + "head_sha": os.environ.get("HEAD_SHA"), "checks": self.checks, + "console_errors_and_pageerrors": self.errors, "error": error} + (self.dir / "result.json").write_text(json.dumps(result, indent=2)) + return result + + +def wait_for(page, expr, arg=None, timeout=30_000): + end = time.monotonic() + timeout / 1000 + while time.monotonic() < end: + v = page.evaluate(expr, arg) + if v: + return v + time.sleep(0.1) + raise PWTimeout(f"condition never true: {expr}") + + +def pond(page): + return page.locator("[data-pond-state]") + + +def open_water(page): + """Hover a grid of points until the pond reports a feedable (unobstructed water) spot.""" + vp = page.viewport_size + for y in range(90, vp["height"] - 30, 55): + for x in range(30, vp["width"] - 30, 55): + page.mouse.move(x, y) + if pond(page).get_attribute("data-food-affordance") == "true": + return x, y + raise AssertionError("no feedable water point found in the viewport") + + +def load_home(j, query="?pond-seed=verify-skill"): + j.page.goto(f"{BASE}/{query}", wait_until="networkidle") + wait_for(j.page, "document.querySelector('[data-pond-state]')?.dataset.fishPositions") + + +# ---------------------------------------------------------------- journeys + +def landing_nav(j): + p = j.page + load_home(j) + expect(p.get_by_role("heading", level=1)).to_have_text("Hi, I’m Lu.") + expect(p.get_by_role("heading", name="I make stubborn software behave.")).to_be_visible() + j.check("hero h1 + statement visible", True) + nav = p.get_by_role("navigation", name="Portfolio navigation") + j.check("nav has Work/More/Contact", + [a.inner_text() for a in nav.get_by_role("link").all()] == ["Work", "More", "Contact"]) + j.shot("hero-initial") + j.aria("landing-initial") + for name, anchor, heading in [("Work", "#work", "A few useful obsessions."), + ("More", "#about", "“Mostly works” is still broken."), + ("Contact", "#contact", "Got a useful problem?")]: + nav.get_by_role("link", name=name, exact=True).click() + p.wait_for_function("h => location.hash === h", arg=anchor) + expect(nav.get_by_role("link", name=name, exact=True)).to_have_attribute("aria-current", "location") + j.check(f"nav {name} -> {anchor} and aria-current set", True) + j.shot(f"after-click-{name.lower()}") + # Work section: every project card shows a heading and a View-project link. + p.get_by_role("link", name="Things I’ve made", exact=True).click() + cards = p.locator("#work article") + j.check("4 project cards", cards.count() == 4, cards.count()) + for project in ["HomeBot", "Rakazo", "linux-sonar", "ConnectorMap"]: + expect(cards.get_by_role("heading", name=project, exact=True)).to_be_visible() + expect(cards.get_by_role("link", name=f"View project {project}", exact=True)).to_have_attribute("href", __import__("re").compile(r"^https://")) + j.check("each project card has heading + external View project link", True) + j.shot("work-cards") + j.aria("landing-work") + + +def pond_feeding(j): + p = j.page + load_home(j) + el = pond(p) + before = int(el.get_attribute("data-food-dropped-count")) + j.check("pond running", el.get_attribute("data-pond-state") == "running", el.get_attribute("data-pond-state")) + x, y = open_water(p) + j.shot("before-click-pond") + p.mouse.click(x, y) + wait_for(p, "b => Number(document.querySelector('[data-pond-state]').dataset.foodDroppedCount) > b", before, 15_000) + j.shot("food-dropped") + after = int(el.get_attribute("data-food-dropped-count")) + j.check("click on water dropped exactly one pellet group", after == before + 1, f"{before}->{after}") + j.check("food entity visible in model", int(el.get_attribute("data-food-count")) > 0) + j.check("drop landed where requested", el.get_attribute("data-food-last-drop") == el.get_attribute("data-food-requested-at")) + wait_for(p, "document.querySelector('[data-pond-state]').dataset.foodReservations", None, 20_000) + j.shot("fish-reserved-food") + wait_for(p, "Number(document.querySelector('[data-pond-state]').dataset.fishFedCount) > 0", None, 40_000) + j.shot("fish-fed") + j.check("a fish reached and ate the food", int(el.get_attribute("data-fish-fed-count")) > 0) + j.check("no fish left the water", el.get_attribute("data-fish-water-violation") == "false") + j.check("food cap respected (<=8)", int(el.get_attribute("data-food-max-count")) <= 8) + (j.dir / "pond-state.json").write_text(json.dumps( + {k.replace("data-", ""): v for k, v in p.evaluate( + "() => Object.fromEntries(Object.entries(document.querySelector('[data-pond-state]').dataset).map(([k,v])=>['data-'+k,v]))").items() + if "positions" not in k}, indent=2)) + + +def pond_view(j): + p = j.page + load_home(j) + group = p.get_by_role("group", name="Pond view") + ascii_, pixel = group.get_by_role("radio", name="ASCII"), group.get_by_role("radio", name="Pixel") + expect(ascii_).to_be_checked() + expect(p.locator("[data-view]")).to_have_attribute("data-view", "ascii") + j.shot("ascii-default") + pixel.check() + expect(p.locator("[data-view]")).to_have_attribute("data-view", "pixel") + expect(pond(p)).to_have_attribute("data-pond-state", "running") + j.check("Pixel selected -> data-view=pixel and pond running", True) + wait_for(p, "document.querySelector('[data-pond-state]')?.dataset.fishPositions") + j.shot("pixel-selected") + ascii_.check() + expect(p.locator("[data-view]")).to_have_attribute("data-view", "ascii") + expect(pond(p)).to_have_attribute("data-pond-state", "running") + j.check("switching back to ASCII restores ascii view", True) + j.shot("ascii-restored") + j.aria("pond-view") + + +def contact_email(j): + p = j.page + j.context.grant_permissions(["clipboard-read", "clipboard-write"], origin=BASE) + load_home(j) + contact = p.locator("#contact") + mail_link = contact.locator("a[href^='mailto:']").first + email = mail_link.get_attribute("href").removeprefix("mailto:") + j.check("contact shows a mailto link with an address", "@" in email, email) + contact.scroll_into_view_if_needed() + j.shot("contact-before-copy") + btn = contact.get_by_role("button", name="Copy email address") + expect(btn).to_have_text("Copy") + btn.click() + expect(btn).to_have_text("Copied") + expect(contact.get_by_role("status")).to_have_text("Email address copied.") + clip = p.evaluate("navigator.clipboard.readText()") + j.check("clipboard now holds the address", clip == email, clip) + j.shot("contact-after-copy") + expect(btn).to_have_text("Copy", timeout=6_000) + j.check("button reverts to Copy after ~2.4s", True) + cta = contact.get_by_role("link", name="Book a call") + j.check("Book a call links to an https calendar page", (cta.get_attribute("href") or "").startswith("https://"), cta.get_attribute("href")) + j.aria("contact") + + +def privacy_404(j): + p = j.page + load_home(j) + p.get_by_role("link", name="Privacy", exact=True).click() # footer link + p.wait_for_url(f"{BASE}/privacy") + expect(p.get_by_role("heading", level=1)).to_have_text("Privacy Policy") + j.check("footer Privacy link -> /privacy with policy h1", True) + j.shot("privacy-page") + j.aria("privacy") + p.get_by_role("link", name="Back to luinbytes.dev").click() + p.wait_for_url(f"{BASE}/") + j.check("Back link returns to /", True) + resp = p.goto(f"{BASE}/pip", wait_until="networkidle") # retired route, must be 404 + j.check("retired route /pip returns HTTP 404", resp.status == 404, resp.status) + expect(p.get_by_role("heading", name="Nothing surfaced here.")).to_be_visible() + j.shot("not-found-page") + j.errors = [e for e in j.errors if "status of 404" not in e] # the 404 navigation above is deliberate + p.get_by_role("link", name="Return to the pond").click() + p.wait_for_url(f"{BASE}/") + expect(p.get_by_role("heading", level=1)).to_have_text("Hi, I’m Lu.") + j.check("Return to the pond -> home", True) + j.shot("back-home") + j.aria("not-found") + + +JOURNEYS = {"landing-nav": landing_nav, "pond-feeding": pond_feeding, "pond-view": pond_view, + "contact-email": contact_email, "privacy-404": privacy_404} +VIEWPORT = {"width": 1440, "height": 900} + + +def main(): + ids = list(JOURNEYS) if sys.argv[1] == "all" else [sys.argv[1]] + unknown = [i for i in ids if i not in JOURNEYS] + if unknown: + sys.exit(f"unknown feature {unknown}; choose from {list(JOURNEYS)} or all") + results = [] + with sync_playwright() as pw: + browser = pw.chromium.launch() + for fid in ids: + j = Journey(fid, browser, viewport=VIEWPORT) + try: + JOURNEYS[fid](j) + results.append(j.finish("PASS")) + except Exception as e: # noqa: BLE001 - report every failure with its reason + try: + j.shot("FAILURE") + except Exception: + pass + results.append(j.finish("FAIL", f"{type(e).__name__}: {e}")) + (j.dir / "traceback.txt").write_text(traceback.format_exc()) + browser.close() + clean = lambda r: r["status"] == "PASS" and not r["console_errors_and_pageerrors"] + for r in results: + print(f"{r['status']} {r['feature']} checks={len(r['checks'])} errors={len(r['console_errors_and_pageerrors'])}" + + (f" {r['error']}" if r["error"] else "")) + (OUT / "summary.json").write_text(json.dumps(results, indent=2)) + sys.exit(0 if all(clean(r) for r in results) else 1) + + +main() diff --git a/.cursor/skills/verify-luinbytes-dev/bin/drive.sh b/.cursor/skills/verify-luinbytes-dev/bin/drive.sh new file mode 100755 index 0000000..7d9615f --- /dev/null +++ b/.cursor/skills/verify-luinbytes-dev/bin/drive.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Drive mapped features in headless Chromium against the instance launch.sh started. +# Usage: drive.sh |all [run-id] +# feature ids: landing-nav | pond-feeding | pond-view | contact-email | privacy-404 +# Evidence: $EVIDENCE_ROOT///{NN-*.png,*.aria.txt,result.json}; exit 0 only if every journey passed. +source "$(dirname "$0")/_common.sh" +[ -f "$STATE_ENV" ] || { echo "no running instance; run launch.sh" >&2; exit 2; } +# shellcheck disable=SC1090 +source "$STATE_ENV" +RUN_ID="${2:-$(date +%Y%m%d-%H%M%S)}" +export BASE_URL EVIDENCE_DIR="$EVIDENCE_ROOT/$RUN_ID" HEAD_SHA +mkdir -p "$EVIDENCE_DIR" +echo "evidence -> $EVIDENCE_DIR" +exec "$VENV_PY" "$(dirname "$0")/drive.py" "${1:?feature id or all}" diff --git a/.cursor/skills/verify-luinbytes-dev/bin/launch.sh b/.cursor/skills/verify-luinbytes-dev/bin/launch.sh new file mode 100755 index 0000000..6f812ca --- /dev/null +++ b/.cursor/skills/verify-luinbytes-dev/bin/launch.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Build the production export and start the repo's own preview server on a free +# loopback port. Prints `BASE_URL=http://127.0.0.1:` on success. +# Usage: .cursor/skills/verify-luinbytes-dev/bin/launch.sh +# SKIP_BUILD=1 reuse an existing out/ (doctor will warn if it is stale) +source "$(dirname "$0")/_common.sh" +cd "$REPO_ROOT" + +if [ -f "$STATE_ENV" ]; then + # shellcheck disable=SC1090 + source "$STATE_ENV" + if kill -0 "$SERVER_PID" 2>/dev/null; then + echo "REFUSING: an instance for this checkout is already running ($BASE_URL, pid $SERVER_PID)." >&2 + echo "Run cleanup.sh first, or drive that instance only if YOU started it." >&2 + exit 2 + fi + rm -rf "$STATE_DIR" # stale state from a dead instance +fi + +[ -d node_modules ] && [ -x "$VENV_PY" ] || { echo "Run bin/setup.sh first." >&2; exit 3; } +mkdir -p "$STATE_DIR" + +BUILT=0 +if [ -z "${SKIP_BUILD:-}" ]; then + echo "building (npm run build) ..." >&2 + npm run build > "$STATE_DIR/build.log" 2>&1 || { echo "BUILD FAILED, see $STATE_DIR/build.log" >&2; tail -30 "$STATE_DIR/build.log" >&2; exit 4; } + BUILT=1 +fi + +PORT="$("$VENV_PY" -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1])')" +# setsid => the server is its own process-group leader, so cleanup can kill exactly this group. +setsid npm run preview -- --host 127.0.0.1 --port "$PORT" > "$STATE_DIR/server.log" 2>&1 & +SERVER_PID=$! +cat > "$STATE_ENV" </dev/null; then echo "server exited early:" >&2; cat "$STATE_DIR/server.log" >&2; "$(dirname "$0")/cleanup.sh" >&2 || true; exit 5; fi + if grep -q "Ready in" "$STATE_DIR/server.log" \ + && curl -fsS "http://127.0.0.1:$PORT/" | grep -q "stubborn" \ + && curl -fsS "http://127.0.0.1:$PORT/privacy" | grep -q "Privacy Policy"; then + echo "BASE_URL=http://127.0.0.1:$PORT" + echo "STATE_DIR=$STATE_DIR (server.log, build.log live here; scratch, removed by cleanup)" + exit 0 + fi + sleep 0.5 +done +echo "server never became ready" >&2; cat "$STATE_DIR/server.log" >&2 +"$(dirname "$0")/cleanup.sh" >&2 || true +exit 6 diff --git a/.cursor/skills/verify-luinbytes-dev/bin/setup.sh b/.cursor/skills/verify-luinbytes-dev/bin/setup.sh new file mode 100755 index 0000000..b338758 --- /dev/null +++ b/.cursor/skills/verify-luinbytes-dev/bin/setup.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Idempotent one-time setup: npm deps + Python Playwright venv + Chromium. +# Usage: .cursor/skills/verify-luinbytes-dev/bin/setup.sh +source "$(dirname "$0")/_common.sh" +cd "$REPO_ROOT" +[ -d node_modules ] || npm ci +[ -x "$VENV_PY" ] || python3 -m venv .venv +"$VENV_PY" -c 'import playwright' 2>/dev/null || "$VENV_PY" -m pip install -q -r requirements-test.txt +"$VENV_PY" -m playwright install chromium # no-op when already installed +echo "setup ok: node_modules + .venv (gitignored) + chromium" diff --git a/.cursor/skills/verify-luinbytes-dev/features/README.md b/.cursor/skills/verify-luinbytes-dev/features/README.md new file mode 100644 index 0000000..1c0b848 --- /dev/null +++ b/.cursor/skills/verify-luinbytes-dev/features/README.md @@ -0,0 +1,34 @@ +# luinbytes.dev verification map + +Maintained source for verifying what a visitor of luinbytes.dev can do. Read this index, then the matching feature file, then drive it with `bin/drive.sh `. + +## Baseline preconditions + +- Run `bin/setup.sh` once, then `bin/launch.sh`; require `bin/doctor.sh` to print `DOCTOR: PASS`. +- The instance is `http://127.0.0.1:` serving the production export. `drive.sh` reads the URL from the state file; do not hard-code a port. +- No data to seed. Every journey starts from a fresh browser context at 1440x900, default (ASCII) pond view, no reduced-motion preference. +- Never drive an instance this run did not start, and never touch https://luinbytes.dev. + +## Driving conventions + +- Prefer ARIA roles and accessible names. Stable handles: navigation `Portfolio navigation`, group `Pond view`, button `Copy email address`, headings by text. +- The pond is canvas-like and decorative (`aria-hidden`); observe it through `[data-pond-state]` `data-*` counters (available because the harness sets `window.__POND_TEST_DIAGNOSTICS__ = true`) plus screenshots. +- Use `?pond-seed=verify-skill` on `/` for a repeatable pond. +- External links are asserted by `href`, never followed. + +## Proof and skip reporting + +- Screenshot the action and the resulting state; save an ARIA snapshot where text structure matters. +- Record the feature ID with every artifact (the evidence folder is named by feature ID). +- Report an unreachable path with the attempted command and the unmet precondition. Do not report a skipped entry point as verified through a different path. +- The run fails on any console error or uncaught page error (the deliberate 404 navigation in `privacy-404` is the only filtered one). + +## Features + +- [landing-nav](./landing-nav.md): hero, header nav anchors with active-section state, and the four project cards. +- [pond-feeding](./pond-feeding.md): click open water, a pellet drops, a fish reserves it and eats it. +- [pond-view](./pond-view.md): ASCII / Pixel pond view switch. +- [contact-email](./contact-email.md): copy the email address, mailto and book-a-call links. +- [privacy-404](./privacy-404.md): footer Privacy page, retired URLs rendering the pond 404, and the way back home. + +Not mapped yet (add when needed): responsive/mobile layouts (320/390 px), `prefers-reduced-motion`, light/dark system color modes, no-JavaScript navigation, share-card/OG metadata. The repo's own `npm run test:e2e` already covers these. diff --git a/.cursor/skills/verify-luinbytes-dev/features/contact-email.md b/.cursor/skills/verify-luinbytes-dev/features/contact-email.md new file mode 100644 index 0000000..642c864 --- /dev/null +++ b/.cursor/skills/verify-luinbytes-dev/features/contact-email.md @@ -0,0 +1,29 @@ +# Contact: copy email and book a call + +The Contact section shows Lu's email as a `mailto:` link with a `Copy` button that puts the address on the clipboard, plus a `Book a call` link to Cal.com. + +## Sub-features + +- `contact-mailto` the address is a `mailto:` link in `#contact`. +- `contact-copy` `Copy email address` copies it, flips to `Copied`, announces `Email address copied.`, and reverts after about 2.4 s. +- `contact-book` `Book a call` points at an https calendar page. + +## How to get to it (user POV) + +- Click `Contact` in the header (or scroll to the bottom), then press `Copy` beside the email. +- The footer `Email` link is a second mailto entry point (asserted by href in the ARIA snapshot only). + +## Driving it with drive.sh (Playwright) + +Preconditions: doctor passes; the context is granted `clipboard-read` and `clipboard-write` for `BASE_URL` (the journey does this). + +- **Before.** `bin/drive.sh contact-email`. In `#contact`, the mailto link yields the address; button `Copy email address` reads `Copy`. Screenshot `01-contact-before-copy.png`. +- **Copy.** Click the button. It reads `Copied`; the `status` region reads `Email address copied.`; `navigator.clipboard.readText()` equals the mailto address. Screenshot `02-contact-after-copy.png`. +- **Revert.** Within 6 s the button text returns to `Copy`. +- **Book.** `Book a call` href starts with `https://` (not clicked). ARIA `contact.aria.txt`. + +## Gotchas + +- Without clipboard permission the site shows the fallback status `Couldn’t copy automatically...`; that is a precondition failure of the harness, not a site bug. +- The email is read from the page, never hard-coded, so the journey survives an address change. +- Never click `mailto:` or Cal.com links: they leave the site. diff --git a/.cursor/skills/verify-luinbytes-dev/features/landing-nav.md b/.cursor/skills/verify-luinbytes-dev/features/landing-nav.md new file mode 100644 index 0000000..ab10e80 --- /dev/null +++ b/.cursor/skills/verify-luinbytes-dev/features/landing-nav.md @@ -0,0 +1,30 @@ +# Landing page and navigation + +The home page introduces Lu, offers three header anchors (Work, More, Contact) that scroll to sections and mark the current one, and lists four featured projects. + +## Sub-features + +- `hero` shows `Hi, I’m Lu.` as the h1 and `I make stubborn software behave.` as the statement. +- `nav-anchors` moves to `#work`, `#about`, `#contact` and sets `aria-current="location"` on the matching link. +- `hero-cta` links `Things I’ve made` (to `#work`) and `Say hello` (mailto). +- `project-cards` shows four cards, each with a heading and an external `View project ` link. + +## How to get to it (user POV) + +- Open `/`. +- Click `Work`, `More`, or `Contact` in the `Portfolio navigation` header. +- Click `Things I’ve made` in the hero. + +## Driving it with drive.sh (Playwright) + +Preconditions: doctor passes; fresh context at 1440x900. + +- **Load.** `bin/drive.sh landing-nav`. `/?pond-seed=verify-skill` loads, h1 text is `Hi, I’m Lu.`, the statement heading is visible. Screenshot `01-hero-initial.png`. +- **Nav.** For each of `Work`, `More`, `Contact`, click the link in the `Portfolio navigation` header. `location.hash` becomes `#work` / `#about` / `#contact` and that link has `aria-current="location"`. Screenshots `02`-`04`. +- **Cards.** Click hero `Things I’ve made`. `#work article` count is 4; headings `HomeBot`, `Rakazo`, `linux-sonar`, `ConnectorMap` each have a `View project ` link whose `href` starts with `https://`. Screenshot `05-work-cards.png`, ARIA `landing-work.aria.txt`. + +## Gotchas + +- Card headings come from `lib/portfolio-content.ts` (`name`), not the repo names: `Rakazo`, not `rakazo-android`. Update `drive.py` when that file changes. +- `aria-current` is set by a scroll listener; assert it after the hash change, and expect `Contact` to win at the very bottom of the page. +- Section headings use curly quotes (`“Mostly works” is still broken.`); typing straight quotes will not match. diff --git a/.cursor/skills/verify-luinbytes-dev/features/pond-feeding.md b/.cursor/skills/verify-luinbytes-dev/features/pond-feeding.md new file mode 100644 index 0000000..9f2a4f3 --- /dev/null +++ b/.cursor/skills/verify-luinbytes-dev/features/pond-feeding.md @@ -0,0 +1,30 @@ +# Feed the fish + +Clicking open water in the background pond drops food; a koi reserves a pellet, swims to it, and eats. + +## Sub-features + +- `feed-drop` a click on feedable water drops exactly one pellet group where clicked. +- `feed-reserve` a fish reserves the food (`data-food-reservations` non-empty). +- `feed-eat` a fish completes the cycle (`data-fish-fed-count` > 0), never leaves the water, and food on screen never exceeds 8. + +## How to get to it (user POV) + +- Open `/`, move the pointer over open water (the hint reads `Stay a while. Feed the fish. Click or tap the water`), and click. +- Touch/tap works the same on mobile (not driven by this skill yet). + +## Driving it with drive.sh (Playwright) + +Preconditions: doctor passes; page loaded at `/?pond-seed=verify-skill` and `[data-pond-state]` has `data-fish-positions`. + +- **Find water.** `bin/drive.sh pond-feeding`. The harness hovers a 55 px grid until `[data-pond-state]` reports `data-food-affordance="true"`. Screenshot `01-before-click-pond.png`. +- **Click.** Real `mouse.click` at that point. `data-food-dropped-count` goes from N to N+1 and `data-food-count` > 0; `data-food-last-drop` equals `data-food-requested-at`. Screenshot `02-food-dropped.png`. +- **Reserve and eat.** `data-food-reservations` becomes non-empty (≤20 s), then `data-fish-fed-count` > 0 (≤40 s). Screenshots `03-fish-reserved-food.png`, `04-fish-fed.png`; final counters in `pond-state.json`. +- **Invariants.** `data-fish-water-violation="false"`, `data-food-max-count` ≤ 8. + +## Gotchas + +- The hero card and nav obscure the pond (`data-pond-obscures`); clicks there do nothing. That is why the harness searches for an affordance instead of using fixed coordinates. +- The `data-*` counters exist only when `window.__POND_TEST_DIAGNOSTICS__` is true. Without it the pond still works but is unobservable through attributes. +- Eating can take 10-30 s; do not shorten the 40 s wait. Do not weaken the journey if a fish never eats: report a failure with `pond-state.json`. +- A right-click must not drop food (covered by the repo's own e2e `test_food_drop_reservation_and_feeding`, not by this journey). diff --git a/.cursor/skills/verify-luinbytes-dev/features/pond-view.md b/.cursor/skills/verify-luinbytes-dev/features/pond-view.md new file mode 100644 index 0000000..60c3e2b --- /dev/null +++ b/.cursor/skills/verify-luinbytes-dev/features/pond-view.md @@ -0,0 +1,27 @@ +# Pond view switch + +A `Pond view` radio group in the bottom-right corner switches the pond between the ASCII renderer (default) and the Pixel (three.js) renderer. + +## Sub-features + +- `view-default` ASCII is checked on load. +- `view-pixel` choosing Pixel sets `data-view="pixel"` and the pond keeps running. +- `view-restore` choosing ASCII goes back with the pond still running. + +## How to get to it (user POV) + +- On `/`, use the `ASCII` / `Pixel` radio pair at the bottom-right of the viewport (group name `Pond view`). Visible at every screen size; hidden without JavaScript. + +## Driving it with drive.sh (Playwright) + +Preconditions: doctor passes; `/` loaded and the pond reporting `data-fish-positions`. + +- **Default.** `bin/drive.sh pond-view`. Radio `ASCII` is checked, `[data-view]` has `data-view="ascii"`. Screenshot `01-ascii-default.png`. +- **Pixel.** `group.get_by_role("radio", name="Pixel").check()`. `[data-view]` becomes `pixel`, `[data-pond-state]` is `running`, fish positions reappear. Screenshot `02-pixel-selected.png`. +- **Back.** Check `ASCII`. `data-view="ascii"`, state `running`. Screenshot `03-ascii-restored.png`, ARIA `pond-view.aria.txt`. + +## Gotchas + +- Pixel mode loads its renderer lazily; assert with auto-waiting `expect(...)`, not an instant read. +- Pixel pond screenshots need a moment to settle (rely on the `data-view`/`data-pond-state` assertions first). Failure/context-loss recovery paths are covered by the repo's e2e, not this journey. +- Radios are visually styled labels; locate by role `radio` and name, not by the `` stones. diff --git a/.cursor/skills/verify-luinbytes-dev/features/privacy-404.md b/.cursor/skills/verify-luinbytes-dev/features/privacy-404.md new file mode 100644 index 0000000..1864b60 --- /dev/null +++ b/.cursor/skills/verify-luinbytes-dev/features/privacy-404.md @@ -0,0 +1,30 @@ +# Privacy page and pond 404 + +`/privacy` is the short personal-use privacy policy, reached from the footer. Any retired or unknown URL renders the pond-themed 404 with a way back. + +## Sub-features + +- `privacy-page` footer `Privacy` link opens `/privacy` with h1 `Privacy Policy`. +- `privacy-back` `Back to luinbytes.dev` returns to `/`. +- `not-found` a retired route such as `/pip` returns HTTP 404 and shows `Nothing surfaced here.`. +- `not-found-back` `Return to the pond` returns to `/`. + +## How to get to it (user POV) + +- On `/`, click `Privacy` in the footer. Or type `/privacy` directly. +- Type a removed URL (`/pip`, `/meteor`, `/linux-sonar`, ...) or any unknown path. + +## Driving it with drive.sh (Playwright) + +Preconditions: doctor passes (it already checks `GET /privacy` and unknown-route 404 over HTTP). + +- **Privacy.** `bin/drive.sh privacy-404`. Click footer `Privacy` -> URL is `/privacy`, h1 `Privacy Policy`. Screenshot `01-privacy-page.png`, ARIA `privacy.aria.txt`. +- **Back.** Click `Back to luinbytes.dev` -> URL `/`. +- **404.** `goto(/pip)`: response status 404, heading `Nothing surfaced here.` visible. Screenshot `02-not-found-page.png`, ARIA `not-found.aria.txt`. +- **Home.** Click `Return to the pond` -> `/`, h1 `Hi, I’m Lu.`. Screenshot `03-back-home.png`. + +## Gotchas + +- The 404 navigation logs a browser console error (`Failed to load resource ... 404`). The journey filters exactly that one message; any other console or page error fails the run. +- The preview server maps unknown paths to `404.html` with status 404; `/404` and `/404.html` also return 404. Static hosting on GitHub Pages behaves similarly but is not exercised here. +- The footer is far down the page; use role+name `Privacy` (exact) to avoid matching the privacy text elsewhere.