Implement role-based account journeys with database and browser acceptance suites
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
parent
75750c0036
commit
1127f852dd
24 changed files with 1554 additions and 148 deletions
51
scripts/browser_journeys.mjs
Normal file
51
scripts/browser_journeys.mjs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// No production accounts, network providers or header overrides are used.
|
||||
const [debug, base] = process.argv.slice(2);
|
||||
const version=await (await fetch(debug+'/json/version')).json();
|
||||
const ws=new WebSocket(version.webSocketDebuggerUrl);
|
||||
await new Promise(resolve=>ws.addEventListener('open',resolve,{once:true}));
|
||||
let next=0;const pending=new Map();
|
||||
ws.addEventListener('message',event=>{const m=JSON.parse(event.data);if(pending.has(m.id)){const p=pending.get(m.id);pending.delete(m.id);m.error?p.reject(Error(m.error.message)):p.resolve(m.result);}});
|
||||
const call=(method,params={},sessionId)=>new Promise((resolve,reject)=>{const id=++next;pending.set(id,{resolve,reject});ws.send(JSON.stringify({id,method,params,...(sessionId?{sessionId}:{})}));});
|
||||
const {targetId}=await call('Target.createTarget',{url:'about:blank'});
|
||||
const {sessionId}=await call('Target.attachToTarget',{targetId,flatten:true});
|
||||
const cmd=(method,params)=>call(method,params,sessionId);
|
||||
await cmd('Page.enable');await cmd('Network.enable');
|
||||
const evaluate=async expression=>{const r=await cmd('Runtime.evaluate',{expression,returnByValue:true});if(r.exceptionDetails)throw Error('Browser evaluation failed');return r.result.value;};
|
||||
async function waitFor(expression){for(let n=0;n<70;n++){if(await evaluate(expression))return;await new Promise(r=>setTimeout(r,100));}throw Error('Browser condition not met: '+expression);}
|
||||
async function navigate(path){await cmd('Page.navigate',{url:base+path});await waitFor('location.href === '+JSON.stringify(base+path)+' && document.readyState === "complete"');}
|
||||
async function identity(who){await cmd('Network.clearBrowserCookies');if(who)await cmd('Network.setCookie',{name:'ue_session',value:who,url:base,path:'/',httpOnly:true,sameSite:'Lax'});}
|
||||
let checks=0;async function check(expression,name){if(!await evaluate(expression))throw Error(name);console.log('PASS '+name);checks++;}
|
||||
try{
|
||||
await navigate('/');
|
||||
await check(`!!document.querySelector('a[href="/login"]') && !document.querySelector('a[href="/logout"]')`,'U01 anonymous session controls');
|
||||
await identity('member');await navigate('/onboarding');
|
||||
await check(`!!document.querySelector('a[href="/logout"]') && !document.querySelector('a[href="/login"]')`,'U01 authenticated session controls');
|
||||
await navigate('/platform');
|
||||
await check(`document.body.innerText.includes("Access is not available") && !!document.querySelector('a[href="/access-recovery"]')`,'T01 member denial has recovery');
|
||||
await navigate('/security');
|
||||
await check(`document.body.innerText.includes("Authenticator setup is temporarily unavailable")`,'U06 unavailable OTP is explicit');
|
||||
await identity('admin');await navigate('/admin/tenant:trial:demo-company');
|
||||
await check(`document.body.innerText.includes("Login name: actual.login")`,'T03 actual login name shown');
|
||||
await check(`document.body.innerText.includes("Onboarding follow-up")`,'T07 onboarding state visible');
|
||||
await evaluate(`Array.from(document.forms).find(f=>f.action.endsWith("/status")).querySelector("button").click()`);
|
||||
await waitFor('document.body.innerText.includes("Confirm change")');
|
||||
await check(`document.body.innerText.includes("Other tenant access and the shared login are retained")`,'T06 scope confirmation');
|
||||
await evaluate(`Array.from(document.links).find(a=>a.textContent==="Cancel without changes").click()`);
|
||||
await waitFor('location.pathname === "/"');
|
||||
await navigate('/admin/tenant:trial:demo-company');
|
||||
await check(`document.body.innerText.includes("active for this tenant")`,'T06 cancel preserves active account');
|
||||
await cmd('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true});
|
||||
await navigate('/security');
|
||||
await check(`document.documentElement.scrollWidth <= innerWidth`,'U12 security recovery fits mobile');
|
||||
await cmd('Emulation.clearDeviceMetricsOverride');
|
||||
await identity('operator');await navigate('/platform');
|
||||
await check(`!!document.querySelector('a[href="/platform/operations"]')`,'P01 platform recovery navigation');
|
||||
await navigate('/platform/operations');
|
||||
await check(`document.body.innerText.includes("Live sign-in, email receipt and authenticator health are not verified here")`,'P05 unknown provider health remains explicit');
|
||||
await navigate('/logout');
|
||||
await check(`document.body.innerText.includes("Log out of this portal?")`,'U11 logout requires confirmation');
|
||||
await evaluate(`document.querySelector('form[action="/logout"] button').click()`);
|
||||
await waitFor('location.pathname === "/logged-out"');
|
||||
await check(`!!document.querySelector('a[href="/login"]') && !document.querySelector('a[href="/logout"]')`,'U11 logout updates controls');
|
||||
console.log(JSON.stringify({checks,result:'passed',scope:'isolated browser and synthetic providers; live OTP/email not inferred'}));
|
||||
}finally{await call('Target.closeTarget',{targetId});ws.close();}
|
||||
47
scripts/browser_journeys.py
Normal file
47
scripts/browser_journeys.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Run Chromium against an isolated loopback-only portal with synthetic identities."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from threading import Thread
|
||||
import time
|
||||
from wsgiref.simple_server import make_server, WSGIRequestHandler
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path[:0]=[str(ROOT/'src'),str(ROOT/'tests')]
|
||||
from test_journey_roles import JourneyFixture
|
||||
|
||||
chrome=os.environ.get('JOURNEY_CHROME') or shutil.which('chromium') or shutil.which('google-chrome')
|
||||
if not chrome:
|
||||
matches=sorted((Path.home()/'.cache/ms-playwright').glob('chromium-*/chrome-linux64/chrome'))
|
||||
chrome=str(matches[-1]) if matches else None
|
||||
if not chrome or not shutil.which('node'):
|
||||
raise SystemExit('Chromium and Node are required; set JOURNEY_CHROME to the Chromium executable. Browser tests were not run.')
|
||||
fixture=JourneyFixture();fixture.setUp();fixture.member(email='actual.login@example.test')
|
||||
class Quiet(WSGIRequestHandler):
|
||||
def log_message(self,*args):pass
|
||||
server=make_server('127.0.0.1',0,fixture.app,handler_class=Quiet)
|
||||
worker=Thread(target=server.serve_forever,daemon=True);worker.start()
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix='user-engine-browser-') as profile:
|
||||
process=subprocess.Popen([chrome,'--headless','--no-sandbox','--disable-gpu','--remote-debugging-address=127.0.0.1',
|
||||
'--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
port_file=Path(profile)/'DevToolsActivePort'
|
||||
for _ in range(100):
|
||||
if port_file.exists():break
|
||||
if process.poll() is not None:raise RuntimeError('Chromium exited before test connection')
|
||||
time.sleep(.1)
|
||||
if not port_file.exists():raise RuntimeError('Chromium did not expose its test connection')
|
||||
port=port_file.read_text().splitlines()[0]
|
||||
subprocess.run(['node',str(ROOT/'scripts/browser_journeys.mjs'),'http://127.0.0.1:'+port,
|
||||
'http://127.0.0.1:'+str(server.server_port)],check=True,timeout=55)
|
||||
finally:
|
||||
process.terminate()
|
||||
try:process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:process.kill();process.wait()
|
||||
finally:
|
||||
server.shutdown();server.server_close();worker.join(timeout=5)
|
||||
39
scripts/run_journey_suites.py
Normal file
39
scripts/run_journey_suites.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Run mapped acceptance tests; report external gaps separately from test results."""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path[:0]=[str(ROOT/'src'),str(ROOT/'tests')]
|
||||
parser=argparse.ArgumentParser()
|
||||
parser.add_argument('--role',choices=['user','tenant_admin','platform_admin'])
|
||||
parser.add_argument('--report',type=Path)
|
||||
parser.add_argument('--require-complete',action='store_true',help='Fail while any journey has an implementation or external acceptance gap')
|
||||
args=parser.parse_args()
|
||||
rows=json.loads((ROOT/'tests/journey-coverage.json').read_text())['journeys']
|
||||
expected={f'U{i:02}' for i in range(1,14)}|{f'T{i:02}' for i in range(1,9)}|{f'P{i:02}' for i in range(1,9)}
|
||||
if len(rows)!=29 or {r['id'] for r in rows}!=expected:
|
||||
raise SystemExit('Journey coverage must contain each of the 29 journey IDs exactly once')
|
||||
rows=[r for r in rows if not args.role or r['role']==args.role]
|
||||
for row in rows:
|
||||
if not row['tests'] or row['implementation'] not in {'implemented','partial','external-blocked'}:
|
||||
raise SystemExit('Invalid coverage entry: '+row['id'])
|
||||
if row['implementation']!='implemented' and not row['remaining']:
|
||||
raise SystemExit('Unexplained journey gap: '+row['id'])
|
||||
selectors=sorted({name for row in rows for name in row['tests']})
|
||||
suite=unittest.TestSuite(unittest.defaultTestLoader.loadTestsFromName(name) for name in selectors)
|
||||
result=unittest.TextTestRunner(verbosity=2).run(suite)
|
||||
failed={test.id() for test,_ in result.failures+result.errors}
|
||||
skipped={test.id() for test,_ in result.skipped}
|
||||
report={'tests_run':result.testsRun,'test_success':result.wasSuccessful(),'skipped':len(skipped),
|
||||
'journeys':[dict(row,automated_result='failed' if failed.intersection(row['tests']) else 'skipped' if skipped.intersection(row['tests']) else 'passed') for row in rows],
|
||||
'complete':result.wasSuccessful() and not skipped and all(r['implementation']=='implemented' for r in rows)}
|
||||
if args.report:
|
||||
args.report.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.report.write_text(json.dumps(report,indent=2)+'\n')
|
||||
print(json.dumps({'tests_run':report['tests_run'],'test_success':report['test_success'],'complete':report['complete'],
|
||||
'unresolved_journeys':[r['id'] for r in rows if r['implementation']!='implemented']}))
|
||||
raise SystemExit(0 if result.wasSuccessful() and not skipped and (not args.require_complete or report['complete']) else 1)
|
||||
Loading…
Add table
Add a link
Reference in a new issue