fix(B03): preauthorize full access and replace composer with active approval
This commit is contained in:
1 parent
0bc9faa129
commit
adbbb3bd37
15 files changed
+627
-63
No files matched your search
@@ -1,16 +1,22 @@
|
||||
/** jsdom behavior only; no layout, browser pixels, SDK process or model calls. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { createRequire } from 'node:module'
|
||||
import { readFileSync } from 'node:fs'
|
||||
const externalRequire=createRequire(process.cwd()+'/package.json')
|
||||
const {JSDOM}=externalRequire(process.env.YCODE_TEST_DOM_MODULE||'jsdom')
|
||||
const dom=new JSDOM('<html><body><main id="root"></main></body></html>',{url:'http://localhost/',pretendToBeVisual:true})
|
||||
for(const key of ['window','document','Node','Element','HTMLElement','HTMLInputElement','HTMLTextAreaElement','MouseEvent','KeyboardEvent']) Object.defineProperty(globalThis,key,{value:dom.window[key],configurable:true})
|
||||
for(const key of ['window','document','Node','Element','HTMLElement','HTMLInputElement','HTMLTextAreaElement','MouseEvent','KeyboardEvent','Document','Text','DOMParser','MutationObserver','Range','ShadowRoot','File','FileReader','Event','getComputedStyle']) Object.defineProperty(globalThis,key,{value:dom.window[key],configurable:true})
|
||||
Object.defineProperty(globalThis,'navigator',{value:dom.window.navigator,configurable:true})
|
||||
Object.assign(globalThis,{IS_REACT_ACT_ENVIRONMENT:true})
|
||||
Object.assign(globalThis,{IS_REACT_ACT_ENVIRONMENT:true,requestAnimationFrame:dom.window.requestAnimationFrame.bind(dom.window),cancelAnimationFrame:dom.window.cancelAnimationFrame.bind(dom.window)})
|
||||
const composerStyle=document.createElement('style')
|
||||
composerStyle.textContent=['composer.css','composer-source.css'].map(file=>readFileSync(`src/renderer/src/workbuddy/composer/${file}`,'utf8')).join('\n')
|
||||
document.head.append(composerStyle)
|
||||
const React=require('react') as typeof import('react')
|
||||
const {act}=React
|
||||
const {createRoot}=require('react-dom/client') as typeof import('react-dom/client')
|
||||
const {PermissionConfirm}=require('../src/renderer/src/workbuddy/conversation/PermissionConfirm') as typeof import('../src/renderer/src/workbuddy/conversation/PermissionConfirm')
|
||||
const {ComposerControls}=require('../src/renderer/src/workbuddy/composer/ComposerControls') as typeof import('../src/renderer/src/workbuddy/composer/ComposerControls')
|
||||
const {composeComposer}=require('../src/renderer/src/workbuddy/composer/input-composition') as typeof import('../src/renderer/src/workbuddy/composer/input-composition')
|
||||
const root=createRoot(document.getElementById('root')!)
|
||||
let count=0,completed=0,declined=0, resolve!:()=>void,reject!:(e:Error)=>void
|
||||
let checks=0
|
||||
@@ -48,6 +54,81 @@ async function main(){
|
||||
check(document.querySelector('section')?.textContent?.includes('Cancelled'),'cancelled remains visible')
|
||||
id='e';status='pending';await render();await act(async()=>click('.conversation-intercept-card__rule-action'))
|
||||
check(document.querySelector('[role="status"]')?.textContent?.includes('未提供命中规则详情'),'missing rule explanation explicit')
|
||||
// Exercise the real composer with the real approval surface, not a skeleton.
|
||||
let session='session-a', active=true, sends=0, stops=0, changes=0, stopFails=false, request='request-1', stopPending=false
|
||||
let rejectStop!: (error: Error)=>void
|
||||
let settled: 'answered'|'rejected'|undefined
|
||||
const draft='preserved draft'
|
||||
const attachments=[{id:'attachment-a',name:'a.txt',mimeType:'text/plain',size:1}]
|
||||
const resources=['/fixture/reference.ts']
|
||||
const quotes=[{id:'quote-a',kind:'table' as const,sourceId:'source-a',markdown:'table',preview:'table'}]
|
||||
const renderComposer=()=>act(async()=>{
|
||||
const composition=composeComposer({interaction:active?{id:session,kind:'approval',status:'pending'}:null,queueCount:2,running:true})
|
||||
root.render(<ComposerControls key={session} sessionId={session} value={draft} onChange={()=>{changes++}} onSend={()=>{sends++}}
|
||||
attachments={attachments} resources={resources} quotes={quotes} onStop={()=>{stops++;if(stopFails)throw new Error('stop fixture failed');if(stopPending)return new Promise<void>((_yes,no)=>{rejectStop=no})}}
|
||||
queueSlot={composition.queue.visible?<div data-queue="true">two queued messages</div>:undefined}
|
||||
approvalKey={request} approvalSlot={!composition.input.visible?<PermissionConfirm pendingId={session} toolName="Bash" options={options} resolve={pending} reject={async()=>{declined++}} onDecisionComplete={decision=>{settled=decision}}/>:undefined}/>)
|
||||
})
|
||||
await renderComposer()
|
||||
const stack=document.querySelector<HTMLElement>('.conversation-input-area__input-stack')!
|
||||
check(stack.hidden&&stack.hasAttribute('inert'),'approval hides and inerts preserved input stack')
|
||||
check(getComputedStyle(stack).display==='none','composer CSS removes the normal input from layout during approval')
|
||||
check(document.querySelectorAll('[data-composer-approval] [role="dialog"]').length===1,'one bottom approval surface')
|
||||
check(!document.querySelector('.wb-composer__toolbar'),'normal toolbar and send controls removed during approval')
|
||||
check(!document.querySelector('[data-queue]'),'queue hidden without deleting queue state')
|
||||
const editor=stack.querySelector<HTMLElement>('[contenteditable]')!
|
||||
check(editor?.getAttribute('contenteditable')==='false','rich editor disabled during approval')
|
||||
const changesBefore=changes
|
||||
await act(async()=>{editor.focus();editor.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true,cancelable:true}))})
|
||||
check(!stack.contains(document.activeElement)&&sends===0&&changes===changesBefore,'hidden editor cannot focus, type or submit')
|
||||
await act(async()=>click('[data-option-id="once"]'))
|
||||
await act(async()=>reject(new Error('composer retry fixture')))
|
||||
check(document.querySelector('[role="alert"]')?.textContent?.includes('composer retry fixture'),'submission failure stays in replacement surface')
|
||||
check(stack.hidden&&!document.querySelector('.wb-composer__toolbar'),'failure does not expose editor/send')
|
||||
await act(async()=>click('[data-option-id="once"]'));await act(async()=>resolve())
|
||||
check(settled==='answered','allow completes through real composer')
|
||||
active=false;await renderComposer()
|
||||
check(!stack.hidden&&!stack.hasAttribute('inert'),'completion restores input stack')
|
||||
check(getComputedStyle(stack).display!=='none','composer CSS restores normal input layout')
|
||||
check(stack.textContent?.includes('a.txt')&&stack.textContent?.includes('reference.ts')&&stack.textContent?.includes('会话表格'),'attachment resource and quote survive approval')
|
||||
check(stack.textContent?.includes(draft),'rich draft survives approval')
|
||||
check(!!document.querySelector('[data-queue]')&&!!document.querySelector('.wb-composer__toolbar'),'queue and send toolbar restore')
|
||||
active=true;settled=undefined;await renderComposer();await act(async()=>key('Escape'))
|
||||
check(settled==='rejected','deny completes through real composer')
|
||||
active=false;await renderComposer();check(!stack.hidden,'deny restores editor after terminal interaction')
|
||||
active=true;await renderComposer();await act(async()=>click('[data-option-id="once"]'));const staleResolve=resolve
|
||||
session='session-b';active=false;settled=undefined;await renderComposer();await act(async()=>staleResolve())
|
||||
check(!document.querySelector('[data-composer-approval]')&&settled===undefined,'session switch unmounts old request and ignores its late result')
|
||||
active=true;stopFails=true;await renderComposer();await act(async()=>click('.wb-composer__approval-stop'))
|
||||
check(document.querySelector('[data-composer-approval] [role="alert"]')?.textContent?.includes('stop fixture failed'),'failed cancellation visible in approval surface')
|
||||
request='request-2';stopFails=false;await renderComposer()
|
||||
check(!document.querySelector('[data-composer-approval] [role="alert"]'),'next request clears previous cancellation failure')
|
||||
await act(async()=>click('.wb-composer__approval-stop'))
|
||||
check(!document.querySelector('[data-composer-approval] [role="alert"]'),'cancellation retry clears error')
|
||||
check(stops===2,'stop remains reachable independently of normal send')
|
||||
stopPending=true;await renderComposer();await act(async()=>click('.wb-composer__approval-stop'))
|
||||
check(document.querySelector<HTMLButtonElement>('.wb-composer__approval-stop')?.disabled,'pending stop disabled against duplicate cancellation')
|
||||
request='request-3';stopPending=false;await renderComposer();await act(async()=>rejectStop(new Error('obsolete stop failure')))
|
||||
check(!document.querySelector('[data-composer-approval] [role="alert"]')&&!document.querySelector<HTMLButtonElement>('.wb-composer__approval-stop')?.disabled,'late previous-request cancellation cannot alter new request')
|
||||
active=false;await renderComposer();check(!document.querySelector('[data-composer-approval]'),'cancelled interaction restores ordinary input')
|
||||
// An upload already started before takeover must finish into the same draft without stealing focus.
|
||||
let uploaded: typeof attachments=[], stagingResolve!: (value: typeof attachments[number])=>void, uploadActive=false
|
||||
const uploadHost={pick:async()=>[],stage:()=>new Promise<typeof attachments[number]>(yes=>{stagingResolve=yes})}
|
||||
const renderUpload=()=>act(async()=>root.render(<ComposerControls key="upload-session" sessionId="upload-session" value="upload draft" onChange={()=>{}} onSend={()=>{sends++}}
|
||||
attachments={uploaded} onAttachmentsChange={items=>{uploaded=items}} attachmentHost={uploadHost}
|
||||
approvalKey="upload-approval" approvalSlot={uploadActive?<PermissionConfirm pendingId="upload-approval" toolName="Bash" options={options} resolve={pending} reject={async()=>{}}/>:undefined}/>))
|
||||
await renderUpload()
|
||||
const drop=new Event('drop',{bubbles:true,cancelable:true})
|
||||
Object.defineProperty(drop,'dataTransfer',{value:{files:[new File(['fixture'],'upload.txt',{type:'text/plain'})],types:[],getData:()=>''}})
|
||||
await act(async()=>{document.querySelector('.wb-composer')!.dispatchEvent(drop);await new Promise(yes=>setTimeout(yes,30))})
|
||||
check(typeof stagingResolve==='function','attachment staging started before takeover')
|
||||
uploadActive=true;await renderUpload()
|
||||
await act(async()=>stagingResolve({id:'uploaded',name:'upload.txt',mimeType:'text/plain',size:7}))
|
||||
await renderUpload()
|
||||
check(uploaded.length===1,'in-flight attachment preserved in owning draft during approval')
|
||||
check(!document.querySelector('.conversation-input-area__input-stack')!.contains(document.activeElement),'upload completion does not focus hidden editor')
|
||||
uploadActive=false;await renderUpload()
|
||||
check(document.querySelector('.conversation-input-area__input-stack')?.textContent?.includes('upload.txt'),'completed attachment restored after approval')
|
||||
await act(async()=>root.unmount());dom.window.close()
|
||||
console.log(`Permission intercept DOM: ${checks} checks passed (not pixel/SDK verification)`)
|
||||
process.exit(0)
|
||||
|
||||
Reference in new issue
Block a user