--- evidence-capture-2/capture.mjs +++ evidence-capture-3/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-2'; +const scratch='/tmp/lead-32576-r1/evidence-capture-3'; const side=process.argv[2]; assert(['before','after'].includes(side)); -const checkout=side==='before'?'/home/user/wt/pr-32576-before2':'/home/user/wt/pr-32576'; -const revision=side==='before'?'41443e5706c4605bc3efc17bcf431970488a146d':'c7e32d78e517c2ef8f33d1c2f19a9ec88a246c10'; +const checkout=side==='before'?'/home/user/wt/pr-32576-before3':'/home/user/wt/pr-32576'; +const revision=side==='before'?'41443e5706c4605bc3efc17bcf431970488a146d':'73598a65e6f639d82cdcc9fc71a32ee9aa6d4156'; const require=createRequire(path.join(checkout,'apps/web/package.json')); const {chromium}=require('playwright'); const {expect}=require('playwright/test'); @@ -18,24 +18,27 @@ const out=path.join(scratch,`${side}-${startedAt.replaceAll(':','-')}`); await fs.mkdir(out,{recursive:true}); await fs.writeFile(path.join(out,'driver-private.pid'),String(process.pid)); -const log=await fs.readFile(path.join(scratch,`${side}-server-private-2.log`),'utf8'); +const log=await fs.readFile(path.join(scratch,`${side}-server-private.log`),'utf8'); const pairingUrl=log.match(/pairingUrl: (\S+)/)?.[1]; assert(pairingUrl,'Server must be running and print its pairing URL'); const origin=new URL(pairingUrl).origin; const f=path.join(scratch,'fixture-fs'); 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 actualRevision=(await run('git',['rev-parse','HEAD'],{cwd:checkout})).stdout.trim(); +assert.equal(actualRevision,revision); const driverSha256=createHash('sha256').update(await fs.readFile(new URL(import.meta.url))).digest('hex'); -const requested=['P1','P2','P0']; +const requested=['N1','N2']; 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}; console.log(JSON.stringify({out,environment})); +await fs.writeFile(path.join(out,'browser-private.pid'),String(browser._browserProcess?.process?.pid??'')); process.on('SIGTERM',async()=>{await browser.close();process.exit(143);}); 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}}]}; +const discovery={versionControlSystems:[{kind:'git',implemented:true,label:'Git',status:'available',version:some('synthetic fixture'),installHint:'Fixture only',detail:none}],sourceControlProviders:[]}; async function session({mac=false,rpcHarness=false,omitScope=null}={}){ const grantedScopes=standardScopes.filter(scope=>scope!==omitScope); const sessionResponses=[]; @@ -79,7 +82,7 @@ const freshPair=pair.stdout.match(/Pairing URL: (\S+)/)?.[1];assert(freshPair,'fresh pairing URL'); 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}); + try{await welcomeHeading.or(page.getByRole('button',{name:'Add project',exact:true}).first()).first().waitFor({timeout:60000});}catch(error){await fs.writeFile(path.join(out,'startup-private.json'),JSON.stringify({path:new URL(page.url()).pathname,text:await page.locator('body').innerText(),sessions:sessionResponses},null,2));throw error;} 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}); @@ -88,7 +91,7 @@ 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 input=()=>page.getByRole('dialog',{name:'Command palette',exact:true}).locator('input[role="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);}} async function root(){await close();await page.keyboard.press(mac?'Meta+k':'Control+k');await expect(input()).toBeFocused();} @@ -96,7 +99,7 @@ async function local(){await source();await page.getByRole('option',{name:/^Local folder/}).click();await expect(input()).toBeFocused();} async function fill(value,label){action(`Fill combobox with ${value}`);await input().fill(value);if(label)await expect(button(label)).toBeVisible();await expect(page.getByRole('status').filter({hasText:'Loading folders'})).toHaveCount(0);await expect(input()).toBeFocused();} async function highlight(){action('ArrowDown: highlight first directory');await input().press('ArrowDown');await expect(input()).toHaveAttribute('aria-activedescendant',/.+/);await expect(input()).toBeFocused();} - async function capture(fixture,step,{terminal=false,harness=rpcHarness?'RPC harness: synthetic ready GitHub host, common new-project root, held request and synthetic failure':mac?'navigator.platform override harness':'real app'}={}){ + async function capture(fixture,step,{terminal=false,harness=rpcHarness?'RPC harness: synthetic Git availability, no source-control hosts, common scratch new-project root; no project submission':mac?'navigator.platform override harness':'real app'}={}){ const timestamp=new Date().toISOString(); const dom=await page.evaluate(()=>{ const input=document.querySelector('[role="dialog"] input[role="combobox"]');const active=document.activeElement; @@ -114,6 +117,8 @@ if(dom.input){assert(dom.focused,`${fixture}: actual native combobox must be focused`);const {result}=await cdp.send('Runtime.evaluate',{expression:'document.activeElement'});ax=await cdp.send('Accessibility.getPartialAXTree',{objectId:result.objectId,fetchRelatives:true});await cdp.send('Runtime.releaseObject',{objectId:result.objectId}); if(dom.ariaDescribedby){const {result:t}=await cdp.send('Runtime.evaluate',{expression:`document.getElementById(${JSON.stringify(dom.ariaDescribedby)})`});targetAX=await cdp.send('Accessibility.getPartialAXTree',{objectId:t.objectId,fetchRelatives:false});await cdp.send('Runtime.releaseObject',{objectId:t.objectId});assert.equal(dom.targets.length,1);assert.equal(dom.targets[0].count,1);} }else assert(terminal,'Expected focused combobox'); + let highlightedAX=null; + if(dom.ariaActiveDescendant){const {result:h}=await cdp.send('Runtime.evaluate',{expression:`document.getElementById(${JSON.stringify(dom.ariaActiveDescendant)})`});highlightedAX=await cdp.send('Accessibility.getPartialAXTree',{objectId:h.objectId,fetchRelatives:false});await cdp.send('Runtime.releaseObject',{objectId:h.objectId});} const focusedAX=ax?.nodes.find(n=>n.role?.value==='combobox'&&n.properties?.some(p=>p.name==='focused'&&p.value.value===true)); if(dom.input)assert(focusedAX,'CDP must independently report focused combobox'); const whole=await cdp.send('Accessibility.getFullAXTree'); @@ -122,7 +127,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,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()}; + 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,highlightedAXTree:highlightedAX,highlightedRowName:highlightedAX?.nodes.find(n=>n.role?.value==='option')?.name?.value??null,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; @@ -131,29 +136,41 @@ } 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{ - 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}); + await suite('N1/N2 New project',async()=>{ + const s=await session({rpcHarness:true}); 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 s.root(); + action('Command palette: type Add project and select its row'); + await s.input().fill('Add project'); + await s.page.getByRole('option',{name:'Add project',exact:true}).click(); + action('Add project: select New project'); + await s.page.getByRole('option',{name:/^New project/}).click(); + await expect(s.input()).toHaveAttribute('placeholder','Project name'); + await s.fill('demo'); + await expect(s.input()).not.toHaveAttribute('aria-activedescendant',/.+/); + const n1=await s.capture('N1','New project; name demo; no row highlighted'); + assert.equal(n1.dom.query,'demo'); + assert(!n1.dom.ariaActiveDescendant); + assert.equal(n1.dom.accessoryCandidate?.disabled,false); + assert.equal(n1.dom.accessoryCandidate?.ariaLabel,'Create (Enter)'); + assert.equal(n1.computedDescription,side==='before'?null:'Create (Enter)'); + const existing=s.page.getByRole('option',{name:/^Add existing project/}); + await existing.waitFor(); + const rowCount=await s.page.getByRole('option').count(); + for(let index=0;index0,'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); - } + const n2=await s.capture('N2','New project; name demo; ArrowDown highlights Add existing project'); + assert(n2.dom.ariaActiveDescendant); + assert(n2.highlightedRowName?.startsWith('Add existing project')); + assert.equal(n2.dom.accessoryCandidate?.disabled,false); + assert.equal(n2.dom.accessoryCandidate?.ariaLabel,'Create (Ctrl Enter)'); + assert.equal(n2.computedDescription,side==='before'?null:'Create (Ctrl Enter)'); + assert.equal(n1.dom.input.nodeToken,n2.dom.input.nodeToken); }finally{await s.context.close();} }); }finally{ @@ -166,3 +183,5 @@ for(const fixture of requested){const rows=records.filter(r=>r.fixture===fixture);if(rows.length)await fs.writeFile(path.join(out,`${fixture}-trace.md`),`# ${fixture} — ${side}\n\n| UTC timestamp | Scripted state/event | Computed description | Focused | Input token | Target label | Disabled |\n|---|---|---|---|---|---|---|\n`+rows.map(r=>`| ${r.timestamp} | ${r.step} | ${r.computedDescription??'(none)'} | ${r.dom.focused} | ${r.dom.input?.nodeToken??'—'} | ${r.dom.accessoryCandidate?.ariaLabel??'—'} | ${r.dom.accessoryCandidate?.disabled??'—'} |`).join('\n')+'\n');} console.log('SUMMARY',JSON.stringify(summary)); } + +if(failures.length||requested.some(id=>!records.some(r=>r.fixture===id)))process.exitCode=1;