2026-10-14 17:25:52 -07:00
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' ));
2026-10-20 14:19:23 -07:00
const page = await context . newPage (), errors = [], uploads = [], sends = [], downloads = [], trackingRequests = [];
2026-10-14 17:25:52 -07:00
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 : []};
2026-10-20 14:19:23 -07:00
let failedUpload = true , failedSend = true , live , cursor = 0 , inputAttachments = true ; const history = [];
2026-10-14 17:25:52 -07:00
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 } ` });
2026-10-20 14:19:23 -07:00
const assistantImage = {... attachment ( 'picture.png' ), id : 'att_12345678-1234-4234-8234-123456789abe' , filename : 'assistant.png' }; delete assistantImage . workspacePath ;
await page . route ( 'https://never-fetch.invalid/**' , async route =>{ trackingRequests . push ( route . request (). url ()); await route . abort ();});
2026-10-14 17:25:52 -07:00
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 ;
}
2026-10-20 14:19:23 -07:00
if ( p . endsWith ( '/content' )){ downloads . push ( req . url ()); const isImage = p . includes ( '789abd' ) || p . includes ( '789abe' ); await route . fulfill ({ contentType : isImage ? 'image/png' : 'text/plain' , body : isImage ? image : 'data' }); return ;}
2026-10-14 17:25:52 -07:00
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 };
2026-10-20 14:19:23 -07:00
else if ( p . endsWith ( '/agent' )) value = { id : '1' , title : 'Sybil Agent' , activity : idle , capabilities : {...( inputAttachments ? { attachments : cap } : {}), assistantImages : { version : 1 }}, protocol : 'pi-room.v1' , websocket : '/v1/agent/ws' };
2026-10-14 17:25:52 -07:00
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 ();
2026-10-20 14:19:23 -07:00
await page . getByRole ( 'button' ,{ name : 'Remove notes.txt' }). click ();
// Assistant image-only messages use authenticated bytes automatically, with no source path.
const assistant = { id : 'outbound:900' , role : 'assistant' , createdAt : new Date (). toISOString (), content : '' , metadata : { kind : 'message' , status : 'complete' , entryId :++ cursor , agentAttachments : [ assistantImage ]}}; history . push ( assistant );
live . send ( JSON . stringify ({ type : 'messages' , conversationId : '1' , messages : [ assistant ], cursor : String ( cursor ), beforeCursor : String ( cursor ), hasMore : false }));
const assistantBubble = page . locator ( '[data-agent-message-id="outbound:900"]' ), inlineImage = assistantBubble . locator ( '.agent-attachment__preview' );
await inlineImage . waitFor (); await inlineImage . evaluate ( img => img . decode ());
assert . ok (( await inlineImage . getAttribute ( 'src' )). startsWith ( 'blob:' ));
assert . equal ( await assistantBubble . getByRole ( 'button' ,{ name : 'Preview' , exact : true }). count (), 0 );
assert . equal ( await assistantBubble . locator ( '.agent-attachment__path' ). count (), 0 );
const tracking = { id : 'outbound:901' , role : 'assistant' , createdAt : new Date (). toISOString (), content : 'Remote images must not load.\n\n <img src="https://never-fetch.invalid/html.png">' , metadata : { kind : 'message' , status : 'complete' , entryId :++ cursor }}; history . push ( tracking );
live . send ( JSON . stringify ({ type : 'messages' , conversationId : '1' , messages : [ tracking ], cursor : String ( cursor ), beforeCursor : String ( cursor ), hasMore : false }));
await page . getByText ( 'Remote images must not load.' ,{ exact : true }). waitFor ();
assert . equal ( await page . locator ( '[data-agent-message-id="outbound:901"] .md-content img' ). count (), 0 );
// An output-only capability must preserve history without enabling input controls.
inputAttachments = false ; await page . reload ({ waitUntil : 'networkidle' }); await page . locator ( '[data-testid="agent-sidebar-item"]' ). click ();
await inlineImage . waitFor (); await inlineImage . evaluate ( img => img . decode ());
assert . equal ( await assistantBubble . count (), 1 ); assert . equal ( await page . locator ( '.agent-attachment__preview' ). count (), 1 );
assert . ok (( await inlineImage . getAttribute ( 'src' )). startsWith ( 'blob:' ));
assert . equal ( await page . getByRole ( 'button' ,{ name : 'Attach files' , exact : true }). count (), 0 );
assert . equal ( await page . getByLabel ( 'Choose attachments' ). count (), 0 );
assert . deepEqual ( trackingRequests ,[], 'Neither live nor historical Markdown/HTML image markup fetches remote bytes' );
2026-10-14 17:25:52 -07:00
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 );
2026-10-20 14:19:23 -07:00
assert . deepEqual ( errors ,[]); console . log ( 'PASS browser picker/retry/quota/remove, file-only send/reload, authenticated preview/download, copy, paste/drop, assistant image-only live/history, output-only capability, blocked Markdown/HTML images, mobile layout' );
2026-10-14 17:25:52 -07:00
} finally { await context . close (); await browser . close ();}