Przeglądaj źródła

fix: 人工拖动请求仅发送截图坐标与协议字段

Samien 1 miesiąc temu
rodzic
commit
616b86132c

+ 4 - 3
src/client/utils/browserDrag.ts

@@ -14,7 +14,7 @@ export class BrowserDragSession {
   private heartbeat:ReturnType<typeof setInterval>
   private latest:Point
   constructor(start:Point,private send:(action:BrowserPointerAction)=>Promise<void>,private failed:(error:unknown)=>void){
-    this.latest=start;this.done=new Promise(resolve=>{this.resolve=resolve})
+    this.latest={x:start.x,y:start.y};this.done=new Promise(resolve=>{this.resolve=resolve})
     this.heartbeat=setInterval(()=>{if(!this.ending)this.move(this.latest)},1000)
     this.enqueue('start',start)
   }
@@ -26,8 +26,9 @@ export class BrowserDragSession {
     return this.done
   }
   private enqueue(phase:BrowserPointerAction['phase'],point:Point){
-    this.latest=point
-    const action:BrowserPointerAction={kind:'pointer',gestureId:this.id,phase,...point}
+    // Type annotations do not strip the viewer's DOM element and pointer metadata.
+    this.latest={x:point.x,y:point.y}
+    const action:BrowserPointerAction={kind:'pointer',gestureId:this.id,phase,x:point.x,y:point.y}
     if(phase==='move'&&this.queue.at(-1)?.phase==='move')this.queue[this.queue.length-1]=action
     else this.queue.push(action)
     void this.flush()

+ 22 - 3
tests/client/browserDrag.test.ts

@@ -4,6 +4,21 @@ import {BrowserDragSession} from '@/utils/browserDrag'
 import type {BrowserPointerAction} from '@shared/managedBrowser'
 
 afterEach(()=>vi.useRealTimers())
+it('copies only frame coordinates from the viewer pointer state into every request',async()=>{
+  vi.useFakeTimers()
+  const start={x:1,y:2,clientX:101,clientY:102,pointerId:3,element:document.createElement('img')}
+  const send=vi.fn(async(_action:BrowserPointerAction)=>{}),session=new BrowserDragSession(start,send,vi.fn())
+  start.x=99
+  await flushPromises();await vi.advanceTimersByTimeAsync(1000)
+  session.move({...start,x:30,y:40});await flushPromises()
+  session.end({...start,x:50,y:60});await session.done
+  expect(send.mock.calls.map(([action])=>action)).toEqual([
+    {kind:'pointer',gestureId:session.id,phase:'start',x:1,y:2},
+    {kind:'pointer',gestureId:session.id,phase:'move',x:1,y:2},
+    {kind:'pointer',gestureId:session.id,phase:'move',x:30,y:40},
+    {kind:'pointer',gestureId:session.id,phase:'end',x:50,y:60},
+  ])
+})
 it('serializes input and coalesces pending movement without losing the final point',async()=>{
   vi.useFakeTimers()
   let release!:()=>void
@@ -32,9 +47,13 @@ it('keeps a stationary hold alive and discards queued moves on cancellation',asy
 it('releases an uncertain input after failure without replaying it',async()=>{
   vi.useFakeTimers()
   const send=vi.fn(async(_action:BrowserPointerAction)=>{}).mockRejectedValueOnce(new Error('timeout')),failed=vi.fn()
-  const session=new BrowserDragSession({x:1,y:2},send,failed)
-  session.move({x:30,y:40});await session.done
-  expect(send.mock.calls.map(([action])=>action.phase)).toEqual(['start','cancel'])
+  const start={x:1,y:2,clientX:101,clientY:102,pointerId:3,element:document.createElement('img')}
+  const session=new BrowserDragSession(start,send,failed)
+  await session.done
+  expect(send.mock.calls.map(([action])=>action)).toEqual([
+    {kind:'pointer',gestureId:session.id,phase:'start',x:1,y:2},
+    {kind:'pointer',gestureId:session.id,phase:'cancel',x:1,y:2},
+  ])
   expect(failed).toHaveBeenCalledWith(expect.objectContaining({message:'timeout'}))
   await vi.advanceTimersByTimeAsync(10000);expect(send).toHaveBeenCalledTimes(2)
 })

+ 10 - 0
tests/client/managedBrowserCardSmoke.test.ts

@@ -49,6 +49,11 @@ it.runIf(process.env.YAOYAO_BROWSER_UI_SMOKE==='1')('opens the structured chat c
       if(path.endsWith('/frame'))return route.fulfill({json:{id:'frame',generation:4,width:1000,height:650,data:image}})
       if(route.request().method()==='POST'){
         const op=path.split('/').at(-1)!;calls.push({op,body:route.request().postDataJSON()})
+        if(op==='input'){
+          const action=route.request().postDataJSON().action
+          const extra=Object.keys(action).filter(key=>!['gestureId','kind','phase','x','y'].includes(key))
+          if(extra.length)return route.fulfill({status:400,json:{error:'Unrecognized keys: '+extra.join(', ')}})
+        }
         if(op==='take'){expect(available).toBe(true);human=true;browserOpen=true}
         if(op==='giveback')human=false
       }
@@ -91,6 +96,9 @@ it.runIf(process.env.YAOYAO_BROWSER_UI_SMOKE==='1')('opens the structured chat c
     await browserExpect.poll(()=>calls.filter(call=>call.op==='input'&&call.body.action.phase==='move').length).toBeGreaterThan(0)
     const liveInputs=()=>calls.filter(call=>call.op==='input').map(call=>call.body.action)
     expect(liveInputs()[0]).toMatchObject({kind:'pointer',phase:'start'})
+    // The real server rejects extra fields. Matching only a subset masked the
+    // viewer's clientX/clientY/pointerId/element leaking into the start request.
+    expect(Object.keys(liveInputs()[0]).sort()).toEqual(['gestureId','kind','phase','x','y'])
     expect(liveInputs().some(action=>action.phase==='end')).toBe(false)
     // Pointer capture keeps a release outside the image, clamped to the frame.
     await page.mouse.move(bounds.x+bounds.width+20,centerY);await page.mouse.up()
@@ -103,6 +111,8 @@ it.runIf(process.env.YAOYAO_BROWSER_UI_SMOKE==='1')('opens the structured chat c
     await browserExpect.poll(()=>liveInputs().filter(action=>action.phase==='cancel').length).toBe(1)
     await page.mouse.up()
     expect(liveInputs().every(action=>action.kind==='pointer')).toBe(true)
+    for(const action of liveInputs())expect(Object.keys(action).sort()).toEqual(['gestureId','kind','phase','x','y'])
+    await browserExpect(panel.locator('.computer-error')).toHaveCount(0)
     await panel.getByRole('button',{name:'交还并收起',exact:true}).click()
     await browserExpect(panel).toHaveCount(0)
     expect(calls.filter(call=>call.op==='giveback')).toHaveLength(1)