--- previous-capture.mjs +++ permission-capture.mjs @@ -6,11 +6,11 @@ import {promisify} from 'node:util'; import {createHash} from 'node:crypto'; const run=promisify(execFile); -const scratch='/tmp/lead-32576-r1/evidence-capture'; +const scratch='/tmp/lead-32576-r1/evidence-capture-2'; const side=process.argv[2]; assert(['before','after'].includes(side)); -const checkout=side==='before'?'/home/user/wt/pr-32576-before':'/home/user/wt/pr-32576'; -const revision=side==='before'?'41443e5706c4605bc3efc17bcf431970488a146d':'2ac1b7da140b2520750cb047dc8c1b5bc2ef980b'; +const checkout=side==='before'?'/home/user/wt/pr-32576-before2':'/home/user/wt/pr-32576'; +const revision=side==='before'?'41443e5706c4605bc3efc17bcf431970488a146d':'c7e32d78e517c2ef8f33d1c2f19a9ec88a246c10'; const require=createRequire(path.join(checkout,'apps/web/package.json')); const {chromium}=require('playwright'); const {expect}=require('playwright/test'); @@ -26,7 +26,8 @@ for(const d of ['existing','parent/child','parent/common-a','parent/common-b','clone-existing'])await fs.mkdir(path.join(f,d),{recursive:true}); await fs.writeFile(path.join(scratch,`${side}-driver.pid`),String(process.pid)); const driverSha256=createHash('sha256').update(await fs.readFile(new URL(import.meta.url))).digest('hex'); -const requested=['AX0','F1','F3','F4','R2','D1','D2','D4','D6','N5','T0','T1','T2','T3']; +const requested=['P1','P2','P0']; +const standardScopes=['orchestration:read','orchestration:operate','settings:write','providers:manage','environment:maintain','preview:operate','diagnostics:read','terminal:read','terminal:operate','source-control:write','filesystem:read','filesystem:write','relay:read','access:read','access:write','relay:write']; const records=[];const failures=[];const actions=[]; const browser=await chromium.launch({headless:true}); const environment={browser:'Chromium',version:browser.version(),headless:true,viewport:{width:1440,height:900},DPR:1,host:'Linux x86_64',driverSha256,startedAt,revision,side}; @@ -35,12 +36,21 @@ function action(step){const r={timestamp:new Date().toISOString(),step};actions.push(r);console.log('STEP',step);return r;} const none={_tag:'None'};const some=value=>({_tag:'Some',value}); const discovery={versionControlSystems:[{kind:'git',implemented:true,label:'Git',status:'available',version:some('synthetic fixture'),installHint:'Fixture only',detail:none}],sourceControlProviders:[{kind:'github',label:'GitHub',status:'available',version:some('synthetic fixture'),installHint:'Fixture only',detail:none,auth:{status:'authenticated',account:some('fixture-user'),host:some('github.com'),detail:none}}]}; -async function session({mac=false,rpcHarness=false}={}){ +async function session({mac=false,rpcHarness=false,omitScope=null}={}){ + const grantedScopes=standardScopes.filter(scope=>scope!==omitScope); + const sessionResponses=[]; const context=await browser.newContext({viewport:environment.viewport,deviceScaleFactor:1}); if(mac)await context.addInitScript(()=>Object.defineProperty(navigator,'platform',{get:()=> 'MacIntel',configurable:true})); await context.addInitScript(()=>{window.__captureFocus=[];window.__captureIds=new WeakMap();window.__captureNext=0; document.addEventListener('focusin',e=>{const n=e.target;if(n instanceof HTMLInputElement&&n.getAttribute('role')==='combobox')window.__captureFocus.push({timestamp:new Date().toISOString(),id:n.id,query:n.value});});}); const page=await context.newPage();page.setDefaultTimeout(20000); const cdp=await context.newCDPSession(page); + page.on('pageerror',error=>console.log('PRIVATE PAGEERROR',String(error))); + page.on('console',message=>{if(message.type()==='error')console.log('PRIVATE CONSOLE',message.text());}); + page.on('response',async response=>{try{ + if(!new URL(response.url()).pathname.endsWith('/auth/session'))return; + const state=await response.json(); + sessionResponses.push({timestamp:new Date().toISOString(),path:new URL(response.url()).pathname,status:response.status(),authenticated:state.authenticated,scopes:state.scopes,permissions:state.permissions}); + }catch(error){console.log('PRIVATE session response read',String(error));}}); let held=null;let holdTag=null;const rpcLog=[];const browseLog=[];const method=new Map(); await page.routeWebSocket('**/ws*',ws=>{ const server=ws.connectToServer();let pendingFrame=''; @@ -65,14 +75,19 @@ }); action(`Open ${mac?'Mac platform harness':rpcHarness?'RPC harness':'real app'} client; synthetic empty onboarding scan; no project import`); // Tokens stay only in private server logs, never in saved outputs. - const pair=await run('node',['apps/server/src/bin.ts','pair','--base-dir',path.join(checkout,'.t3'),'--label','synthetic-evidence-capture'],{cwd:checkout,maxBuffer:2*1024*1024}); + const pair=await run('node',['apps/server/src/bin.ts','pair','--base-dir',path.join(scratch,`${side}-home`),'--label','synthetic-permission-evidence',...grantedScopes.flatMap(scope=>['--scope',scope])],{cwd:checkout,maxBuffer:2*1024*1024}); const freshPair=pair.stdout.match(/Pairing URL: (\S+)/)?.[1];assert(freshPair,'fresh pairing URL'); - await page.goto(freshPair); - await page.waitForURL(url=>url.pathname==='/welcome',{timeout:60000}); - await page.getByRole('heading',{name:'Welcome to T3 Code'}).waitFor({timeout:60000}); - await expect(page.getByRole('button',{name:'Start',exact:true})).toBeEnabled({timeout:60000}); - await page.getByRole('button',{name:'Start',exact:true}).click(); - await page.getByRole('button',{name:'Add project',exact:true}).first().waitFor({timeout:60000}); + await page.goto(freshPair);action(`Paired; current path ${new URL(page.url()).pathname}`); + const welcomeHeading=page.getByRole('heading',{name:'Welcome to T3 Code'}); + await welcomeHeading.or(page.getByRole('button',{name:'Add project',exact:true}).first()).first().waitFor({timeout:60000}); + if(await welcomeHeading.isVisible()){ + await page.getByRole('heading',{name:'Welcome to T3 Code'}).waitFor({timeout:60000}); + await expect(page.getByRole('button',{name:'Start',exact:true})).toBeEnabled({timeout:60000}); + await page.getByRole('button',{name:'Start',exact:true}).click(); + } + try{await page.getByRole('button',{name:'Add project',exact:true}).first().waitFor({timeout:60000});} + catch(error){await fs.writeFile(path.join(out,`startup-private-${omitScope??'full'}.json`),JSON.stringify({path:new URL(page.url()).pathname,text:await page.locator('body').innerText(),sessions:sessionResponses},null,2));throw error;} + action('App ready; Add project is visible'); const input=()=>page.getByRole('combobox'); const button=label=>page.getByRole('button',{name:label,exact:true}); async function close(){if(await input().count()){await page.keyboard.press('Escape');await expect(page.getByRole('dialog',{name:'Command palette',exact:true})).toHaveCount(0);}} @@ -91,7 +106,7 @@ const ids=describedby?.split(/\s+/).filter(Boolean)??[]; const describe=n=>({id:n.id||null,ariaLabel:n.getAttribute('aria-label'),text:n.textContent,disabled:n.disabled,tabIndex:n.tabIndex,tag:n.tagName}); const targets=ids.map(id=>({id,count:Array.from(document.querySelectorAll('[id]')).filter(e=>e.id===id).length,element:document.getElementById(id)?describe(document.getElementById(id)):null})); - const candidate=input?.closest('[data-slot="command-input-wrapper"]')?.parentElement?.querySelector('button[tabindex="-1"][aria-label]')??(input?Array.from(input.closest('[role="dialog"]').querySelectorAll('button[tabindex="-1"][aria-label]')).find(b=>/^(Add|Create|Clone|Continue|Lookup)/.test(b.getAttribute('aria-label'))):null); + const candidate=input?Array.from(input.closest('[role="dialog"]').querySelectorAll('button[tabindex="-1"][aria-label]')).find(b=>/^(Add|Create|Clone|Continue|Lookup|This connection)/.test(b.getAttribute('aria-label'))):null; const highlighted=input?.getAttribute('aria-activedescendant');const highlightedNode=highlighted?document.getElementById(highlighted):null; return {platform:navigator.platform,viewport:{width:innerWidth,height:innerHeight},DPR:devicePixelRatio,active:node(active),input:node(input),focused:!!input&&active===input,query:input?.value??null,placeholder:input?.getAttribute('placeholder')??null,ariaDescribedby:describedby,targets,accessoryCandidate:candidate?describe(candidate):null,ariaActiveDescendant:highlighted,highlighted:highlightedNode?{id:highlightedNode.id,value:highlightedNode.getAttribute('data-value'),text:highlightedNode.textContent}:null,focusEvents:window.__captureFocus.slice()}; }); @@ -107,7 +122,7 @@ function visit(id){if(paletteIds.has(id))return;paletteIds.add(id);for(const child of nodeMap.get(id)?.childIds??[])visit(child);} if(dialog)visit(dialog.nodeId); const paletteAXTree={nodes:whole.nodes.filter(n=>paletteIds.has(n.nodeId))}; - const record={fixture,step,timestamp,side,revision,harness,environment,dom,paletteAXTree,computedDescription:focusedAX?.description?.value??null,focusedAX:focusedAX??null,partialAXTree:ax,targetAXTree:targetAX,browseTransactions:browseLog.filter(e=>{const request=e.event==='request'?e:browseLog.find(r=>r.event==='request'&&r.id===e.id);return request?.payload?.partialPath?.startsWith(f);}),rpcHarnessLog:rpcLog.slice()}; + const record={fixture,step,timestamp,side,revision,harness,environment,permissions:{mode:'real pairing token; no session or scopes interception',omittedScope:omitScope,requestedScopes:grantedScopes,observedSessionResponses:sessionResponses},dom,paletteAXTree,computedDescription:focusedAX?.description?.value??null,focusedAX:focusedAX??null,partialAXTree:ax,targetAXTree:targetAX,browseTransactions:browseLog.filter(e=>{const request=e.event==='request'?e:browseLog.find(r=>r.event==='request'&&r.id===e.id);return request?.payload?.partialPath?.startsWith(f);}),rpcHarnessLog:rpcLog.slice()}; records.push(record);await fs.writeFile(path.join(out,`${fixture}-${String(records.filter(r=>r.fixture===fixture).length).padStart(2,'0')}.json`),JSON.stringify(record,null,2)+'\n'); console.log('CAPTURE',fixture,step,JSON.stringify(record.computedDescription),dom.accessoryCandidate?.ariaLabel,dom.accessoryCandidate?.disabled); return record; @@ -116,33 +131,31 @@ } async function suite(name,fn){try{await fn();}catch(e){const failure={suite:name,error:String(e),timestamp:new Date().toISOString()};failures.push(failure);console.log('FAIL',JSON.stringify(failure));await fs.writeFile(path.join(out,'failures.json'),JSON.stringify(failures,null,2));}} try{ - await suite('local/root/traces',async()=>{const s=await session();try{ - await s.local();await s.fill(`${f}/existing/`,'Add (Enter)');await s.page.getByRole('option',{name:'..',exact:true}).waitFor(); - await s.capture('T0','existing path, no highlight');await s.highlight();await s.capture('AX0','focused highlighted existing path; enabled target');await s.capture('T0','ArrowDown highlights parent; same focused input'); - action('Type x: edit query and clear highlight');await s.input().press('x');await expect(s.button('Create & Add (Enter)')).toBeVisible();await s.capture('T0','query edit clears highlight and adds Create prefix');await s.capture('F1','missing leaf existing/x, browse resolved'); - await s.fill(`${f}/parent/`,'Add (Enter)');await s.page.getByRole('option',{name:'child',exact:true}).waitFor();await s.highlight();await s.capture('T2','parent path, parent directory highlighted'); - action('ArrowDown selects child');await s.input().press('ArrowDown');await s.capture('T2','child highlighted before Tab'); - action('Tab navigates into highlighted child and remounts input');await s.input().press('Tab');await expect(s.input()).toHaveValue(`${f}/parent/child/`);await expect(s.button('Add (Enter)')).toBeVisible();await s.capture('T2','Tab completed navigation; focused replacement input; no highlight'); - await s.root();await s.fill(`${f}/existing/`,'Add (Enter)');await s.capture('F4','root absolute path browsing');await s.capture('T3','root path has association'); - await s.fill('ordinary-text');await expect(s.page.getByRole('button',{name:/^(Add|Create & Add) \(/})).toHaveCount(0);await s.capture('T3','ordinary text removes association'); - action('Escape closes palette');await s.input().press('Escape');await expect(s.page.getByRole('dialog',{name:'Command palette',exact:true})).toHaveCount(0);await s.capture('T3','closed: no palette input exposed',{terminal:true}); - await s.root();await s.capture('T3','fresh palette session, ordinary root query');await s.fill(`${f}/existing/`,'Add (Enter)');await s.capture('T3','fresh session re-enters path; target resolves uniquely'); - }finally{await s.context.close();}}); - await suite('mac modifier',async()=>{const s=await session({mac:true});try{await s.local();await s.fill(`${f}/existing/`,'Add (Enter)');await s.page.getByRole('option',{name:'..',exact:true}).waitFor();await s.highlight();await expect(s.button('Add (⌘ Enter)')).toBeVisible();await s.capture('F3','MacIntel client override, highlighted parent');}finally{await s.context.close();}}); - await suite('clone states/trace',async()=>{const s=await session();try{ - await s.source();action('Choose Git URL');await s.page.getByRole('option',{name:/^Git URL/}).click();await expect(s.button('Continue (Enter)')).toBeDisabled();await s.capture('AX0','empty Git URL; disabled described target'); - await s.fill('https://example.invalid/fixture/repository.git','Continue (Enter)');await s.capture('T1','Git URL repository entry');action('Enter continues to destination');await s.input().press('Enter');await expect(s.button('Create & Clone (Enter)')).toBeVisible();await s.capture('T1','destination confirmation after Enter'); - await s.fill(`${f}/clone-existing/`,'Clone (Enter)');await s.capture('D1','existing empty destination, unhighlighted');await s.highlight();await expect(s.button('Clone (Ctrl Enter)')).toBeVisible();await s.capture('D2','existing destination, parent highlighted'); - await s.fill('plain-name');await expect(s.page.getByRole('button',{name:/Clone \(/})).toHaveCount(0);await s.capture('D6','clone confirmation retained with non-path query');await s.capture('T1','non-path query removes association'); - await s.fill(`${f}/clone-missing`,'Create & Clone (Enter)');await s.capture('T1','absolute missing destination restores association'); - action('Back button returns to source list');await s.page.locator('button[aria-label="Back"]').click();await s.page.getByRole('option',{name:/^Local folder/}).waitFor();await expect(s.input()).toBeFocused();await s.capture('T1','Back to source list removes association'); - }finally{await s.context.close();}}); - await suite('pending states',async()=>{const s=await session({rpcHarness:true});try{ - await s.source();await s.page.getByRole('option',{name:/^GitHub repository/}).click();await s.fill('fixture-owner/fixture-repository','Lookup (Enter)');await s.capture('R2','ready provider lookup');s.hold('sourceControl.lookupRepository');action('Enter starts held provider lookup');await s.input().press('Enter');await s.waitHeld();await expect(s.button('Lookup (Enter)')).toBeDisabled();await expect(s.button('Lookup (Enter)')).toContainText('Working');await s.capture('R2','held lookup: Working; disabled target');action('Release lookup as synthetic failure');s.release();await expect(s.button('Lookup (Enter)')).toBeEnabled();await expect(s.button('Lookup (Enter)')).toContainText('Lookup');await s.capture('R2','failure released; repository input remains focused'); - await s.source();await s.page.getByRole('option',{name:/^Git URL/}).click();await s.fill('https://example.invalid/fixture/repository.git','Continue (Enter)');await s.input().press('Enter');await s.fill(`${f}/clone-pending`,'Create & Clone (Enter)');await s.capture('D4','ready new clone destination');s.hold('projectClone.start');action('Enter starts held clone acknowledgement');await s.input().press('Enter');await s.waitHeld();await expect(s.button('Create & Clone (Enter)')).toBeDisabled();await expect(s.button('Create & Clone (Enter)')).toContainText('Cloning');await s.capture('D4','held clone: Cloning; disabled target');action('Release clone as synthetic failure');s.release();await expect(s.button('Create & Clone (Enter)')).toBeEnabled();await s.capture('D4','failure released; clone destination remains focused'); - await s.source();await s.page.getByRole('option',{name:/^New project/}).click();await s.fill('fixture-new-project','Create (Enter)');const publish=s.page.getByRole('option',{name:/Create.*repository on/});if(await publish.count()){const checked=await publish.locator('[role="checkbox"]').getAttribute('aria-checked');if(checked==='true')await publish.click();await s.input().focus();} - await s.capture('N5','ready named project; publishing off');s.hold('projects.createNew');action('Enter starts held project creation');await s.input().press('Enter');await s.waitHeld();await expect(s.button('Create (Enter)')).toBeDisabled();await expect(s.button('Create (Enter)')).toContainText('Creating');await s.capture('N5','held creation: Creating; disabled target');action('Release creation as synthetic failure');s.release();await expect(s.button('Create (Enter)')).toBeEnabled();await s.capture('N5','failure released; project-name input remains focused'); - }finally{await s.context.close();}}); + for(const fixture of requested)await suite(fixture,async()=>{ + const omitScope=fixture==='P1'?'orchestration:operate':fixture==='P2'?'source-control:write':null; + const s=await session({omitScope}); + try{ + if(fixture==='P2'){ + await s.source();action('Choose Git URL');await s.page.getByRole('option',{name:/^Git URL/}).click(); + await s.fill('https://example.invalid/fixture/repository.git','Continue (Enter)'); + action('Enter continues to clone destination');await s.input().press('Enter'); + await s.fill(`${f}/clone-existing/`); + }else{await s.local();await s.fill(`${f}/existing/`);} + await s.page.getByRole('option',{name:'..',exact:true}).waitFor(); + await expect(s.input()).toBeFocused(); + const r=await s.capture(fixture,fixture==='P2'?'Git URL clone destination; source-control:write omitted':fixture==='P1'?'Local folder; orchestration:operate omitted':'Local folder; full standard scopes'); + assert.equal(r.dom.accessoryCandidate?.disabled,fixture!=='P0'); + assert(r.permissions.observedSessionResponses.length>0,'Capture must observe real auth session response'); + const state=r.permissions.observedSessionResponses.at(-1); + assert.equal(state.authenticated,true); + assert.deepEqual([...state.permissions].sort(),[...standardScopes.filter(scope=>scope!==omitScope)].sort()); + if(side==='before')assert.equal(r.computedDescription,null); + else{ + const expected=fixture==='P1'?'This connection cannot add projects.':fixture==='P2'?'This connection needs permission to write source control and add projects.':'Add (Enter)'; + assert.equal(r.computedDescription,expected);assert.equal(r.dom.accessoryCandidate.ariaLabel,expected); + } + }finally{await s.context.close();} + }); }finally{ await browser.close(); const captured=[...new Set(records.map(r=>r.fixture))];