import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; // Run against a local Vite server with an existing Playwright/Chromium installation: // PLAYWRIGHT_MODULE=/path/to/playwright-core/index.mjs CHROME_EXECUTABLE=/path/to/chrome // WEB_TEST_URL=http://127.0.0.1:5192 node tests/agent-attachments.browser.mjs // Every API and Agent socket is mocked; this does not contact the deployed runtime. const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright-core'); const target = new URL(process.env.WEB_TEST_URL || 'http://127.0.0.1:5192/'); assert.ok(target.protocol === 'http:' && ['localhost','127.0.0.1','[::1]'].includes(target.hostname), 'Use a loopback development server'); const browser=await chromium.launch({executablePath:process.env.CHROME_EXECUTABLE || undefined,headless:true, ...(process.env.BROWSER_NO_SANDBOX === '1' ? {args:['--no-sandbox']} : {})}); const context=await browser.newContext({viewport:{width:1200,height:900},permissions:['clipboard-write']}); await context.addInitScript(()=>localStorage.setItem('sybil_admin_token','browser-fixture-token')); const page=await context.newPage(), errors=[], uploads=[], sends=[], downloads=[]; page.on('pageerror',e=>errors.push(e.stack || e.message)); const cap={version:1,maxCount:8,maxTotalBytes:33554432,maxImageBytes:6291456,maxFileBytes:8388608,imageMimeTypes:['image/png','image/jpeg','image/gif','image/webp'],quotaBytes:1073741824,usedBytes:0,remainingBytes:1073741824,maxConcurrentUploads:2,maxConcurrentDecodes:1}; const idle={state:'idle',isThinking:false,queued:0,backgroundWorkers:0,tools:[],queuedMessages:[]}; let failedUpload=true, failedSend=true, live, cursor=0;const history=[]; const image=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a2ioAAAAASUVORK5CYII=','base64'); const attachment=(name='notes.txt')=>({id:`att_12345678-1234-4234-8234-${name==='picture.png'?'123456789abd':'123456789abc'}`,filename:name,mimeType:name==='picture.png'?'image/png':'text/plain',kind:name==='picture.png'?'image':'text',sizeBytes:name==='picture.png'?image.length:4,sha256:createHash('sha256').update(name==='picture.png'?image:Buffer.from('data')).digest('hex'),workspacePath:`/data/workspace/uploads/test/${name}`}); await page.route('**/api/**',async route=>{ const req=route.request(),url=new URL(req.url()),p=url.pathname,m=req.method();let value; if(p.startsWith('/api/v1/agent'))assert.equal(req.headers().authorization,'Bearer browser-fixture-token'); if(p.endsWith('/attachments')&&m==='POST'){ const body=req.postDataBuffer().toString();uploads.push(body); if(failedUpload){failedUpload=false;await route.fulfill({status:507,json:{message:'Storage quota reached',code:'attachment_quota_exceeded'}});return;} await route.fulfill({status:201,json:{attachment:attachment(body.includes('picture.png')?'picture.png':'notes.txt')}});return; } if(p.endsWith('/content')){downloads.push(req.url());await route.fulfill({contentType:p.includes('789abd')?'image/png':'text/plain',body:p.includes('789abd')?image:'data'});return;} if(p.endsWith('/agent/messages')&&m==='POST'){ const body=req.postDataJSON();sends.push(body); if(failedSend){failedSend=false;await route.fulfill({status:503,json:{message:'Temporarily unavailable'}});return;} const files=(body.attachments||[]).map(f=>attachment(f.id.endsWith('abd')?'picture.png':'notes.txt')); const row={id:`inbound:sybil:${body.requestId}`,role:'user',createdAt:new Date().toISOString(),content:body.content||`Attached: ${files.map(f=>f.filename).join(', ')}.`,metadata:{kind:'message',status:'complete',entryId:++cursor,agentAttachments:files}};history.push(row); live?.send(JSON.stringify({type:'messages',conversationId:'1',messages:[row],cursor:String(cursor),beforeCursor:String(cursor),hasMore:false})); await route.fulfill({status:202,json:{id:cursor,status:'queued',messageId:row.id,attachmentIds:files.map(f=>f.id)}});return; } if(p.endsWith('/agent/messages'))value={conversationId:'1',messages:history,cursor:String(cursor),beforeCursor:null,hasMore:false}; else if(p.endsWith('/read-state'))value={cursor:String(cursor),updatedAt:null}; else if(p.endsWith('/agent'))value={id:'1',title:'Sybil Agent',activity:idle,capabilities:{attachments:cap},protocol:'pi-room.v1',websocket:'/v1/agent/ws'}; else if(p.includes('session'))value={mode:'token',authenticated:true}; else if(p.endsWith('/tools')||p.endsWith('/chat-tools'))value={tools:[]}; else if(p.endsWith('/models'))value={models:[],providers:{}}; else if(p.includes('stream'))value=[]; else value=[]; await route.fulfill({json:value}); }); await page.routeWebSocket('**/api/v1/agent/ws',socket=>{live=socket;socket.onMessage(raw=>{if(JSON.parse(String(raw)).type==='auth')socket.send(JSON.stringify({type:'ready',conversationId:'1'}));});}); try{ await page.goto(target.href,{waitUntil:'networkidle'}); await page.locator('[data-testid="agent-sidebar-item"]').click(); await page.getByRole('button',{name:'Attach files',exact:true}).waitFor(); const picker=page.getByLabel('Choose attachments'); await picker.setInputFiles({name:'notes.txt',mimeType:'text/plain',buffer:Buffer.from('data')}); await page.getByText('Storage quota reached',{exact:true}).waitFor(); assert.equal(await page.getByRole('button',{name:'Send message',exact:true}).isDisabled(),true); await page.getByRole('button',{name:'Retry upload',exact:true}).click(); await page.getByText('Uploaded',{exact:true}).waitFor(); await page.getByRole('button',{name:/^New chat/}).first().click();await page.locator('.agent-room__composer-area').waitFor({state:'detached'});await page.locator('[data-testid="agent-sidebar-item"]').click();await page.getByText('Uploaded',{exact:true}).waitFor(); await page.reload({waitUntil:'networkidle'});await page.locator('[data-testid="agent-sidebar-item"]').click();await page.getByText('Uploaded',{exact:true}).waitFor(); const uuid=body=>body.match(/name="uploadId"\r\n\r\n([^\r]+)/)?.[1];assert.equal(uuid(uploads[0]),uuid(uploads[1])); await page.getByRole('button',{name:'Remove notes.txt'}).click();assert.equal(await page.locator('.agent-room__upload').count(),0); await picker.setInputFiles([{name:'notes.txt',mimeType:'text/plain',buffer:Buffer.from('data')},{name:'picture.png',mimeType:'image/png',buffer:image}]); await page.waitForFunction(()=>document.querySelectorAll('.agent-room__upload [role="status"]').length===2&&Array.from(document.querySelectorAll('.agent-room__upload [role="status"]')).every(e=>e.textContent==='Uploaded')); await page.getByRole('button',{name:'Send message',exact:true}).click();await page.getByRole('button',{name:'Retry message',exact:true}).waitFor(); assert.deepEqual(sends[0].attachments,[{id:attachment().id},{id:attachment('picture.png').id}]);assert.equal(sends[0].content,''); await page.reload({waitUntil:'networkidle'});await page.locator('[data-testid="agent-sidebar-item"]').click();await page.getByRole('button',{name:'Retry message',exact:true}).click(); await page.getByText('Attached: notes.txt, picture.png.',{exact:true}).waitFor();assert.equal(sends[1].requestId,sends[0].requestId); await page.getByRole('button',{name:'Preview',exact:true}).click();await page.locator('.agent-attachment__preview').waitFor();assert.ok((await page.locator('.agent-attachment__preview').getAttribute('src')).startsWith('blob:')); await page.getByRole('button',{name:'Hide preview',exact:true}).click();assert.equal(await page.locator('.agent-attachment__preview').count(),0); const download=page.waitForEvent('download');await page.getByRole('button',{name:'Download',exact:true}).first().click();assert.equal((await download).suggestedFilename(),'notes.txt');assert.ok(downloads.every(url=>!url.includes('token'))); await page.getByRole('button',{name:'Copy path for notes.txt'}).click(); // Real paste/drop DOM events feed the same upload queue. await page.getByRole('textbox',{name:'Message Sybil Agent'}).evaluate(el=>{const data=new DataTransfer();data.items.add(new File(['data'],'notes.txt',{type:'text/plain'}));el.dispatchEvent(new ClipboardEvent('paste',{clipboardData:data,bubbles:true,cancelable:true}));}); await page.getByText('Uploaded',{exact:true}).waitFor();await page.getByRole('button',{name:'Remove notes.txt'}).click(); await page.locator('.agent-room__composer-area').evaluate(el=>{const data=new DataTransfer();data.items.add(new File(['data'],'notes.txt',{type:'text/plain'}));el.dispatchEvent(new DragEvent('drop',{dataTransfer:data,bubbles:true,cancelable:true}));}); await page.getByText('Uploaded',{exact:true}).waitFor(); await page.setViewportSize({width:390,height:844});if(process.env.ATTACHMENT_SCREENSHOT)await page.screenshot({path:process.env.ATTACHMENT_SCREENSHOT}); assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>window.innerWidth),false); assert.deepEqual(errors,[]);console.log('PASS browser picker/retry/quota/remove, file-only send/reload, authenticated preview/download, copy, paste/drop, mobile layout'); }finally{await context.close();await browser.close();}