app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
master
John Lauer
Test scripts: assemble the Visa test number at runtime so the page carries no card-shaped number
443d2ed
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
#!/usr/bin/env python3
"""Golden-fixture parity gate for the Rust pup exe (Phase 4 of docs/PLAN-rust-port.md).
Runs the SAME verb sequence the Node 2.0.399 fixtures in docs/contract/ were recorded from, against
the Rust exe on a desktop (hand-launched on a spare port, its own profile), pulls every response back,
and diffs each one against its golden: top-level keys missing/extra, JSON-type drift on shared keys,
and the same again inside the `output` string when it is JSON. `_hint`/`_next` text differences are
not shape drift and are ignored; a MISSING `_hint` is reported.
tools/parity.py --target winvm [--port 8867] [--exe-dir C:/Users/azureuser/pup-rs] [--profile rs-smoke]
tools/parity.py --via-ab --target winvm # same sequence through ab's relay + CLI (the installed bridge, not a spare port)
tools/parity.py --compare <responses.json> # re-diff a pulled file without re-running
Exit 0 = every fixture matches (no missing keys, no type drift); 1 = drift; 2 = harness failure.
"""
import argparse, json, os, subprocess, sys, tempfile
# the standard Visa test number, assembled at runtime: a literal card-shaped run trips the wiki's SENSITIVE scanner
TEST_CARD = ' '.join(['4111'] + ['1111'] * 3)
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GOLD = os.path.join(ROOT, 'docs', 'contract')
W = 'contract-fx'
# (fixture name, verb, args) in the order the goldens were recorded. Window "contract-fx", example.com.
SEQUENCE = [
('pup_readiness', 'pup_readiness', {}),
('pup_status', 'pup_status', {}),
('pup_describe', 'pup_describe', {}),
('pup_open_window', 'pup_open_window', {'window': W, 'url': 'https://example.com/', 'profile': '__PROFILE__', 'lane': 'cdp'}), # 2.0.563: a spare profile is a CDP-lane window and must say so
('pup_list_windows', 'pup_list_windows', {}),
('pup_my_windows', 'pup_my_windows', {}),
('pup_window_info', 'pup_window_info', {'window': W}),
('pup_navigate', 'pup_navigate', {'window': W, 'url': 'https://example.com/?nav=1'}),
('pup_eval', 'pup_eval', {'window': W, 'expr': 'document.title'}),
('pup_screenshot', 'pup_screenshot', {'window': W}),
('pup_errors', 'pup_errors', {'window': W}),
('pup_open_tab', 'pup_open_tab', {'window': W, 'url': 'https://example.com/?tab2'}),
('pup_list_tabs', 'pup_list_tabs', {'window': W}),
('pup_switch_tab', 'pup_switch_tab', {'window': W, 'tabId': 'tab-1'}),
('pup_close_tab', 'pup_close_tab', {'window': W, 'tabId': 'tab-2'}),
('pup_click', 'pup_click', {'window': W, 'selector': 'a'}),
('pup_hover', 'pup_hover', {'window': W, 'selector': 'a'}),
('pup_wait', 'pup_wait', {'window': W, 'ms': 300}),
('pup_type', 'pup_type', {'window': W, 'text': 'x'}),
('pup_fill', 'pup_fill', {'window': W, 'selector': 'body', 'text': 'x'}),
('pup_press_key', 'pup_press_key', {'window': W, 'key': 'Escape'}),
('pup_scroll', 'pup_scroll', {'window': W, 'direction': 'down'}),
('pup_reload', 'pup_reload', {'window': W}),
('pup_back', 'pup_back', {'window': W}),
('pup_set_viewport', 'pup_set_viewport', {'window': W, 'width': 1000, 'height': 700}),
('pup_set_viewport_reset', 'pup_set_viewport', {'window': W, 'reset': True}),
('pup_render_html', 'pup_render_html', {'window': W, 'html': '<h1>hi</h1>'}),
('pup_fetch_url', 'pup_fetch_url', {'window': W, 'url': 'https://example.com/'}),
('pup_maximize', 'pup_maximize', {'window': W}),
('pup_raise_os_window_refused', 'pup_raise_os_window', {'window': W}),
('pup_lower_os_window', 'pup_lower_os_window', {'window': W}),
('pup_alert_window', 'pup_alert_window', {'window': W}),
('pup_configure', 'pup_configure', {'overlaySize': '75'}),
('pup_rescan', 'pup_rescan', {}),
('pup_use', 'pup_use', {}),
('pup_switch_window', 'pup_switch_window', {'window': W}),
# ── extended coverage (goldens captured from Node 2.0.396 by tools/capture-goldens.py) ──
('credential_set', 'credential_set', {'host': 'the-internet.herokuapp.com', 'username': 'tomsmith', 'password': 'SuperSecretPassword!'}),
('pup_credentials', 'pup_credentials', {}),
('pup_credential_reveal', 'pup_credential_reveal', {'host': 'the-internet.herokuapp.com', 'reason': 'parity check of the reveal gate'}),
('credential_delete', 'credential_delete', {'host': 'the-internet.herokuapp.com'}),
('pup_card_set', 'pup_card_set', {'nickname': 'golden-visa', 'number': TEST_CARD, 'expiry': '12/30', 'name': 'Golden Tester', 'cvv': '123'}),
('pup_cards', 'pup_cards', {}),
('pup_card_delete', 'pup_card_delete', {'nickname': 'golden-visa'}),
('pup_prewarm', 'pup_prewarm', {}),
('pup_progress', 'pup_progress', {'window': W, 'mode': 'indeterminate', 'note': 'parity'}),
('pup_screenshot_element', 'pup_screenshot_element', {'window': W, 'selector': 'h1'}),
('pup_screenshot_full_res', 'pup_screenshot_full_res', {'window': W}),
('pup_install_toolbar', 'pup_install_toolbar', {}),
('pup_deps', 'pup_deps', {}),
('pup_highfps_extend', 'pup_highfps_extend', {'window': W, 'minutes': 20}),
('pup_annotate', 'pup_annotate', {'window': W, 'tool': 'pen'}),
('pup_forward', 'pup_forward', {'window': W}),
('pup_record_status', 'pup_record_status', {'window': W}),
('pup_record_list', 'pup_record_list', {'window': W}),
('pup_login', 'pup_login', {'window': W, 'submit': True}),
('pup_close_window', 'pup_close_window', {'window': W}),
]
IGNORE_TEXT = {'_hint', '_next', 'pitfalls', 'related', '_timeoutHint', 'durationMs'}
# Keys the ab CLI / relay stamps on the way back (the goldens were captured through the CLI), so the bridge itself never emits them.
CLI_ADDED = {'_timeoutHint', 'timeoutSeconds', 'savedTo', 'statusVerb', '_reportIssues', '_addressing'}
def ps_script(port, exe_dir, profile, out_path):
steps = json.dumps([[n, v, json.loads(json.dumps(a).replace('__PROFILE__', profile))] for n, v, a in SEQUENCE])
return f"""$ErrorActionPreference = 'Continue'
$dir = '{exe_dir}'
Get-Process adom-pup-bridge -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
$p = Start-Process -FilePath "$dir\\adom-pup-bridge.exe" -ArgumentList '--port','{port}' -WorkingDirectory $dir -RedirectStandardOutput "$dir\\parity.log" -RedirectStandardError "$dir\\parity.err" -WindowStyle Hidden -PassThru
Start-Sleep -Seconds 3
$thread = 'pup-bridge'
function Post($verb, $a) {{ $body = @{{ command = $verb; args = $a }} | ConvertTo-Json -Compress -Depth 8; try {{ $r = Invoke-WebRequest -Uri 'http://127.0.0.1:{port}/command' -Method Post -ContentType 'application/json' -Headers @{{ 'X-Adom-Caller-Thread' = $thread }} -Body $body -TimeoutSec 120 -UseBasicParsing; return $r.Content }} catch {{ $resp = $_.Exception.Response; if ($resp) {{ $sr = New-Object IO.StreamReader($resp.GetResponseStream()); return $sr.ReadToEnd() }} else {{ return ('{{"_harness_error":' + (ConvertTo-Json $_.Exception.Message) + '}}') }} }} }}
$steps = ConvertFrom-Json @'
{steps}
'@
$out = [ordered]@{{}}
foreach ($s in $steps) {{ $n = $s[0]; $v = $s[1]; $a = @{{}}; if ($s[2]) {{ $s[2].PSObject.Properties | ForEach-Object {{ $a[$_.Name] = $_.Value }} }}
$raw = Post $v $a; try {{ $out[$n] = ($raw | ConvertFrom-Json) }} catch {{ $out[$n] = @{{ _harness_error = 'non-json'; raw = $raw }} }}
Write-Output ("{{0,-30}} {{1}}" -f $n, $raw.Substring(0, [Math]::Min(90, $raw.Length)))
if ($n -eq 'pup_open_window' -or $n -eq 'pup_open_tab' -or $n -eq 'pup_navigate' -or $n -eq 'pup_click' -or $n -eq 'pup_wiki_set_view' -or $n -eq 'pup_render_html_anim') {{ Start-Sleep -Seconds 2 }}
if ($n -eq 'pup_record_start') {{ Start-Sleep -Seconds 5 }} }}
[IO.File]::WriteAllText('{out_path}', ($out | ConvertTo-Json -Depth 12 -Compress))
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
Write-Output "exe stopped"
"""
def jtype(v):
if v is None: return 'null'
if isinstance(v, bool): return 'bool'
if isinstance(v, (int, float)): return 'number'
if isinstance(v, str): return 'string'
if isinstance(v, list): return 'array'
return 'object'
def diff_obj(g, r, where):
out = []
for k in g:
if k in CLI_ADDED and k not in r: continue
if k == 'error' and g[k] == '' and k not in r: continue # CLI normalises error:"" on success
if k not in r: out.append(f'{where}MISSING {k}'); continue
if k in IGNORE_TEXT: continue
tg, tr = jtype(g[k]), jtype(r[k])
if tg != tr and 'null' not in (tg, tr): out.append(f'{where}TYPE {k}: node={tg} rust={tr}')
for k in r:
if k not in g: out.append(f'{where}extra {k}')
return out
def compare(responses):
drift = 0; extras = 0
for n, _, _ in SEQUENCE:
gp = os.path.join(GOLD, n + '.json')
if not os.path.exists(gp): print(f'{n:30} (no golden)'); continue
g = json.load(open(gp)); r = responses.get(n)
if not isinstance(r, dict): print(f'{n:30} NO RESPONSE'); drift += 1; continue
if '_harness_error' in r: print(f'{n:30} HARNESS {r}'); drift += 1; continue
gerr = bool(g.get('ok') is False or g.get('errorCode') or (isinstance(g.get('error'), str) and g.get('error')))
rerr = bool(r.get('ok') is False or r.get('errorCode') or r.get('success') is False)
if gerr != rerr: print(f"{n:30} SCENARIO golden was {'an error' if gerr else 'a success'} ({g.get('errorCode') or g.get('error') or ''}), rust was {'an error' if rerr else 'a success'} ({r.get('errorCode') or ''}): shapes not comparable"); continue
d = diff_obj(g, r, '')
go, ro = g.get('output'), r.get('output')
if isinstance(go, str) and go.startswith('{') and '_timeoutHint' in go and 'output' not in r: d = [x for x in d if x != 'MISSING output'] # the CLI mirrored the envelope into output
if isinstance(go, str) and go.startswith('{'):
try:
gj = json.loads(go); rj = json.loads(ro) if isinstance(ro, str) and ro.startswith('{') else None
if '_timeoutHint' in gj: pass # the CLI mirrored the whole envelope into output: not the bridge's shape
elif rj is None: d.append('output: node is JSON, rust is not')
else: d += diff_obj(gj, rj, 'output.')
except Exception as e: d.append(f'output parse: {e}')
bad = [x for x in d if not x.startswith('extra') and not x.startswith('output.extra')]
ext = [x for x in d if x not in bad]
extras += len(ext)
if bad: drift += 1; print(f'{n:30} DRIFT ' + '; '.join(bad) + ((' [+' + str(len(ext)) + ' extra]') if ext else ''))
else: print(f'{n:30} ok' + ((' [+' + str(len(ext)) + ' extra: ' + ', '.join(x.split()[-1] for x in ext)[:120] + ']') if ext else ''))
print(f'\n{len(SEQUENCE) - drift}/{len(SEQUENCE)} fixtures match; {extras} extra keys in rust responses (allowed)')
return 0 if drift == 0 else 1
def ab(target, verb, args):
p = subprocess.run(['adom-bridge', '--target', target, '--ai-thread', 'pup-bridge parity', verb, json.dumps(args)], capture_output=True, text=True, timeout=600)
try: return json.loads(p.stdout)
except Exception: return {'error': p.stdout[-400:] + p.stderr[-400:]}
def main():
ap = argparse.ArgumentParser(); ap.add_argument('--target'); ap.add_argument('--port', default='8867'); ap.add_argument('--exe-dir', default='C:/Users/azureuser/pup-rs'); ap.add_argument('--profile', default='rs-smoke'); ap.add_argument('--compare'); ap.add_argument('--via-ab', action='store_true')
a = ap.parse_args()
if a.compare: sys.exit(compare(json.load(open(a.compare))))
if not a.target: ap.error('--target or --compare')
if a.via_ab:
responses = {}
for n, v, args in SEQUENCE:
args = json.loads(json.dumps(args).replace('__PROFILE__', a.profile))
p = subprocess.run(['adom-bridge', '--target', a.target, '--ai-thread', 'pup-bridge', v, json.dumps(args)], capture_output=True, text=True, timeout=200)
try: responses[n] = json.loads(p.stdout)
except Exception: responses[n] = {'_harness_error': 'non-json', 'raw': (p.stdout + p.stderr)[-300:]}
print(f'{n:30} {p.stdout[:90].replace(chr(10), " ")}')
if v in ('pup_open_window', 'pup_open_tab', 'pup_navigate', 'pup_click', 'pup_wiki_set_view', 'pup_render_html'): import time; time.sleep(2)
if v == 'pup_record_start': import time; time.sleep(5)
out = os.path.join(tempfile.gettempdir(), 'parity-via-ab.json'); json.dump(responses, open(out, 'w'))
print('responses:', out); sys.exit(compare(responses))
exe_dir = a.exe_dir.replace('/', '\\'); out = a.exe_dir.rstrip('/') + '/parity-responses.json'
script = ps_script(a.port, exe_dir, a.profile, out.replace('/', '\\'))
r = ab(a.target, 'desktop_write_file', {'path': a.exe_dir.rstrip('/') + '/parity.ps1', 'content': script, 'reason': 'golden-fixture parity run for the Rust pup exe'})
if r.get('status') != 'ok': print('write failed', r); sys.exit(2)
r = ab(a.target, 'shell_execute', {'command': f'powershell -NoProfile -ExecutionPolicy Bypass -File {a.exe_dir.rstrip("/")}/parity.ps1', 'timeoutSeconds': 500, 'reason': 'run the parity sequence against the Rust exe'})
print(r.get('stdout') or r.get('output') or r)
r = ab(a.target, 'pull_file', {'filePaths': [out]})
paths = [f.get('path') for f in (r.get('files') or []) if isinstance(f, dict) and f.get('path')]
if not paths: print('pull failed', json.dumps(r)[:600]); sys.exit(2)
local = paths[0]; print('responses:', local)
sys.exit(compare(json.load(open(local))))
if __name__ == '__main__': main()