master
John Lauer selftest T10: name the window that took foreground. Running on John's own laptop while he worked, every click on his Ed… 53c5575 2mo ago
#!/usr/bin/env bash
# selftest.sh — THE full pup self-test (John, 2026-08-16: "do you have a FULL testing dev skill so
# you always know how to test yourself? build it and constantly run it.")
#
# Run AFTER EVERY SHIP + RESPAWN. Every check states its expectation and verdict; the run is RED if
# any check fails. Companion doc: dev-skills/pup-selftest/SKILL.md (what each check proves + the
# pixel-gate rules this script cannot automate).
#
# Usage: tools/selftest.sh [TARGET]      (default AdomLapper)
set -u
TARGET="${1:-AdomLapper}"
export ADOM_AI_THREAD="${ADOM_AI_THREAD:-pup-selftest}"
export ADOM_CONTAINER_NAME="${ADOM_CONTAINER_NAME:-galliaApril}"
CLI="adom-bridge --target $TARGET"
REPO="$(cd "$(dirname "$0")/.." && pwd)"
PASS=0; FAIL=0
ok()  { PASS=$((PASS+1)); echo "  PASS: $*"; }
bad() { FAIL=$((FAIL+1)); echo "  FAIL: $*"; }
J() { python3 -c "import sys,json;raw=sys.stdin.read();i=raw.find('{');d=json.loads(raw[i:]) if i>=0 else {};print(eval(\"$1\",{'d':d,'json':__import__('json')}))" 2>/dev/null; }

echo "== T1 STATIC: server parses + served dashboard script parses =="
node --check "$REPO/src/server.js" >/dev/null 2>&1 && ok "server.js parses" || bad "server.js does not parse"
node "$REPO/tests/check_dashboard_script.js" >/dev/null 2>&1 && ok "served dashboard <script> parses" || bad "dashboard script broken (the class that killed the Settings dialog twice)"

echo "== T2 UP-GATE: a real verb answers =="
UPV=""
for i in $(seq 1 15); do
  UPV=$(timeout 25 $CLI pup_status '{}' 2>&1 | J "d.get('version') or ''")
  [ -n "$UPV" ] && break; sleep 4
done
[ -n "$UPV" ] && ok "bridge answers, version=$UPV" || bad "bridge did not answer pup_status with a version (old build or down)"
LOCALV=$(cat "$REPO/src/BRIDGE_VERSION" 2>/dev/null)
[ "$UPV" = "$LOCALV" ] && ok "running version matches repo ($LOCALV)" || bad "running=$UPV repo=$LOCALV — STALE BRIDGE, install+respawn before trusting any other check"

echo "== T3 VERB SMOKE: open/navigate/eval/screenshot/close on own thread window =="
timeout 90 $CLI pup_open_window '{"sessionId":"st-web","url":"https://example.com","foreground":false}' >/dev/null 2>&1
R=$(timeout 60 $CLI pup_eval '{"sessionId":"st-web","expression":"document.title"}' 2>&1 | J "d.get('result') or ''")
echo "$R" | grep -qi example && ok "open+eval reads live DOM ('$R')" || bad "eval: '$R'"
SK=$(timeout 60 $CLI pup_screenshot '{"sessionId":"st-web"}' 2>&1 | J "d.get('sizeKB') or 0")
[ "${SK:-0}" -gt 5 ] 2>/dev/null && ok "screenshot captured (${SK}KB)" || bad "screenshot"

echo "== T4 SCROLL-REFLECT (#50 regression): eval scroll changes the screenshot =="
timeout 90 $CLI pup_open_window '{"sessionId":"st-tall","url":"https://en.wikipedia.org/wiki/Windows_11","foreground":false}' >/dev/null 2>&1
# A heavy page needs time to lay out before the FIRST shot; the old 5s only worked on a warm cache,
# and when a shot came back empty this reported "could not capture the scroll pair" with no evidence
# — the same blind-failure pattern T5 had. Now: longer settle, one retry, and it prints what it got.
shot() {
  for a in 1 2; do
    R=$(timeout 90 $CLI pup_screenshot '{"sessionId":"st-tall"}' 2>&1 | J "d.get('savedTo') or ''")
    [ -n "$R" ] && { echo "$R"; return; }
    sleep 4
  done
  echo ""
}
sleep 12
P1=$(shot)
timeout 60 $CLI pup_eval '{"sessionId":"st-tall","expression":"(window.scrollTo(0,4000),window.scrollY)"}' >/dev/null 2>&1
sleep 3
P2=$(shot)
if [ -n "$P1" ] && [ -n "$P2" ]; then
python3 - "$P1" "$P2" <<'SHOTCMP' && ok "scrolled screenshot differs from top" || bad "screenshots identical after scroll (#50 regressed)"
import sys,hashlib
from PIL import Image
a,b=(hashlib.md5(Image.open(p).tobytes()).hexdigest() for p in sys.argv[1:3])
sys.exit(0 if a!=b else 1)
SHOTCMP
else bad "could not capture the scroll pair (P1=${P1:-empty} P2=${P2:-empty}) — the screenshot verb returned no savedTo"; fi

echo "== T5 IDENTITY KEY: own window gets a pup AUMID registry key (mode-aware) =="
MODE=$(timeout 40 $CLI pup_status '{}' 2>&1 | J "(d.get('settings') or {}).get('taskbarMode') or 'unknown'")
echo "  (live taskbarMode at check time: $MODE — the user may flip it mid-run, that is legitimate)"
# In tiles mode the key is the thread AUMID (…thread.pup-selftest…); in aumid mode it is the
# per-window category id (Adom.Pup.st-web.…). Either proves the stamp/registry path. In plain
# mode there is deliberately no key, so pass with a note. Never flip the user's mode to test.
sleep 20
# NOTE: keep this probe simple. A previous version piped reg through findstr and read the result
# through the J() eval helper; when it reported False the check gave NO evidence, and the finding
# ("stamp/registry path broken") was wrong — the keys were there. A check that can fail must print
# what it saw.
KEYRAW=$(timeout 60 $CLI shell_execute '{"command":"reg query HKCU\\Software\\Classes\\AppUserModelId /f Adom.Pup /k 2>nul","reason":"selftest: list pup AUMID keys"}' 2>&1 | python3 -c "
import sys,json
raw=sys.stdin.read();i=raw.find('{')
d=json.loads(raw[i:]) if i>=0 else {}
print((d.get('stdout') or '').replace(chr(13),' ').replace(chr(10),' '))" 2>/dev/null)
# The expected key carries the slug of the THREAD that opened the windows — and that thread is
# whatever ADOM_AI_THREAD is in force, which the CALLER may have exported (this check used to
# hardcode "pup-selftest" and reported "stamp/registry path broken" whenever the harness was run
# from a shell with its own thread name exported — a false alarm that cost a debugging cycle).
THREAD_SLUG=$(printf '%s' "$ADOM_AI_THREAD" | tr 'A-Z' 'a-z' | sed 's/[^a-z0-9]\+/-/g; s/^-//; s/-$//')
case "$KEYRAW" in *"$THREAD_SLUG"*|*st-web*|*st-tall*) KEY=True ;; *) KEY=False ;; esac
[ "$KEY" = "False" ] && { echo "    expected a key containing: $THREAD_SLUG"; echo "    probe saw: $KEYRAW"; }
if [ "$MODE" = "plain" ]; then
  ok "plain mode: key state not asserted (registry keys without window properties are inert; what matters is the window property, which the converge pass owns)"
else
  [ "$KEY" = "True" ] && ok "pup AUMID registry key exists for the selftest window ($MODE mode)" || bad "no pup AUMID key for the selftest window in $MODE mode (stamp/registry path broken)"
fi

echo "== T6 JUMP LIST: commit log line for the thread AUMID exists =="
timeout 60 $CLI pup_configure '{"jumpLists":"on"}' >/dev/null 2>&1
sleep 8
JL=$(timeout 60 $CLI bridge_log_read '{"name":"puppeteer","tail":600}' 2>&1 | python3 -c "
import sys,json
d=json.load(sys.stdin); log=str(d.get('log') or '')
print('YES' if any(('jumplist' in l and 'set ' in l and 'task' in l) for l in log.splitlines()[-600:]) else 'NO')")
MODE6=$(timeout 40 $CLI pup_status '{}' 2>&1 | J "(d.get('settings') or {}).get('taskbarMode') or 'unknown'")
if [ "$MODE6" = "plain" ]; then ok "plain mode at check time: no pup AUMIDs, jump lists correctly quiet"
elif [ "$JL" = "YES" ]; then ok "jump-list commits present in log"
else
  # plain mode has NO pup AUMIDs, so zero commits is the CORRECT outcome — detect it by the registry
  PK=$(timeout 60 $CLI shell_execute '{"command":"reg query HKCU\\Software\\Classes\\AppUserModelId /f Adom.Pup /k 2>nul","reason":"selftest: any pup AUMID keys registered?"}' 2>&1 | J "('Adom.Pup' in (d.get('stdout') or ''))")
  if [ "$PK" = "False" ]; then ok "plain mode (zero pup AUMID keys): no jump lists to commit — correctly quiet"
  else bad "no jump-list commit lines despite registered pup AUMIDs (menus will be bare Pin/Close — John's screenshot bug)"; fi
fi

echo "== T7 ENUM RATE: lastMin sane; steady-state near zero =="
E1=$(timeout 40 $CLI pup_status '{}' 2>&1 | J "(d.get('enumWindows') or {}).get('total',-1)")
sleep 45
E2=$(timeout 40 $CLI pup_status '{}' 2>&1 | J "(d.get('enumWindows') or {}).get('total',-1)")
D=$((E2-E1))
[ "$D" -le 2 ] 2>/dev/null && ok "steady-state enum: $D calls in 45s" || bad "enum churn: $D calls in 45s idle (spam class regressed)"

echo "== T8 OVERLAY TOGGLE: off clears, on repaints (bookkeeping check; PIXEL gate is manual) =="
timeout 60 $CLI pup_configure '{"overlayBadges":"off"}' >/dev/null 2>&1; sleep 6
timeout 60 $CLI pup_configure '{"overlayBadges":"on"}' >/dev/null 2>&1
ok "toggle roundtrip issued (verify pixels per the skill before claiming to John)"

echo "== T10 FOREGROUND STEAL (#56 guard): a COLD pup_dashboard must not take focus =="
# #56 was reported live (a window jumped in front of John while he typed) and no longer reproduces,
# so this check exists to make sure it cannot come back quietly. Focus a window WE own, cold the
# dashboard profile, call pup_dashboard as an AI caller, and assert the focused window never changed.
timeout 90 $CLI pup_close_window '{"sessionId":"pup-dashboard"}' >/dev/null 2>&1
timeout 60 $CLI shell_execute '{"command":"powershell -NoProfile -Command \"Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like \\\"*pup-dashboard*\\\" } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }\"","reason":"selftest T10: cold the dashboard profile so the launch is a genuine cold start"}' >/dev/null 2>&1
timeout 120 $CLI pup_open_window '{"sessionId":"st-fg","url":"https://example.com","foreground":false}' >/dev/null 2>&1
sleep 4
timeout 90 $CLI pup_raise_os_window '{"sessionId":"st-fg","foregroundReason":"selftest T10 guard for issue 56: this window must remain focused across a cold dashboard launch"}' >/dev/null 2>&1
sleep 3
FG_BEFORE=$(timeout 60 $CLI shell_execute '{"command":"powershell -NoProfile -Command \"Add-Type -Name FGA -Namespace W -MemberDefinition '"'"'[DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow();'"'"'; [W.FGA]::GetForegroundWindow()\"","reason":"selftest T10: foreground window before the cold dashboard launch"}' 2>&1 | J "(d.get('stdout') or '').strip()")
timeout 120 $CLI pup_dashboard '{}' >/dev/null 2>&1
sleep 6
FG_AFTER=$(timeout 60 $CLI shell_execute '{"command":"powershell -NoProfile -Command \"Add-Type -Name FGB -Namespace W -MemberDefinition '"'"'[DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow();'"'"'; [W.FGB]::GetForegroundWindow()\"","reason":"selftest T10: foreground window after the cold dashboard launch"}' 2>&1 | J "(d.get('stdout') or '').strip()")
# hwnd 0 means NOTHING was focused, so "unchanged" proves nothing — that is a vacuous pass, the
# same class of lie this harness keeps having to unlearn. Say so instead of banking it.
# A CHANGED foreground is not automatically a steal. This check ran on John's own laptop while he
# was working, and every time he clicked his Edge window mid-test it reported "#56 REGRESSED" — a
# false alarm naming a shipped fix, which is worse than no check at all. The accusation is specific
# ("stolen BY A COLD pup_dashboard"), so only pup taking the foreground can support it: name the new
# foreground window and fail only if it is pup's. Anything else is the human using their machine.
FG_AFTER_TITLE=$(timeout 60 $CLI shell_execute '{"command":"powershell -NoProfile -Command \"Add-Type -Name FGT -Namespace W -MemberDefinition '"'"'[DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow(); [DllImport(\\\"user32.dll\\\", CharSet=CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr h, System.Text.StringBuilder s, int n);'"'"'; $h=[W.FGT]::GetForegroundWindow(); $sb=New-Object System.Text.StringBuilder 300; [void][W.FGT]::GetWindowTextW($h,$sb,300); $sb.ToString()\"","reason":"selftest T10: name the foreground window so a human focus change is not misreported as a pup steal"}' 2>&1 | J "(d.get('stdout') or '').strip()")
if [ "$FG_BEFORE" = "0" ] || [ -z "$FG_BEFORE" ]; then
  bad "T10 INCONCLUSIVE: no window held focus before the launch (hwnd '${FG_BEFORE:-empty}'), so a steal could not be detected — raise a window and re-run"
elif [ "$FG_BEFORE" = "$FG_AFTER" ]; then ok "cold pup_dashboard did NOT steal focus (foreground hwnd $FG_BEFORE unchanged)"
elif printf '%s' "$FG_AFTER_TITLE" | grep -qi "Chrome for Testing"; then
  bad "FOREGROUND STOLEN by a cold pup_dashboard (#56 regressed): $FG_BEFORE -> $FG_AFTER (\"$FG_AFTER_TITLE\")"
else
  ok "cold pup_dashboard did NOT steal focus (focus moved to a NON-pup window, \"$FG_AFTER_TITLE\" — the human was using the machine, which is not a steal)"
fi
timeout 60 $CLI pup_close_window '{"sessionId":"st-fg"}' >/dev/null 2>&1
timeout 60 $CLI pup_close_window '{"sessionId":"pup-dashboard"}' >/dev/null 2>&1

echo "== T9 CLEANUP: close test windows; nothing owned by pup-selftest remains; keys reaped =="
timeout 60 $CLI pup_close_window '{"sessionId":"st-web"}' >/dev/null 2>&1
timeout 60 $CLI pup_close_window '{"sessionId":"st-tall"}' >/dev/null 2>&1
sleep 5
LEFT=$(timeout 40 $CLI pup_list_windows '{}' 2>&1 | python3 -c "
import sys,json;raw=sys.stdin.read();i=raw.find('{');d=json.loads(raw[i:]) if i>=0 else {}
print(sum(1 for s in (d.get('sessions') or []) if s.get('owner')=='pup-selftest'))")
[ "$LEFT" = "0" ] && ok "no selftest windows left open" || bad "$LEFT selftest window(s) LEFT OPEN (John's rule: never leave test windows)"

echo
echo "== RESULT: $PASS passed, $FAIL failed =="
[ "$FAIL" -eq 0 ] && echo "GREEN — pup core features verified on $TARGET" || echo "RED — fix before telling the user anything works"
exit $FAIL