app
Pup - Puppeteer Bridge
Public Made by Adomby adom
pup is the AI's own browser: a real, full Chrome on the user's desktop that the AI fully controls (a sandbox, not the user's signed-in browser). Rides Bridge; pup_* verbs open windows and tabs, navigate, screenshot, and eval JS.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
#!/usr/bin/env bash
# ── pup CHARACTERIZATION SUITE (restructure step 0) ────────────────────────────────────────────
#
# Locks the CURRENT observable behaviour of every verb BEFORE the restructure moves any code.
# This is not a correctness suite — it does not assert what pup *should* do. It records what pup
# DOES do, so that when the 3,601-line handler is split into verbs/ modules, any behavioural drift
# shows up as a diff instead of as a user-reported bug three days later.
#
# Usage:
# tests/characterize.sh baseline # record current behaviour -> tests/baseline.json
# tests/characterize.sh check # re-run and diff against the baseline
#
# Rules this suite obeys (from dev-skills/pup-bridge-test):
# - up-gate first: never test a bridge that is still reconstructing
# - never >/dev/null an open: capture ok + error + errorCode
# - clean up every window it creates
# - use a throwaway thread name so no real thread's window is touched
set -u
TARGET="${ADOM_TARGET:-AdomLapper}"
THREAD="pup-characterize"
MODE="${1:-check}"
DIR="$(cd "$(dirname "$0")" && pwd)"
OUT="$DIR/baseline.json"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
AD() { timeout 75 adom-desktop --target "$TARGET" --ai-thread "$THREAD" "$@" 2>/dev/null; }
# Record only the SHAPE + key invariants of a response, not volatile values (timings, ids, paths).
# A restructure must preserve shape and invariants; it is allowed to change a duration.
shape() {
python3 -c "
import sys, json
raw = sys.stdin.read()
try:
d = json.loads(raw)
except Exception:
print(json.dumps({'_unparseable': raw[:200]})); raise SystemExit
o = d.get('output', d)
if isinstance(o, str):
try: o = json.loads(o)
except Exception: pass
if not isinstance(o, dict): o = {'_scalar': type(o).__name__}
# Volatile = values that legitimately change run to run, PLUS advisory fields that pup emits
# INTERMITTENTLY by design (issue-report nudges, cleanup reports, occasional hints). Those come and
# go on purpose, so comparing them produces false drift — and a suite that cries wolf gets ignored,
# which is worse than no suite. Presence is normalised, not just the value.
VOLATILE = {'durationMs','ts','timestamp','createdAt','updatedAt','ageMinutes','pid','cdpPort',
'hwnd','shotId','path','localSafePath','fullPath','tabId','sessionId','url','title',
'progressBarMs','progressBarCount','flashCount','bounds','rect','_hint','_next',
'_timeoutHint','_reportIssues','_cleanupReport','_degraded','_urlLint','lastAgentUpdate',
# intermittent-by-design advisory/status fields:
'active','activeSession','activeTabId','hint','_verifyRender','_autoLogin','_flashCount',
'errorCount','tabCount','overlay','ownerSource','owner','background','flash','emulatedViewport',
'lastForeground','restoredTabsClosed','_appOverlay','degraded','degradedReason','profile',
'windows','sessions','tabs','count','_screenshots','_related','_cacheHint','_skillsHint',
'_staleWarning','staleBridges','staleCheckedAt','summary','bridges','current_os'}
def keyshape(x, depth=0):
if depth > 3: return '…'
if isinstance(x, dict):
return {k: ('<volatile>' if k in VOLATILE else keyshape(v, depth+1))
for k, v in sorted(x.items())}
if isinstance(x, list):
return [keyshape(x[0], depth+1)] if x else []
if isinstance(x, bool): return 'bool'
if isinstance(x, (int, float)): return 'num'
if x is None: return 'null'
return 'str'
print(json.dumps({'ok': d.get('ok', o.get('ok')), 'errorCode': d.get('errorCode') or o.get('errorCode'),
'shape': keyshape(o)}, sort_keys=True))
"
}
record() { # record <name> <<< response
local name="$1"
python3 -c "
import sys, json
name = sys.argv[1]
body = sys.stdin.read().strip() or '{}'
print(json.dumps({'verb': name, 'result': json.loads(body)}))
" "$name"
}
echo "── pup characterization ($MODE) ──"
# ── up-gate ────────────────────────────────────────────────────────────────────────────────────
for i in $(seq 1 15); do
AD browser_list_windows | python3 -c "
import sys, json
try: sys.exit(0 if json.load(sys.stdin).get('count') is not None else 1)
except Exception: sys.exit(1)" && break
sleep 6
done
VER=$(AD bridge_list | python3 -c "
import sys, json
try:
print(next((b.get('version') for b in json.load(sys.stdin).get('bridges', []) if b.get('name') == 'puppeteer'), '?'))
except Exception: print('?')")
echo " bridge: $VER"
: > "$TMP/results.jsonl"
SID="char-$$"
# ── read-only verbs (safe, no side effects) ────────────────────────────────────────────────────
for v in browser_readiness browser_status browser_list_windows browser_describe browser_deps; do
AD "$v" '{}' | shape | record "$v" >> "$TMP/results.jsonl"
done
# ── lifecycle: open → drive → close ────────────────────────────────────────────────────────────
AD browser_open_window "{\"sessionId\":\"$SID\",\"url\":\"https://example.com\",\"reason\":\"characterization suite\"}" \
| shape | record browser_open_window >> "$TMP/results.jsonl"
sleep 4
AD browser_eval "{\"sessionId\":\"$SID\",\"expr\":\"1+1\"}" | shape | record browser_eval >> "$TMP/results.jsonl"
AD browser_navigate "{\"sessionId\":\"$SID\",\"url\":\"https://example.org\"}" | shape | record browser_navigate >> "$TMP/results.jsonl"
AD browser_open_tab "{\"sessionId\":\"$SID\",\"url\":\"https://www.iana.org\"}" | shape | record browser_open_tab >> "$TMP/results.jsonl"
AD browser_list_tabs "{\"sessionId\":\"$SID\"}" | shape | record browser_list_tabs >> "$TMP/results.jsonl"
AD browser_scroll "{\"sessionId\":\"$SID\",\"y\":200}" | shape | record browser_scroll >> "$TMP/results.jsonl"
AD browser_set_viewport "{\"sessionId\":\"$SID\",\"width\":900,\"height\":600}" | shape | record browser_set_viewport >> "$TMP/results.jsonl"
AD browser_set_viewport "{\"sessionId\":\"$SID\",\"reset\":true}" | shape | record browser_set_viewport_reset >> "$TMP/results.jsonl"
AD browser_screenshot "{\"sessionId\":\"$SID\"}" | shape | record browser_screenshot >> "$TMP/results.jsonl"
AD browser_errors "{\"sessionId\":\"$SID\"}" | shape | record browser_errors >> "$TMP/results.jsonl"
AD browser_maximize "{\"sessionId\":\"$SID\"}" | shape | record browser_maximize >> "$TMP/results.jsonl"
# ── refusals: these MUST keep refusing, with the same errorCode ────────────────────────────────
AD browser_open_window '{"url":"https://example.com"}' | shape | record refusal_no_identity >> "$TMP/results.jsonl"
AD browser_eval '{"sessionId":"definitely-not-a-real-session","expr":"1"}' | shape | record refusal_no_session >> "$TMP/results.jsonl"
AD browser_open_window '{"sessionId":"lint-check","url":"file:///home/adom/x.html"}' | shape | record refusal_container_path >> "$TMP/results.jsonl"
AD browser_raise_os_window "{\"sessionId\":\"$SID\"}" | shape | record refusal_bare_raise >> "$TMP/results.jsonl"
# ── cleanup ────────────────────────────────────────────────────────────────────────────────────
AD browser_close_window "{\"sessionId\":\"$SID\",\"reason\":\"characterization done\"}" \
| shape | record browser_close_window >> "$TMP/results.jsonl"
python3 -c "
import json, sys
rows = [json.loads(l) for l in open('$TMP/results.jsonl') if l.strip()]
print(json.dumps({'bridge': '$VER', 'cases': rows}, indent=2, sort_keys=True))
" > "$TMP/current.json"
if [ "$MODE" = "baseline" ]; then
cp "$TMP/current.json" "$OUT"
echo " baseline written: $OUT ($(python3 -c "
import json; print(len(json.load(open('$OUT'))['cases']))") cases)"
exit 0
fi
if [ ! -f "$OUT" ]; then
echo " NO BASELINE. Run: tests/characterize.sh baseline"
exit 2
fi
python3 -c "
import json
base = {c['verb']: c['result'] for c in json.load(open('$OUT'))['cases']}
cur = {c['verb']: c['result'] for c in json.load(open('$TMP/current.json'))['cases']}
drift = []
for verb in sorted(set(base) | set(cur)):
b, c = base.get(verb), cur.get(verb)
if b != c:
drift.append((verb, b, c))
print(f' cases: {len(cur)} | drift: {len(drift)}')
for verb, b, c in drift:
print(f' ✗ {verb}')
print(f' baseline: {json.dumps(b)[:150]}')
print(f' current : {json.dumps(c)[:150]}')
raise SystemExit(1 if drift else 0)
"