2026-11-02 18:34:44 -08:00
import assert from "node:assert/strict" ;
import { mkdir } from "node:fs/promises" ;
// Run against a local production preview after npm run build. No live API calls.
// PLAYWRIGHT_MODULE=/path/playwright-core/index.mjs CHROME_EXECUTABLE=/path/chrome
// WEB_TEST_URL=http://127.0.0.1:5197 node tests/mobile-workspace.browser.mjs
const { chromium } = await import ( process . env . PLAYWRIGHT_MODULE || "playwright-core" );
const target = new URL ( process . env . WEB_TEST_URL || "http://127.0.0.1:5197/" );
assert . ok ( target . protocol === "http:" && [ "127.0.0.1" , "localhost" , "[::1]" ]. includes ( target . hostname ));
const baseline = process . env . MOBILE_BASELINE === "1" ;
const artifactDir = process . env . MOBILE_ARTIFACT_DIR ;
if ( artifactDir ) await mkdir ( artifactDir , { recursive : true });
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 : 390 , height : 844 }, isMobile : true , hasTouch : true ,
permissions : [ "clipboard-read" , "clipboard-write" ] });
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce" ;
const date = "2026-11-02T12:00:00.000Z" ;
const markdown = [
"Mobile answer with selectable text. " + "This regular chat response should use the available width. " . repeat ( 7 ),
"| Name | Description with a very wide column | Another long heading | Final column |\n| --- | --- | --- | --- |\n| Alpha | Wide table content | More content | End |" ,
"> A quotation with selectable text and a nested horizontal code block:\n>\n> ```text\n> " + "quoted-unbroken-code-" . repeat ( 22 ) + "\n> ```" ,
"```js\nconst longLine = '" + "horizontal-code-" . repeat ( 28 ) + "';\n```" ,
"A final paragraph with a [native link menu](https://example.test/document)." ,
]. join ( "\n\n" );
const chat = { id : "mobile-chat" , title : "Mobile QA conversation with a long title" , parentChatId : null , titleGenerationPending : false ,
createdAt : date , updatedAt : date , starred : false , starredAt : null , initiatedProvider : "openai" , initiatedModel : "mobile-model-one" ,
lastUsedProvider : "openai" , lastUsedModel : "mobile-model-one" , additionalSystemPrompt : null , enabledTools : [],
2026-11-02 21:11:19 -08:00
messages : [{ id : "mobile-assistant" , role : "assistant" , content : markdown , createdAt : date , name : null , metadata : { generation : { provider : "openai" , model : "mobile-model-one" } } }] };
2026-11-02 18:34:44 -08:00
const idle = { state : "idle" , isThinking : false , queued : 0 , backgroundWorkers : 0 , tools : [], queuedMessages : [] };
2026-11-02 20:52:38 -08:00
const errors = [], calls = [], forkRequests = [];
let forkedChat ;
2026-11-02 18:34:44 -08:00
let readCursor = "0" ;
await context . addInitScript (() => localStorage . setItem ( "sybil_admin_token" , "mobile-fixture-token" ));
await context . route ( "**/*" , async route => {
const url = new URL ( route . request (). url ());
if ( url . origin !== target . origin ) { await route . abort (); return ; }
if ( ! url . pathname . startsWith ( "/api/" )) { await route . continue (); return ; }
const path = url . pathname , method = route . request (). method ();
calls . push ({ path , method });
let value ;
if ( path . endsWith ( "/auth/session" )) value = { mode : "token" , authenticated : true };
2026-11-02 20:52:38 -08:00
else if ( path . endsWith ( "/workspace-items" )) value = { items : [ chat , ...( forkedChat ? [ forkedChat ] : [])]. map ( chat => ({ ... chat , type : "chat" })) };
2026-11-02 18:34:44 -08:00
else if ( path . endsWith ( "/models" )) value = { providers : { openai : { models : [ "mobile-model-one" , "mobile-model-two" ], loadedAt : date , error : null } } };
else if ( path . endsWith ( "/chat-tools" ) || path . endsWith ( "/agent/tools" )) value = { tools : [] };
else if ( path . endsWith ( "/active-runs" )) value = { chats : [], searches : [] };
else if ( path . endsWith ( "/chats/mobile-chat" )) {
if ( method === "PATCH" ) Object . assign ( chat , route . request (). postDataJSON ());
value = { chat };
2026-11-02 20:52:38 -08:00
} else if ( path . endsWith ( "/chats/mobile-chat/fork" )) {
forkRequests . push ( route . request (). postDataJSON ());
await new Promise ( resolve => setTimeout ( resolve , 300 ));
forkedChat = { ... chat , id : "mobile-fork" , title : "Forked conversation" , parentChatId : chat . id };
value = { chat : forkedChat };
} else if ( path . endsWith ( "/chats/mobile-fork" )) value = { chat : forkedChat };
else if ( path . endsWith ( "/agent/messages" )) value = { conversationId : "1" , messages : [{ ... chat . messages [ 0 ], id : "outbound:1" , metadata : { entryId : 1 } }], cursor : "1" , beforeCursor : null , hasMore : false };
2026-11-02 18:34:44 -08:00
else if ( path . endsWith ( "/agent" )) value = { id : "1" , title : "Sybil Agent" , activity : idle , protocol : "pi-room.v1" , websocket : "/v1/agent/ws" };
else if ( path . endsWith ( "/read-state" )) {
if ( method === "PUT" ) readCursor = route . request (). postDataJSON (). cursor ;
value = { sourceId , cursor : readCursor , updatedAt : null };
}
else if ( path . endsWith ( "/push/config" )) value = { version : 1 , enabled : false , webPushAvailable : false , apnsAvailable : false , sourceId , quarantined : false , policy : { presenceHeartbeatMs : 15000 , presenceLeaseMs : 60000 } };
else if ( path . includes ( "/presence/" )) value = { expiresAt : date };
else value = {};
await route . fulfill ({ json : value });
});
await context . routeWebSocket ( "**/api/v1/agent/ws" , socket => socket . onMessage ( raw => {
if ( JSON . parse ( String ( raw )). type === "auth" ) socket . send ( JSON . stringify ({ type : "ready" , conversationId : "1" }));
}));
const page = await context . newPage ();
page . on ( "pageerror" , error => errors . push ( error . message ));
const cdp = await context . newCDPSession ( page );
async function openSidebar () {
const sidebar = page . locator ( ".workspace-sidebar" );
if ( await sidebar . evaluate ( el => el . getBoundingClientRect (). right <= 0 )) {
await page . getByRole ( "button" , { name : "Open sidebar" , exact : true }). click ();
}
}
async function selectChat () {
await openSidebar ();
await page . getByRole ( "button" , { name : new RegExp ( chat . title ) }). click ();
await page . locator ( "main h1" ). filter ({ hasText : chat . title }). waitFor ();
await page . locator ( "main .md-content" ). waitFor ();
await page . waitForFunction (() => document . querySelector ( ".workspace-sidebar" ). getBoundingClientRect (). right <= 1 );
}
async function drag ( x , y , dx , dy = 0 ) {
await cdp . send ( "Input.dispatchTouchEvent" , { type : "touchStart" , touchPoints : [{ x , y }] });
for ( let step = 1 ; step <= 8 ; step ++ ) {
await cdp . send ( "Input.dispatchTouchEvent" , { type : "touchMove" , touchPoints : [{ x : x + dx * step / 8 , y : y + dy * step / 8 }] });
await page . waitForTimeout ( 25 );
}
await cdp . send ( "Input.dispatchTouchEvent" , { type : "touchEnd" , touchPoints : [] });
await page . waitForTimeout ( 200 );
}
async function scrollHorizontally ( locator ) {
await locator . scrollIntoViewIfNeeded ();
await locator . evaluate ( el => { el . scrollLeft = 0 ; });
const box = await locator . boundingBox ();
const x = Math . min ( box . x + box . width - 30 , 350 ), y = box . y + Math . min ( box . height / 2 , 30 );
await drag ( x , y , - 130 );
}
async function contextMenuCanceled ( locator ) {
return locator . evaluate ( el => ! el . dispatchEvent ( new MouseEvent ( "contextmenu" , { bubbles : true , cancelable : true , clientX : 100 , clientY : 200 })));
}
2026-11-02 20:52:38 -08:00
async function checkFooterAlignment () {
const footer = page . locator ( '[data-assistant-footer="mobile-assistant"]' );
await footer . scrollIntoViewIfNeeded ();
// Leave space for the fixed composer when inspecting the final reply actions.
await footer . evaluate ( el => el . scrollIntoView ({ block : "center" }));
const layout = await footer . evaluate ( el => ({
paragraphLeft : el . parentElement . querySelector ( ".md-content > p" ). getBoundingClientRect (). left ,
dateLeft : el . querySelector ( "time" ). getBoundingClientRect (). left ,
actionLeft : el . querySelector ( "button svg" ). getBoundingClientRect (). left ,
dateColor : getComputedStyle ( el . querySelector ( "time" )). color ,
paragraphColor : getComputedStyle ( el . parentElement . querySelector ( ".md-content" )). color ,
}));
assert . ok ( Math . abs ( layout . dateLeft - layout . paragraphLeft ) < 1 , "timestamp aligns with reply text" );
assert . ok ( Math . abs ( layout . actionLeft - layout . paragraphLeft ) < 1 , "first action icon aligns with reply text" );
assert . notEqual ( layout . dateColor , layout . paragraphColor , "timestamp uses secondary color" );
assert . equal ( await footer . locator ( "time" ). getAttribute ( "datetime" ), date );
assert . match ( await footer . locator ( "time" ). textContent (), /2026/ );
2026-11-02 21:11:19 -08:00
assert . equal ( await footer . locator ( "[data-message-generation]" ). textContent (), "OpenAI · mobile-model-one" );
2026-11-02 20:52:38 -08:00
}
2026-11-02 18:34:44 -08:00
try {
await page . goto ( target . href , { waitUntil : "networkidle" });
await selectChat ();
const metrics = await page . locator ( "main .md-content" ). evaluate ( el => ({
width : el . getBoundingClientRect (). width , parentWidth : el . parentElement . parentElement . getBoundingClientRect (). width ,
touchAction : getComputedStyle ( document . querySelector ( "main" )). touchAction ,
modelVisible : Array . from ( document . querySelectorAll ( "main header p" )). some ( el => el . textContent . includes ( "mobile-model-one" ) && el . getBoundingClientRect (). height > 0 ),
}));
if ( artifactDir ) await page . screenshot ({ path : ` ${ artifactDir } /regular-390.png` });
const canceled = await contextMenuCanceled ( page . locator ( "main .md-content p" ). first ());
if ( baseline ) { await page . getByRole ( "button" , { name : "Fork" , exact : true }). waitFor (); await page . keyboard . press ( "Escape" ); }
const table = page . locator ( "main .md-table-scroll" );
await scrollHorizontally ( table );
const stayed = await page . locator ( "main h1" ). textContent () === chat . title ;
const scrolled = stayed ? await table . evaluate ( el => el . scrollLeft ) : 0 ;
console . log ( JSON . stringify ({ baseline , metrics , contextMenuCanceled : canceled , tableScrollLeft : scrolled , stayedInChat : stayed }));
if ( baseline ) {
await openSidebar (); await page . locator ( '[data-testid="agent-sidebar-item"]' ). click ();
await page . locator ( ".agent-room .md-content" ). waitFor ();
await page . waitForFunction (() => document . querySelector ( ".workspace-sidebar" ). getBoundingClientRect (). right <= 1 );
console . log ( "Agent width" , await page . locator ( ".agent-room .md-content" ). evaluate ( el => el . getBoundingClientRect (). width ));
if ( artifactDir ) await page . screenshot ({ path : ` ${ artifactDir } /agent-390.png` });
} else {
assert . equal ( metrics . touchAction , "auto" );
assert . equal ( metrics . modelVisible , true );
assert . ok ( Math . abs ( metrics . width - metrics . parentWidth ) < 2 , "compact answer uses full transcript width" );
assert . equal ( canceled , false , "native menu is not canceled" );
assert . ok ( stayed && scrolled > 50 , "trusted horizontal pan scrolls the table without navigation" );
// Even at either end of nested scrollers, pans belong to the content.
for ( const locator of [ table , page . locator ( "main blockquote pre" ), page . locator ( "main .md-content > pre" )]) {
await scrollHorizontally ( locator );
assert . equal ( await page . locator ( "main h1" ). textContent (), chat . title );
assert . ok ( await locator . evaluate ( el => el . scrollLeft > 50 ));
await locator . evaluate ( el => { el . scrollLeft = el . scrollWidth ; });
await locator . scrollIntoViewIfNeeded ();
let box = await locator . boundingBox ();
await drag ( box . x + box . width - 30 , box . y + 20 , - 130 );
assert . equal ( await page . locator ( "main h1" ). textContent (), chat . title );
await locator . evaluate ( el => { el . scrollLeft = 0 ; });
box = await locator . boundingBox ();
await drag ( box . x + 30 , box . y + 20 , 130 );
assert . ok ( await page . locator ( ".workspace-sidebar" ). evaluate ( el => el . getBoundingClientRect (). right <= 0 ));
}
const paragraph = page . locator ( "main .md-content p" ). first ();
await paragraph . scrollIntoViewIfNeeded ();
await paragraph . evaluate ( el => {
const range = document . createRange (); range . selectNodeContents ( el );
window . getSelection (). removeAllRanges (); window . getSelection (). addRange ( range );
});
await page . keyboard . press ( "Control+c" );
assert . match ( await page . evaluate (() => navigator . clipboard . readText ()), /Mobile answer/ );
assert . equal ( await contextMenuCanceled ( paragraph ), false );
assert . equal ( await contextMenuCanceled ( page . locator ( "main .md-content a" )), false );
await page . keyboard . press ( "Escape" );
// Selection, multi-touch, long-press and touchcancel must not become navigation.
for ( const scenario of [ "selection" , "multi" , "hold" , "cancel" ]) {
await page . locator ( "main header" ). evaluate (( el , scenario ) => {
if ( scenario !== "selection" ) window . getSelection (). removeAllRanges ();
const touch = ( x , id = 1 ) => new Touch ({ identifier : id , target : el , clientX : x , clientY : 20 });
const send = ( type , touches , changed = touches , time = 0 ) => {
const event = new TouchEvent ( type , { bubbles : true , cancelable : true , touches , changedTouches : changed });
Object . defineProperty ( event , "timeStamp" , { value : 1000 + time }); el . dispatchEvent ( event );
};
send ( "touchstart" , [ touch ( 120 )]);
if ( scenario === "multi" ) send ( "touchstart" , [ touch ( 120 ), touch ( 160 , 2 )]);
if ( scenario === "cancel" ) send ( "touchcancel" , [], [ touch ( 120 )]);
send ( "touchmove" , [ touch ( 280 )], undefined , scenario === "hold" ? 500 : 100 );
send ( "touchend" , [], [ touch ( 280 )], 600 );
}, scenario );
assert . ok ( await page . locator ( ".workspace-sidebar" ). evaluate ( el => el . getBoundingClientRect (). right <= 0 ), scenario );
}
await page . evaluate (() => window . getSelection (). removeAllRanges ());
const composer = page . locator ( "main textarea" );
assert . equal ( await contextMenuCanceled ( composer ), false );
await page . evaluate (() => navigator . clipboard . writeText ( "native pasted draft" ));
await composer . focus (); await page . keyboard . press ( "Control+v" );
assert . equal ( await composer . inputValue (), "native pasted draft" );
assert . equal ( calls . filter ( call => call . method === "POST" ). length , 0 );
2026-11-02 20:52:38 -08:00
// The footer uses the saved date and exposes Copy/Fork without a custom menu.
await checkFooterAlignment ();
const copy = page . getByRole ( "button" , { name : "Copy message" , exact : true });
await copy . click ();
await page . getByText ( "Message copied." , { exact : true }). waitFor ({ state : "attached" });
assert . equal ( await page . evaluate (() => navigator . clipboard . readText ()), markdown );
await page . evaluate (() => Object . defineProperty ( navigator . clipboard , "writeText" , { configurable : true , value : async () => { throw new DOMException ( "Denied" , "NotAllowedError" ); } }));
await copy . click ();
await page . getByText ( /Could not copy\. Select the message text/ ). waitFor ();
await page . evaluate (() => delete navigator . clipboard . writeText );
await copy . focus (); await page . keyboard . press ( "Enter" );
await page . getByText ( "Message copied." , { exact : true }). waitFor ({ state : "attached" });
const fork = page . getByRole ( "button" , { name : "Fork chat from this message" , exact : true });
await fork . click ();
assert . equal ( await fork . isDisabled (), true );
await page . getByRole ( "heading" , { name : "Forked conversation" , exact : true }). waitFor ();
assert . deepEqual ( forkRequests , [{ messageId : "mobile-assistant" }]);
await selectChat ();
2026-11-02 18:34:44 -08:00
await page . getByRole ( "button" , { name : "Open chat settings" }). click ();
await page . getByRole ( "dialog" ). waitFor ();
await page . getByPlaceholder ( "Select or type model" , { exact : true }). fill ( "mobile-model-two" );
await page . getByPlaceholder ( "Select or type model" , { exact : true }). press ( "Enter" );
await page . getByRole ( "button" , { name : "Save" , exact : true }). first (). click ();
await page . getByRole ( "dialog" ). waitFor ({ state : "detached" });
assert . match ( await page . locator ( "main header p" ). textContent (), /mobile-model-two/ );
for ( const width of [ 320 , 390 , 430 , 767 ]) {
await page . setViewportSize ({ width , height : 844 });
assert . equal ( await page . evaluate (() => document . documentElement . scrollWidth > innerWidth ), false , ` ${ width } px page overflow` );
assert . ok ( await page . locator ( "main header p" ). isVisible ());
2026-11-02 20:52:38 -08:00
await checkFooterAlignment ();
2026-11-02 18:34:44 -08:00
if ( artifactDir ) await page . screenshot ({ path : ` ${ artifactDir } /regular- ${ width } .png` });
}
await page . setViewportSize ({ width : 390 , height : 844 });
// Swipes still work in workspace chrome, away from text and scrollable content.
await drag ( 90 , 8 , 150 );
await page . waitForFunction (() => document . querySelector ( ".workspace-sidebar" ). getBoundingClientRect (). left >= 0 );
await page . locator ( '[data-testid="agent-sidebar-item"]' ). click ();
await page . locator ( ".agent-room .md-content" ). waitFor ();
await page . waitForFunction (() => document . querySelector ( ".workspace-sidebar" ). getBoundingClientRect (). right <= 1 );
console . log ( "Agent width" , await page . locator ( ".agent-room .md-content" ). evaluate ( el => el . getBoundingClientRect (). width ));
if ( artifactDir ) await page . screenshot ({ path : ` ${ artifactDir } /agent-390.png` });
await scrollHorizontally ( page . locator ( ".agent-room .md-table-scroll" ));
assert . ok ( await page . locator ( ".agent-room .md-table-scroll" ). evaluate ( el => el . scrollLeft > 50 ));
assert . equal ( await contextMenuCanceled ( page . locator ( ".agent-room .md-content p" ). first ()), false );
assert . equal ( await page . evaluate (() => document . documentElement . scrollWidth > innerWidth ), false );
// Keyboard-height approximation: both composers remain inside a shorter viewport.
await page . setViewportSize ({ width : 390 , height : 450 });
assert . ok ( await page . locator ( ".agent-room textarea" ). evaluate ( el => el . getBoundingClientRect (). bottom <= innerHeight ));
await selectChat ();
assert . ok ( await composer . evaluate ( el => el . getBoundingClientRect (). bottom <= innerHeight ));
await page . setViewportSize ({ width : 1280 , height : 900 });
assert . equal ( await page . evaluate (() => document . documentElement . scrollWidth > innerWidth ), false );
assert . ok ( await page . locator ( "main header button" ). filter ({ hasText : "mobile-model-one" }). isVisible ());
assert . equal ( await contextMenuCanceled ( paragraph ), false );
2026-11-02 20:52:38 -08:00
await checkFooterAlignment ();
await copy . click ();
assert . equal ( await page . evaluate (() => navigator . clipboard . readText ()), markdown );
if ( artifactDir ) await page . screenshot ({ path : ` ${ artifactDir } /regular-1280.png` });
2026-11-02 18:34:44 -08:00
const manifest = await page . evaluate ( async () => ( await fetch ( "/manifest.webmanifest" )). json ());
assert . equal ( manifest . display , "standalone" ); assert . ok ( ! manifest . display_override ? . includes ( "fullscreen" ));
const viewport = await page . locator ( 'meta[name="viewport"]' ). getAttribute ( "content" );
assert . match ( viewport , /user-scalable=no/ );
assert . match ( viewport , /maximum-scale=1\.0(?:,|$)/ );
2026-11-02 21:11:19 -08:00
// A mixed-model history keeps each attribution after reload, including unknown old replies.
const longModel = "claude-" + "long-model-identifier-" . repeat ( 8 );
chat . messages . push (
{ ... chat . messages [ 0 ], id : "second-assistant" , content : "A reply from a different provider." , metadata : { generation : { provider : "anthropic" , model : longModel } } },
{ ... chat . messages [ 0 ], id : "legacy-assistant" , content : "A reply with no generation record." , metadata : null },
);
await page . reload ({ waitUntil : "networkidle" });
await page . getByRole ( "heading" , { name : chat . title , exact : true }). waitFor ();
assert . deepEqual ( await page . locator ( "main [data-message-generation]" ). allTextContents (), [ "OpenAI · mobile-model-one" , `Anthropic · ${ longModel } ` , "Model not recorded" ]);
for ( const width of [ 320 , 390 , 1280 ]) {
await page . setViewportSize ({ width , height : 844 });
if ( width < 768 ) await page . waitForFunction (() => document . querySelector ( ".workspace-sidebar" ). getBoundingClientRect (). right <= 1 );
await page . locator ( '[data-assistant-footer="second-assistant"]' ). evaluate ( el => el . scrollIntoView ({ block : "center" }));
assert . ok ( await page . locator ( '[data-assistant-footer="second-assistant"]' ). evaluate ( el => el . scrollWidth <= el . clientWidth ), "long model identifier wraps within the footer" );
assert . equal ( await page . evaluate (() => document . documentElement . scrollWidth > innerWidth ), false , ` ${ width } px model-label overflow` );
if ( artifactDir ) await page . screenshot ({ path : ` ${ artifactDir } /mixed-models- ${ width } .png` });
}
2026-11-02 18:34:44 -08:00
await page . setViewportSize ({ width : 390 , height : 844 });
2026-11-02 20:52:38 -08:00
await page . waitForFunction (() => document . querySelector ( ".workspace-sidebar" ). getBoundingClientRect (). right <= 1 );
2026-11-02 18:34:44 -08:00
await drag ( 300 , 8 , - 160 );
await page . getByRole ( "heading" , { name : "New chat" , exact : true }). waitFor ();
assert . deepEqual ( errors , []);
2026-11-02 20:52:38 -08:00
console . log ( "PASS mobile horizontal scrolling/edges, native menu/copy/paste, selection/multitouch/cancellation, compact widths/models, aligned timestamp and Copy/Fork footer, Agent layout, shorter viewport, desktop and PWA settings" );
2026-11-02 18:34:44 -08:00
}
} finally { await context . close (); await browser . close (); }