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: [], messages: [{ id: "mobile-assistant", role: "assistant", content: markdown, createdAt: date, name: null, metadata: null }] }; const idle = { state: "idle", isThinking: false, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [] }; const errors = [], calls = []; 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 }; else if (path.endsWith("/workspace-items")) value = { items: [{ ...chat, type: "chat" }] }; 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 }; } 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 }; 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 }))); } 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); // Explicit message actions preserve Fork without replacing the native menu. await page.getByRole("button", { name: "Message actions", exact: true }).click(); await page.getByRole("button", { name: "Fork", exact: true }).waitFor(); await page.keyboard.press("Escape"); 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()); 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); 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(?:,|$)/); await page.setViewportSize({ width: 390, height: 844 }); await drag(300, 8, -160); await page.getByRole("heading", { name: "New chat", exact: true }).waitFor(); assert.deepEqual(errors, []); console.log("PASS mobile horizontal scrolling/edges, native menu/copy/paste, selection/multitouch/cancellation, compact widths/models, Agent layout, shorter viewport, desktop and PWA settings"); } } finally { await context.close(); await browser.close(); }