/** * Layout regressions: * - the English draft page must not overflow horizontally on desktop (1280) or mobile (390) * (the input card's toolbar row help text / button text must wrap to fit the **card width**; * it used to blow out the card under English copy); * - the draft state doesn't show the context-usage ring (no session yet); it shows normally * once inside a session; * - the models page at 390x844 must not overflow horizontally, and text must not overlap * (the group header's provider name used to get pushed out of the button box and overlap * the group-level actions), and its model dialog opts every field out of browser autofill * (the API-key box used to receive the account's saved password, the box above it the * username); * - every chat-page dropdown menu, opened at phone widths (375/390), must keep its panel * inside the viewport and must not shove the page sideways (the model menu used to run * ~34px off-screen left, the skills menu ~92px off-screen right — with its autofocused * search box then horizontally scrolling the whole draft page — and the workspace menu * ~143px off-screen right when the ownership pills share one row); * - no page grows the **document**: the app shell is height-constrained and each page scrolls * inside its own container, so a second scrollbar means either an absolutely positioned * descendant escaped its scroller (the Traces tree and the Agent settings page both had one, * visible only with a second Agent below a long list) or something that cannot shrink no * longer fits — checked at 420/320/240px tall in both sidebar states, since the sidebar's * chrome used to stop fitting below ~412px; * - the sidebar's "New chat" button has no background fill (same gray-scale style as nav items); * - the collapsed rail shows, in product-specified order, last conversation / new chat / * Agents / Skills / Models / Cost Center / Trajectories / Evaluation Center with localized * (en + zh) hover * tooltips; "last conversation" targets the newest non-archived session and is disabled * while none exists; expanding from the rail restores the pinned sidebar; * - login page: a single brand penguin logo above the form (part of the form area; the * background still only has the trace animation), the trace animation grows in after a * delayed blank first paint, no two trace segments cross or touch (except where a fork shares * an endpoint with its parent line), the language / theme controls work, and English sits * left of 中文. */ import { test, expect } from "@playwright/test"; import { provisionAndLogin } from "./auth.mjs"; const BASE = process.env.BASE_URL; const MOCK = process.env.MOCK_URL; const U = "layoutuser"; const P = "password123"; const docWidths = (page) => page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, })); /** Count of pairwise rectangle intersections among visible leaf text elements (2px tolerance; ancestor-descendant pairs excluded). */ const textOverlapCount = (page) => page.evaluate(() => { const isVisible = (el) => { const s = getComputedStyle(el); return s.visibility !== "hidden" && s.display !== "none" && Number(s.opacity) > 0.05; }; const leaves = []; for (const el of document.querySelectorAll("body *")) { if (!isVisible(el)) continue; const hasText = [...el.childNodes].some( (n) => n.nodeType === 3 && n.textContent && n.textContent.trim(), ); if (!hasText) continue; const r = el.getBoundingClientRect(); if (r.width < 2 || r.height < 2) continue; leaves.push({ el, r }); } const TOL = 2; let count = 0; for (let i = 0; i < leaves.length; i += 1) { for (let j = i + 1; j < leaves.length; j += 1) { const a = leaves[i]; const b = leaves[j]; if (a.el.contains(b.el) || b.el.contains(a.el)) continue; const w = Math.min(a.r.right, b.r.right) - Math.max(a.r.left, b.r.left); const h = Math.min(a.r.bottom, b.r.bottom) - Math.max(a.r.top, b.r.top); if (w > TOL && h > TOL) count += 1; } } return count; }); test("layout: en draft + context gauge + mobile models", async ({ page }) => { // English copy is longer, making it the worst case for layout wrapping. await page.addInitScript(() => localStorage.setItem("penguin.lang", "en")); await provisionAndLogin(page.request, U, P); const projects = await (await page.request.get(`${BASE}/api/projects`)).json(); const projectId = projects.projects[0].projectId; const put = await page.request.put(`${BASE}/api/projects/${projectId}/models`, { data: { defaultModel: { provider: "custom", modelId: "claude-4-8" }, models: [ { provider: "custom", modelId: "claude-4-8", apiKey: "sk-mock", contextWindow: 200000, pricing: { cacheRead: 1, cacheWrite: 5, output: 10 }, }, { provider: "openai", modelId: "gpt-5.5", apiKey: "sk-mock2" }, { provider: "google", modelId: "gemini-3-pro" }, ], }, }); expect(put.ok(), "put models").toBeTruthy(); // --- Draft page: must not overflow horizontally on desktop or mobile; no context ring in draft state --- await page.setViewportSize({ width: 1280, height: 720 }); await page.goto(`${BASE}/chat/new`); await page.getByPlaceholder(/Type a message/).waitFor(); let d = await docWidths(page); expect(d.scrollWidth, "draft @1280 no horizontal overflow").toBeLessThanOrEqual(d.clientWidth); await expect(page.locator('[title*="Context usage"]')).toHaveCount(0); // Goal mode keeps its chip compact: the committed budget is a value button, while editing // happens in a fixed upward popover (never inline and never covering the objective textarea). await page.getByRole("button", { name: "More input options" }).click(); await page.getByRole("button", { name: /Goal mode/ }).click(); const budgetTrigger = page.getByRole("button", { name: "Budget unlimited" }); await expect(budgetTrigger).toBeVisible(); await expect(page.getByRole("textbox", { name: "Token budget" })).toHaveCount(0); await budgetTrigger.click(); const budget = page.getByRole("textbox", { name: "Token budget" }); await expect(budget).toBeVisible(); const popoverPosition = await budget.evaluate((el) => { const panel = el.closest(".absolute"); const trigger = panel.parentElement.querySelector('button[aria-expanded="true"]'); const p = panel.getBoundingClientRect(); const t = trigger.getBoundingClientRect(); return { panelBottom: p.bottom, triggerTop: t.top }; }); expect( popoverPosition.panelBottom, "goal budget popover stays above its trigger", ).toBeLessThanOrEqual(popoverPosition.triggerTop); await budget.fill("500k"); await budget.press("Enter"); await expect(page.getByRole("button", { name: "Budget 500k" })).toBeVisible(); await expect(budget).toHaveCount(0); // Invalid edits stay local to the popover: save is disabled and Escape restores the // previously committed value rather than poisoning the send state. const committedBudget = page.getByRole("button", { name: "Budget 500k" }); await committedBudget.click(); await budget.fill("not-a-budget"); await expect(page.getByRole("button", { name: "Save budget" })).toBeDisabled(); await budget.press("Escape"); await expect(committedBudget).toBeVisible(); // Any other close (outside click, toggling the trigger) commits a valid draft instead of // silently dropping it — typing a budget and clicking straight onto Send must keep it. await committedBudget.click(); await budget.fill("750k"); await page.getByPlaceholder(/Type a message/).click(); const recommittedBudget = page.getByRole("button", { name: "Budget 750k" }); await expect(recommittedBudget).toBeVisible(); // An invalid draft refuses to close (outside clicks included) and disables Send — no click // sequence can fire a goal with the stale committed budget while the editor shows garbage. await page.getByPlaceholder(/Type a message/).fill("goal objective"); await recommittedBudget.click(); await budget.fill("not-a-budget"); await expect(page.getByRole("button", { name: "Send", exact: true })).toBeDisabled(); await page.getByPlaceholder(/Type a message/).click(); await expect(budget).toBeVisible(); // Escape is focus-independent: after the refused outside click, focus sits in the // objective textarea — Escape must still cancel the editor from there. await page.keyboard.press("Escape"); await expect(recommittedBudget).toBeVisible(); // Escape cancels from any editor control, not just the input: a valid uncommitted draft // Tab-bed onto the save button still reverts instead of committing. await recommittedBudget.click(); await budget.fill("123k"); await budget.press("Tab"); await page.keyboard.press("Escape"); await expect(recommittedBudget).toBeVisible(); await page.getByPlaceholder(/Type a message/).fill(""); await page.setViewportSize({ width: 390, height: 844 }); await page.waitForTimeout(200); await recommittedBudget.click(); d = await docWidths(page); expect(d.scrollWidth, "goal-mode draft @390 no horizontal overflow").toBeLessThanOrEqual( d.clientWidth, ); const budgetPopoverBounds = await budget.evaluate((el) => { const rect = el.closest(".absolute").getBoundingClientRect(); return { left: rect.left, right: rect.right, viewport: window.innerWidth }; }); expect( budgetPopoverBounds.left, "goal budget popover left edge on-screen", ).toBeGreaterThanOrEqual(0); expect(budgetPopoverBounds.right, "goal budget popover right edge on-screen").toBeLessThanOrEqual( budgetPopoverBounds.viewport, ); // Leave the composer in its normal mode for the remaining layout assertions. await budget.press("Escape"); await page.getByRole("button", { name: "Exit goal mode" }).click(); // --- Session state shows the ring as usual (creating a session via the API and entering it directly, no need to actually run a Task) --- const sess = await ( await page.request.post(`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`, { data: { provider: "custom", modelId: "claude-4-8" }, }) ).json(); await page.setViewportSize({ width: 1280, height: 720 }); await page.goto(`${BASE}/chat/${sess.session.sessionId}`); await page.getByPlaceholder(/Type a message/).waitFor(); await expect(page.locator('[title*="Context usage"]')).toHaveCount(1); // --- Models page @390: must not overflow, text must not overlap --- await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${BASE}/models`); await page.getByText("claude-4-8").first().waitFor(); d = await docWidths(page); expect(d.scrollWidth, "models @390 no horizontal overflow").toBeLessThanOrEqual(d.clientWidth); expect(await textOverlapCount(page), "models @390 no overlapping text").toBe(0); // --- Model dialog: no field may invite the browser's saved login. The dialog's fields are // unowned (no