master
John Lauer Test scripts: assemble the Visa test number at runtime so the page carries no card-shaped number 443d2ed 1mo ago
#!/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()