Files
Sybil-2/web/tests/mobile-workspace.browser.mjs
T

227 lines
16 KiB
JavaScript

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(); }