|
|
|
@@ -1,21 +1,23 @@
|
|
|
|
|
// Manual QA against a fresh world on localhost:8080. Uses Chrome's virtual passkeys, not real secrets.
|
|
|
|
|
import {execFileSync} from 'node:child_process';
|
|
|
|
|
import {execFile} from 'node:child_process';
|
|
|
|
|
import {promisify} from 'node:util';
|
|
|
|
|
import {mkdirSync} from 'node:fs';
|
|
|
|
|
import assert from 'node:assert/strict';
|
|
|
|
|
const session='sausage-credential-ui-check';
|
|
|
|
|
const browser=(...args)=>execFileSync('agent-browser',['--session',session,...args],{encoding:'utf8'}).trim();
|
|
|
|
|
const execute=promisify(execFile);
|
|
|
|
|
const browser=async(...args)=>(await execute('agent-browser',['--session',session,...args],{encoding:'utf8'})).stdout.trim();
|
|
|
|
|
mkdirSync('/tmp/T91-shots',{recursive:true});
|
|
|
|
|
const shot=(name,width=1100,height=1000)=>{browser('set','viewport',String(width),String(height));console.log(browser('screenshot','/tmp/T91-shots/'+name+'.png'));};
|
|
|
|
|
const shot=async(name,width=1100,height=1000)=>{await browser('set','viewport',String(width),String(height));console.log(await browser('screenshot','/tmp/T91-shots/'+name+'.png'));};
|
|
|
|
|
const get=async path=>(await fetch('http://localhost:8080/api/cred/'+path)).json();
|
|
|
|
|
const file=async body=>{const response=await fetch('http://localhost:8080/api/cred/requests',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});assert.equal(response.status,200);return (await response.json()).request;};
|
|
|
|
|
let socket;
|
|
|
|
|
try{
|
|
|
|
|
console.log(browser('open','http://localhost:8080/'));
|
|
|
|
|
socket=new WebSocket(browser('get','cdp-url'));
|
|
|
|
|
console.log(await browser('open','http://localhost:8080/'));
|
|
|
|
|
socket=new WebSocket(await browser('get','cdp-url'));
|
|
|
|
|
await new Promise((resolve,reject)=>{socket.onopen=resolve;socket.onerror=reject;});
|
|
|
|
|
let next=0;
|
|
|
|
|
const pending=new Map();
|
|
|
|
|
socket.onmessage=async event=>{const message=JSON.parse(event.data);const promise=pending.get(message.id);if(promise){pending.delete(message.id);message.error?promise.reject(new Error(JSON.stringify(message.error))):promise.resolve(message.result);}else if(message.method==='Runtime.bindingCalled'&&message.params.name==='qaNewDevice')await switchDevice();};
|
|
|
|
|
socket.onmessage=event=>{const message=JSON.parse(event.data);const promise=pending.get(message.id);if(promise){pending.delete(message.id);message.error?promise.reject(new Error(JSON.stringify(message.error))):promise.resolve(message.result);}};
|
|
|
|
|
const cdp=(method,params={},sessionId)=>new Promise((resolve,reject)=>{const id=++next;pending.set(id,{resolve,reject});socket.send(JSON.stringify({id,method,params,sessionId}));});
|
|
|
|
|
const {targetInfos}=await cdp('Target.getTargets');
|
|
|
|
|
const page=targetInfos.find(target=>target.type==='page'&&target.url==='http://localhost:8080/');
|
|
|
|
@@ -24,45 +26,46 @@ try{
|
|
|
|
|
await cdp('WebAuthn.enable',{},sessionId);
|
|
|
|
|
const authenticatorOptions={protocol:'ctap2',transport:'internal',hasResidentKey:true,hasUserVerification:true,isUserVerified:true,automaticPresenceSimulation:true};
|
|
|
|
|
let {authenticatorId}=await cdp('WebAuthn.addVirtualAuthenticator',{options:authenticatorOptions},sessionId);
|
|
|
|
|
// Enrollment verifies the old device, then creates a passkey on a different device.
|
|
|
|
|
// A device cannot enroll twice because the host excludes its existing credentials.
|
|
|
|
|
const switchDevice=async()=>{await cdp('WebAuthn.removeVirtualAuthenticator',{authenticatorId},sessionId);({authenticatorId}=await cdp('WebAuthn.addVirtualAuthenticator',{options:authenticatorOptions},sessionId));await cdp('Runtime.evaluate',{expression:'window.qaDeviceReady()'},sessionId);};
|
|
|
|
|
await cdp('Runtime.enable',{},sessionId);
|
|
|
|
|
await cdp('Runtime.addBinding',{name:'qaNewDevice'},sessionId);
|
|
|
|
|
browser('wait','#credential-notice:not([hidden])');
|
|
|
|
|
shot('chat-empty');shot('chat-empty-narrow',400,850);
|
|
|
|
|
const mainConversation=Number(browser('eval','selected'));
|
|
|
|
|
browser('click','#conversations button:first-child');browser('wait','--fn',"document.getElementById('compose').hidden");shot('chat-world-log',400,850);
|
|
|
|
|
browser('click','#conversations button[aria-current=false]');
|
|
|
|
|
browser('click','#register-first');browser('wait','input[name=label]');shot('passkeys-none',400,850);
|
|
|
|
|
browser('fill','input[name=label]','Laptop');browser('click','#credential-view form button');browser('wait','--text','Passkey registered.');shot('passkeys-one',400,850);
|
|
|
|
|
browser('eval',"(()=>{const create=navigator.credentials.create.bind(navigator.credentials);navigator.credentials.create=async options=>{await new Promise(resolve=>{window.qaDeviceReady=resolve;window.qaNewDevice('');});return create(options);};})()");
|
|
|
|
|
for(const label of ['Phone','Backup key']){browser('fill','input[name=label]',label);browser('click','#credential-view form button');browser('wait','--text','Passkey registered.');}
|
|
|
|
|
assert.equal((await get('passkeys')).length,3);shot('passkeys-several',400,850);
|
|
|
|
|
browser('eval',"location.hash='entry'");browser('wait','--text','No secret fields exist.');shot('entry-empty',400,850);
|
|
|
|
|
await browser('wait','#credential-notice:not([hidden])');
|
|
|
|
|
await shot('chat-empty');await shot('chat-empty-narrow',400,850);
|
|
|
|
|
const {conversations}=await (await fetch('http://localhost:8080/api/harness/conversations')).json();
|
|
|
|
|
const mainConversation=conversations.find(conversation=>conversation.id!==1&&conversation.agent?.model).id;
|
|
|
|
|
await browser('click','#conversations button:first-child');await browser('wait','--fn',"document.getElementById('compose').hidden");await shot('chat-world-log',400,850);
|
|
|
|
|
await browser('click','#conversations button[aria-current=false]');
|
|
|
|
|
await browser('click','#register-first');await browser('wait','input[name=label]');await shot('passkeys-none',400,850);
|
|
|
|
|
await browser('fill','input[name=label]','Laptop');await browser('click','#credential-view form button');await browser('wait','--text','Passkey registered.');await shot('passkeys-one',400,850);
|
|
|
|
|
for(const label of ['Phone','Backup key']){
|
|
|
|
|
await browser('fill','input[name=label]',label);await browser('click','#credential-view form button');await browser('wait','--text','Existing passkey verified.');
|
|
|
|
|
if(label==='Phone')await shot('passkey-verified',400,850);
|
|
|
|
|
await cdp('WebAuthn.removeVirtualAuthenticator',{authenticatorId},sessionId);
|
|
|
|
|
({authenticatorId}=await cdp('WebAuthn.addVirtualAuthenticator',{options:authenticatorOptions},sessionId));
|
|
|
|
|
await browser('click','#credential-view form button');await browser('wait','--text','Passkey registered.');
|
|
|
|
|
}
|
|
|
|
|
assert.equal((await get('passkeys')).length,3);await shot('passkeys-several',400,850);
|
|
|
|
|
await browser('eval',"location.hash='entry'");await browser('wait','--text','No secret fields exist.');await shot('entry-empty',400,850);
|
|
|
|
|
const name='Example API '+Date.now();
|
|
|
|
|
const request=await file({kind:'edit',key:name,request:{item:null,base_version:null,change:[{op:'create_item',config:{name,rules:[{scheme:'https',hosts:['api.example.com'],port:443,path_prefixes:['/v1'],methods:['GET','POST']}],transform_js:'',fields:[{name:'token',public:false,field_type:'text'}]}}]}});
|
|
|
|
|
browser('eval',"location.hash=''");browser('wait',`#approvals a[href="/#approve/${request}"]`);browser('click',`#approvals a[href="/#approve/${request}"]`);
|
|
|
|
|
browser('wait','#credential-view button[value=approve]');shot('approval-create');shot('approval-create-narrow',400,1000);
|
|
|
|
|
assert.equal(browser('eval',"document.querySelectorAll('#credential-content > pre').length"),'0');
|
|
|
|
|
browser('click','#credential-view button[value=approve]');browser('wait','--text','Request approved.');
|
|
|
|
|
await browser('eval',"location.hash=''");await browser('wait',`#approvals a[href="/#approve/${request}"]`);await browser('click',`#approvals a[href="/#approve/${request}"]`);
|
|
|
|
|
await browser('wait','#credential-view button[value=approve]');await shot('approval-create');await shot('approval-create-narrow',400,1000);
|
|
|
|
|
assert.equal(await browser('eval',"document.querySelectorAll('#credential-content > pre').length"),'0');
|
|
|
|
|
await browser('click','#credential-view button[value=approve]');await browser('wait','--text','Request approved.');
|
|
|
|
|
assert.equal((await get('requests/'+request)).state,'approved');
|
|
|
|
|
assert.equal(browser('get','count','#credential-view form'),'0','Decided requests are read-only');
|
|
|
|
|
assert.equal(browser('eval',`!!document.querySelector('#approvals a[href="/#approve/${request}"]')`),'false');shot('approval-complete',400,850);
|
|
|
|
|
assert.equal(await browser('get','count','#credential-view form'),'0','Decided requests are read-only');
|
|
|
|
|
assert.equal(await browser('eval',`!!document.querySelector('#approvals a[href="/#approve/${request}"]')`),'false');await shot('approval-complete',400,850);
|
|
|
|
|
const item=(await get('items')).find(item=>item.name===name);assert.ok(item);
|
|
|
|
|
const grant=await file({kind:'grant',key:'grant-'+name,request:{items:[item.id],duration_secs:3600,max_uses:5}});
|
|
|
|
|
browser('eval',`location.hash='approve/${grant}'`);browser('wait','#credential-view fieldset:not([hidden])');shot('approval-grant');shot('approval-grant-narrow',400,1000);
|
|
|
|
|
browser('fill','input[name=duration]','600');browser('fill','input[name=uses]','2');browser('click','#credential-view button[value=approve]');browser('wait','--text','Request approved.');assert.equal((await get('requests/'+grant)).state,'approved');
|
|
|
|
|
await browser('eval',`location.hash='approve/${grant}'`);await browser('wait','#credential-view fieldset:not([hidden])');await shot('approval-grant');await shot('approval-grant-narrow',400,1000);
|
|
|
|
|
await browser('fill','input[name=duration]','600');await browser('fill','input[name=uses]','2');await browser('click','#credential-view button[value=approve]');await browser('wait','--text','Request approved.');assert.equal((await get('requests/'+grant)).state,'approved');
|
|
|
|
|
const issued=(await get('grants')).find(grant=>grant.items.includes(item.id));assert.equal(issued.max_uses,2);assert.ok(issued.exp-Math.floor(Date.now()/1000)<=600);
|
|
|
|
|
const denied=await file({kind:'grant',key:'deny-'+name,request:{items:[item.id],duration_secs:3600}});
|
|
|
|
|
browser('eval',`location.hash='approve/${denied}'`);browser('wait','#credential-view button[value=deny]');browser('fill','input[name=duration]','');browser('click','#credential-view button[value=deny]');browser('wait','--text','Request denied.');assert.equal((await get('requests/'+denied)).state,'denied');shot('approval-denied',400,1000);
|
|
|
|
|
browser('eval',"location.hash='entry'");browser('wait','select[name=field]');browser('fill','textarea[name=secret]','QA fixture value');shot('entry-secret',400,850);
|
|
|
|
|
browser('click','#credential-view form button');browser('wait','--text','Secret saved.');assert.equal(browser('get','value','textarea[name=secret]'),'');assert.equal((await get('items')).find(value=>value.id===item.id).fields[0].needs_value,false);shot('entry-complete',400,850);
|
|
|
|
|
browser('eval',"location.hash=''");browser('wait','--fn',"document.getElementById('credential-view').hidden");
|
|
|
|
|
await browser('eval',`location.hash='approve/${denied}'`);await browser('wait','#credential-view button[value=deny]');await browser('fill','input[name=duration]','');await browser('click','#credential-view button[value=deny]');await browser('wait','--text','Request denied.');assert.equal((await get('requests/'+denied)).state,'denied');await shot('approval-denied',400,1000);
|
|
|
|
|
await browser('eval',"location.hash='entry'");await browser('wait','select[name=field]');await browser('fill','textarea[name=secret]','QA fixture value');await shot('entry-secret',400,850);
|
|
|
|
|
await browser('click','#credential-view form button');await browser('wait','--text','Secret saved.');assert.equal(await browser('get','value','textarea[name=secret]'),'');assert.equal((await get('items')).find(value=>value.id===item.id).fields[0].needs_value,false);await shot('entry-complete',400,850);
|
|
|
|
|
await browser('eval',"location.hash=''");await browser('wait','--fn',"document.getElementById('credential-view').hidden");
|
|
|
|
|
// Client-only transcript fixtures exercise rendering without a model call or durable state changes.
|
|
|
|
|
browser('eval',`(()=>{select(${mainConversation});$('entries').replaceChildren();entry({kind:'pi.user',model:{content:'Please review the API changes.'}});entry({kind:'pi.assistant',model:{content:[{type:'thinking',thinking:'I will inspect the files and compare the results.'},{type:'toolCall',id:'qa-tool',name:'bash',arguments:{command:'cargo test --workspace'}},{type:'text',text:'# Review result\\n\\n| Check | Result |\\n| --- | --- |\\n| API tests | Passed |\\n| Build | Passed |'}]}});entry({kind:'pi.tool-result',model:{toolCallId:'qa-tool',toolName:'bash',isError:true,content:[{type:'text',text:'A test failed: expected status 200, received 404.'}]}});entry({kind:'pi.assistant',model:{content:'The route needs a correction.\\n\\n'+ '\`\`\`rust\\n'+Array.from({length:35},(_,i)=>'// Code example line '+(i+1)).join('\\n')+'\\n\`\`\`'}});$('entries').scrollTop=0;})()`);
|
|
|
|
|
shot('chat-tools');shot('chat-tools-narrow',400,1000);
|
|
|
|
|
browser('eval',"$('entries').scrollTop=$('entries').scrollHeight");shot('chat-code-narrow',400,1000);
|
|
|
|
|
assert.equal(browser('eval',"document.documentElement.scrollWidth > innerWidth"),'false');
|
|
|
|
|
await browser('eval',`(()=>{select(${mainConversation});$('entries').replaceChildren();entry({kind:'pi.user',model:{content:'Please review the API changes.'}});entry({kind:'pi.assistant',model:{content:[{type:'thinking',thinking:'I will inspect the files and compare the results.'},{type:'toolCall',id:'qa-tool',name:'bash',arguments:{command:'cargo test --workspace'}},{type:'text',text:'# Review result\\n\\n| Check | Result |\\n| --- | --- |\\n| API tests | Passed |\\n| Build | Passed |'}]}});entry({kind:'pi.tool-result',model:{toolCallId:'qa-tool',toolName:'bash',isError:true,content:[{type:'text',text:'A test failed: expected status 200, received 404.'}]}});entry({kind:'pi.assistant',model:{content:'The route needs a correction.\\n\\n'+ '\`\`\`rust\\n'+Array.from({length:35},(_,i)=>'// Code example line '+(i+1)).join('\\n')+'\\n\`\`\`'}});$('entries').scrollTop=0;})()`);
|
|
|
|
|
await shot('chat-tools');await shot('chat-tools-narrow',400,1000);
|
|
|
|
|
await browser('eval',"$('entries').scrollTop=$('entries').scrollHeight");await shot('chat-code-narrow',400,1000);
|
|
|
|
|
assert.equal(await browser('eval',"document.documentElement.scrollWidth > innerWidth"),'false');
|
|
|
|
|
console.log('PASS: registration, multiple passkeys, item creation, immediate banner removal, grant limits, encrypted secret entry, and transcript QA.');
|
|
|
|
|
}catch(error){console.error(browser('snapshot'));console.error(browser('errors'));console.error(browser('screenshot','/tmp/t91-qa-failure.png'));throw error;}finally{socket?.close();browser('close');}
|
|
|
|
|
}catch(error){console.error(await browser('snapshot'));console.error(await browser('errors'));console.error(await browser('screenshot','/tmp/t91-qa-failure.png'));throw error;}finally{socket?.close();await browser('close');}
|
|
|
|
|